9 Commits

Author SHA1 Message Date
Bo-Yi Wu
9aafec169b perf: use single poller with semaphore-based capacity control (#822)
## Background

#819 replaced the shared `rate.Limiter` with per-worker exponential backoff counters to add jitter and adaptive polling. Before #819, the poller used:

```go
limiter := rate.NewLimiter(rate.Every(p.cfg.Runner.FetchInterval), 1)
```

This limiter was **shared across all N polling goroutines with burst=1**, effectively serializing their `FetchTask` calls — so even with `capacity=60`, the runner issued roughly one `FetchTask` per `FetchInterval` total.

#819 replaced this with independent per-worker `consecutiveEmpty` / `consecutiveErrors` counters. Each goroutine now backs off **independently**, which inadvertently removed the cross-worker serialization. With `capacity=N`, the runner now has N goroutines each polling on their own schedule — a regression from the pre-#819 baseline for any runner with `capacity > 1`.

(Thanks to @ChristopherHX for catching this in review.)

## Problem

With the post-#819 code:

- `capacity=N` maintains **N persistent polling goroutines**, each calling `FetchTask` independently
- At idle, N goroutines each wake up and send a `FetchTask` RPC per `FetchInterval`
- At full load, N goroutines **continue polling** even though no slot is available to run a new task — every one of those RPCs is wasted
- The `Shutdown()` timeout branch has a pre-existing bug: the "non-blocking check" is actually a blocking receive, so `shutdownJobs()` is never reached on timeout

## Real-World Impact: 3 Runners × capacity=60

Current production environment: 3 runners each with `capacity=60`.

| Metric | Post-#819 (current) | This PR | Reduction |
|--------|---------------------|---------|-----------|
| Polling goroutines (total) | 3 × 60 = **180** | 3 × 1 = **3** | **98.3%** (177 fewer) |
| FetchTask RPCs per poll cycle (idle) | **180** | **3** | **98.3%** |
| FetchTask RPCs per poll cycle (full load) | **180** (all wasted) | **0** (blocked on semaphore) | **100%** |
| Concurrent connections to Gitea | **180** | **3** | **98.3%** |
| Backoff state objects | 180 (per-worker) | 3 (one per runner) | Simplified |

### Idle scenario

All 180 goroutines wake up every `FetchInterval`, each sending a `FetchTask` RPC that returns empty. Server handles 180 RPCs per cycle for zero useful work. After this PR: **3 RPCs per cycle** — one per runner.

> Note: pre-#819 idle behavior was already ~3 RPCs/cycle due to the shared `rate.Limiter`. This PR restores that property while also addressing the full-load case below.

### Full-load scenario (all 180 slots occupied)

All 180 goroutines **continue polling** even though no slot is available. Every RPC is wasted. After this PR: all 3 pollers are **blocked on the semaphore** — **zero RPCs** until a task completes.

> This is a scenario neither the pre-#819 shared limiter nor the post-#819 per-worker backoff handles — both still issue `FetchTask` RPCs when no slot is free. The semaphore is the only approach of the three that ties polling to available capacity.

## Why Not Just Revert to `rate.Limiter`?

Reverting would restore the serialized behavior but is not the right long-term fix:

- **`rate.Limiter` has no concept of available capacity.** At full load it still hands out tokens and issues `FetchTask` RPCs that can't be acted on. The semaphore blocks polling entirely in that case — zero wasted RPCs.
- **It composes poorly with adaptive backoff from #819.** A shared limiter and per-worker backoff pull in different directions.
- **N goroutines serializing on a shared limiter means N-1 of them exist only to wait in line.** A single poller expresses the same behavior more directly.

The semaphore approach ties polling to capacity explicitly: `acquire slot → fetch → dispatch → release`. That invariant becomes structural rather than emergent from a rate limiter.

## Solution

Replace N polling goroutines with a **single polling loop** that uses a buffered channel as a semaphore to control concurrent task execution:

```go
// New: poller.go Poll()
sem := make(chan struct{}, p.cfg.Runner.Capacity)
for {
    select {
    case sem <- struct{}{}:       // Acquire slot (blocks at capacity)
    case <-p.pollingCtx.Done():
        return
    }
    task, ok := p.fetchTask(...)  // Single FetchTask RPC
    if !ok {
        <-sem                     // Release slot on empty response
        // backoff...
        continue
    }
    go func(t *runnerv1.Task) {   // Dispatch task
        defer func() { <-sem }()  // Release slot when done
        p.runTaskWithRecover(p.jobsCtx, t)
    }(task)
}
```

The exponential backoff and jitter from #819 are preserved — just driven by a single `workerState` instead of N per-worker states.

## Shutdown Bug Fix

Fixed a pre-existing bug in `Shutdown()` where the timeout branch could never force-cancel running jobs:

```go
// Before (BROKEN): blocking receive, shutdownJobs() never reached
_, ok := <-p.done   // blocks until p.done is closed
if !ok { return nil }
p.shutdownJobs()    // dead code when jobs are still running

// After (FIXED): proper non-blocking check
select {
case <-p.done:
    return nil
default:
}
p.shutdownJobs()    // now correctly reached on timeout
```

## Code Changes

| Area | Detail |
|------|--------|
| `Poller.runner` | `*run.Runner` → `TaskRunner` interface (enables mock-based testing) |
| `Poll()` | N goroutines → single loop with buffered-channel semaphore |
| `PollOnce()` | Inlined from removed `pollOnce()` |
| `waitBackoff()` | New helper, eliminates duplicated backoff logic |
| `resetBackoff()` | New method on `workerState`, also resets stale `lastBackoff` metric |
| `Shutdown()` | Fixed blocking receive → proper non-blocking select |
| Removed | `poll()`, `pollOnce()` private methods (-2 methods, -42 lines) |

## Test Coverage

Added `TestPoller_ConcurrencyLimitedByCapacity` which verifies:

- With `capacity=3`, at most 3 tasks execute concurrently (`maxConcurrent <= 3`)
- Tasks actually overlap in execution (`maxConcurrent >= 2`)
- `FetchTask` is never called concurrently — confirms single poller (`maxFetchConcur == 1`)
- All 6 tasks complete successfully (`totalCompleted == 6`)
- Mock runner respects context cancellation, enabling shutdown path verification

```
=== RUN   TestPoller_ConcurrencyLimitedByCapacity
--- PASS: TestPoller_ConcurrencyLimitedByCapacity (0.10s)
PASS
ok  	gitea.com/gitea/act_runner/internal/app/poll	0.59s
```

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

Reviewed-on: https://gitea.com/gitea/act_runner/pulls/822
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Bo-Yi Wu <appleboy.tw@gmail.com>
Co-committed-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2026-04-19 08:10:23 +00:00
silverwind
48944e136c Use golangci-lint fmt to format code (#823)
Use `golangci-lint fmt` to format code, replacing the previous gofumpt-based formatter. https://github.com/daixiang0/gci is used to order the imports.

Also drops the `gitea-vet` dependency since `gci` now handles import ordering.

Mirrors https://github.com/go-gitea/gitea/pull/37194.

---
This PR was written with the help of Claude Opus 4.7

Reviewed-on: https://gitea.com/gitea/act_runner/pulls/823
Reviewed-by: Nicolas <173651+bircni@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-04-17 22:05:01 +00:00
Bo-Yi Wu
40dcee0991 chore(deps): upgrade golangci-lint from v2.10.1 to v2.11.4 (#821)
## Summary
- Bump golangci-lint from v2.10.1 to v2.11.4
- Remove unused `//nolint:revive` directive on metrics package declaration (detected by stricter nolintlint in new version)

## Changes between v2.10.1 and v2.11.4
- **v2.11.0** — Multiple linter dependency upgrades, Go 1.26 support
- **v2.11.2** — Bug fix for `fmt` with path
- **v2.11.3** — gosec update
- **v2.11.4** — Dependency updates (sqlclosecheck, noctx, etc.)

No breaking changes.

Reviewed-on: https://gitea.com/gitea/act_runner/pulls/821
Co-authored-by: Bo-Yi Wu <appleboy.tw@gmail.com>
Co-committed-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2026-04-15 03:56:34 +00:00
Bo-Yi Wu
f33e5a6245 feat: add Prometheus metrics endpoint for runner observability (#820)
## What

Add an optional Prometheus `/metrics` HTTP endpoint to `act_runner` so operators can observe runner health, polling behavior, job outcomes, and RPC latency without scraping logs.

New surface:

- `internal/pkg/metrics/metrics.go` — metric definitions, custom `Registry`, static Go/process collectors, label constants, `ResultToStatusLabel` helper.
- `internal/pkg/metrics/server.go` — hardened `http.Server` serving `/metrics` and `/healthz` with Slowloris-safe timeouts (`ReadHeaderTimeout` 5s, `ReadTimeout`/`WriteTimeout` 10s, `IdleTimeout` 60s) and a 5s graceful shutdown.
- `daemon.go` wires it up behind `cfg.Metrics.Enabled` (disabled by default).
- `poller.go` / `reporter.go` / `runner.go` instrument their existing hot paths with counters/histograms/gauges — no behavior change.

Metrics exported (namespace `act_runner_`):

| Subsystem | Metric | Type | Labels |
|---|---|---|---|
| — | `info` | Gauge | `version`, `name` |
| — | `capacity`, `uptime_seconds` | Gauge | — |
| `poll` | `fetch_total`, `client_errors_total` | Counter | `result` / `method` |
| `poll` | `fetch_duration_seconds`, `backoff_seconds` | Histogram / Gauge | — |
| `job` | `total` | Counter | `status` |
| `job` | `duration_seconds`, `running`, `capacity_utilization_ratio` | Histogram / GaugeFunc | — |
| `report` | `log_total`, `state_total` | Counter | `result` |
| `report` | `log_duration_seconds`, `state_duration_seconds` | Histogram | — |
| `report` | `log_buffer_rows` | Gauge | — |
| — | `go_*`, `process_*` | standard collectors | — |

All label values are predefined constants — **no high-cardinality labels** (no task IDs, repo URLs, branches, tokens, or secrets) so scraping is safe and bounded.

## Why

Teams self-hosting Gitea + `act_runner` at scale need to answer basic SRE questions that are currently invisible:

- How often are RPCs failing? Which RPC? (`act_runner_client_errors_total`)
- Are runners saturated? (`act_runner_job_capacity_utilization_ratio`, `act_runner_job_running`)
- How long do jobs take? (`act_runner_job_duration_seconds`)
- Is polling backing off? (`act_runner_poll_backoff_seconds`, `act_runner_poll_fetch_total{result=\"error\"}`)
- Are log/state reports slow? (`act_runner_report_{log,state}_duration_seconds`)
- Is the log buffer draining? (`act_runner_report_log_buffer_rows`)

Today operators have to grep logs. This PR makes all of the above first-class metrics so they can feed dashboards and alerts (`rate(act_runner_client_errors_total[5m]) > 0.1`, capacity saturation alerts, etc.).

The endpoint is **disabled by default** and binds to `127.0.0.1:9101` when enabled, so it's opt-in and safe for existing deployments.

## How

### Config

```yaml
metrics:
  enabled: false           # opt-in
  addr: 127.0.0.1:9101     # change to 0.0.0.0:9101 only behind a reverse proxy
```

`config.example.yaml` documents both fields plus a security note about binding externally without auth.

### Wiring

1. `daemon.go` calls `metrics.Init()` (guarded by `sync.Once`), sets `act_runner_info`, `act_runner_capacity`, registers uptime + running-jobs GaugeFuncs, then starts the server goroutine with the daemon context — it shuts down cleanly on `ctx.Done()`.
2. `poller.fetchTask` observes RPC latency / result / error counters. `DeadlineExceeded` (long-poll idle) is treated as an empty result and **not** observed into the histogram so the 5s timeout doesn't swamp the buckets.
3. `poller.pollOnce` reports `poll_backoff_seconds` using the pre-jitter base interval (the true backoff level), and only when it changes — prevents noisy no-op gauge updates at the `FetchIntervalMax` plateau.
4. `reporter.ReportLog` / `ReportState` record duration histograms and success/error counters; `log_buffer_rows` is updated only when the value changes, guarded by the already-held `clientM`.
5. `runner.Run` observes `job_duration_seconds` and increments `job_total` by outcome via `metrics.ResultToStatusLabel`.

### Safety / security review

- All timeouts set; Slowloris-safe.
- Custom `prometheus.NewRegistry()` — no global registration side-effects.
- No sensitive data in labels (reviewed every instrumentation site).
- Single new dependency: `github.com/prometheus/client_golang v1.23.2`.
- Endpoint is unauthenticated by design and documented as such; default localhost bind mitigates exposure. Operators exposing externally should front it with a reverse proxy.

## Verification

### Unit tests

\`\`\`bash
go build ./...
go vet ./...
go test ./...
\`\`\`

### Manual smoke test

1. Enable metrics in `config.yaml`:
   \`\`\`yaml
   metrics:
     enabled: true
     addr: 127.0.0.1:9101
   \`\`\`
2. Start the runner against a Gitea instance: \`./act_runner daemon\`.
3. Scrape the endpoint:
   \`\`\`bash
   curl -s http://127.0.0.1:9101/metrics | grep '^act_runner_'
   curl -s http://127.0.0.1:9101/healthz   # → ok
   \`\`\`
4. Confirm the static series appear immediately: \`act_runner_info\`, \`act_runner_capacity\`, \`act_runner_uptime_seconds\`, \`act_runner_job_running\`, \`act_runner_job_capacity_utilization_ratio\`.
5. Trigger a workflow and confirm counters increment: \`act_runner_poll_fetch_total{result=\"task\"}\`, \`act_runner_job_total{status=\"success\"}\`, \`act_runner_report_log_total{result=\"success\"}\`.
6. Leave the runner idle and confirm \`act_runner_poll_backoff_seconds\` settles (and does **not** churn on every poll).
7. Ctrl-C and confirm a clean \"metrics server shutdown\" log line (no port-in-use error on restart within 5s).

### Prometheus integration

Add to \`prometheus.yml\`:

\`\`\`yaml
scrape_configs:
  - job_name: act_runner
    static_configs:
      - targets: ['127.0.0.1:9101']
\`\`\`

Sample alert to try:

\`\`\`
sum(rate(act_runner_client_errors_total[5m])) by (method) > 0.1
\`\`\`

## Out of scope (follow-ups)

- TLS and auth on the metrics endpoint (mitigated today by localhost default; add when operators need external scraping).
- Per-task labels (intentionally avoided for cardinality safety).

---

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

Reviewed-on: https://gitea.com/gitea/act_runner/pulls/820
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Bo-Yi Wu <appleboy.tw@gmail.com>
Co-committed-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2026-04-15 01:27:34 +00:00
Bo-Yi Wu
f2d545565f perf: reduce runner-to-server connection load with adaptive reporting and polling (#819)
## Summary

Many teams self-host Gitea + Act Runner at scale. The current runner design causes excessive HTTP requests to the Gitea server, leading to high server load. This PR addresses three root causes: aggressive fixed-interval polling, per-task status reporting every 1 second regardless of activity, and unoptimized HTTP client configuration.

## Problem

The original architecture has these issues:

**1. Fixed 1-second reporting interval (RunDaemon)**

- Every running task calls ReportLog + ReportState every 1 second (2 HTTP requests/sec/task)
- These requests are sent even when there are no new log rows or state changes
- With 200 runners × 3 tasks each = **1,200 req/sec just for status reporting**

**2. Fixed 2-second polling interval (no backoff)**

- Idle runners poll FetchTask every 2 seconds forever, even when no jobs are queued
- No exponential backoff or jitter — all runners can synchronize after network recovery (thundering herd)
- 200 idle runners = **100 req/sec doing nothing useful**

**3. HTTP client not tuned**

- Uses http.DefaultClient with MaxIdleConnsPerHost=2, causing frequent TCP/TLS reconnects
- Creates two separate http.Client instances (one for Ping, one for Runner service) instead of sharing

**Total: ~1,300 req/sec for 200 runners with 3 tasks each**

## Solution

### Adaptive Event-Driven Log Reporting

Replace the recursive `time.AfterFunc(1s)` pattern in RunDaemon with a goroutine-based select event loop using three trigger mechanisms:

| Trigger | Default | Purpose |
|---------|---------|---------|
| `log_report_max_latency` | 3s | Guarantee even a single log line is delivered within this time |
| `log_report_interval` | 5s | Periodic sweep — steady-state cadence |
| `log_report_batch_size` | 100 rows | Immediate flush during bursty output (e.g., npm install) |

**Key design**: `log_report_max_latency` (3s) must be less than `log_report_interval` (5s) so the max-latency timer fires before the periodic ticker for single-line scenarios.

State reporting is decoupled to its own `state_report_interval` (default 5s), with immediate flush on step transitions (start/stop) via a stateNotify channel for responsive frontend UX.

Additionally:
- Skip ReportLog when `len(rows) == 0` (no pending log rows)
- Skip ReportState when `stateChanged == false && len(outputs) == 0` (nothing changed)
- Move expensive `proto.Clone` after the early-return check to avoid deep copies on no-op paths

### Polling Backoff with Jitter

Replace fixed `rate.Limiter` with adaptive exponential backoff:
- Track `consecutiveEmpty` and `consecutiveErrors` counters
- Interval doubles with each empty/error response: `base × 2^(n-1)`, capped at `fetch_interval_max` (default 60s)
- Add ±20% random jitter to prevent thundering herd
- Fetch first, sleep after ��� preserves burst=1 behavior for immediate first fetch on startup and after task completion

### HTTP Client Tuning

- Configure custom `http.Transport` with `MaxIdleConnsPerHost=10` (was 2)
- Share a single `http.Client` between PingService and RunnerService
- Add `IdleConnTimeout=90s` for clean connection lifecycle

## Load Reduction

For 200 runners × 3 tasks (70% with active log output):

| Component | Before | After | Reduction |
|-----------|--------|-------|-----------|
| Polling (idle) | 100 req/s | ~3.4 req/s | 97% |
| Log reporting | 420 req/s | ~84 req/s | 80% |
| State reporting | 126 req/s | ~25 req/s | 80% |
| **Total** | **~1,300 req/s** | **~113 req/s** | **~91%** |

## Frontend UX Impact

| Scenario | Before | After | Notes |
|----------|--------|-------|-------|
| Continuous output (npm install) | ~1s | ~5s | Periodic ticker sweep |
| Single line then silence | ~1s | ≤3s | maxLatencyTimer guarantee |
| Bursty output (100+ lines) | ~1s | <1s | Batch size immediate flush |
| Step start/stop | ~1s | <1s | stateNotify immediate flush |
| Job completion | ~1s | ~1s | Close() retry unchanged |

## New Configuration Options

All have safe defaults — existing config files need no changes:

```yaml
runner:
  fetch_interval_max: 60s        # Max backoff interval when idle
  log_report_interval: 5s        # Periodic log flush interval
  log_report_max_latency: 3s     # Max time a log row waits (must be < log_report_interval)
  log_report_batch_size: 100     # Immediate flush threshold
  state_report_interval: 5s      # State flush interval (step transitions are always immediate)
```

Config validation warns on invalid combinations:
- `fetch_interval_max < fetch_interval` → auto-corrected
- `log_report_max_latency >= log_report_interval` → warning (timer would be redundant)

## Test Plan

- [x] `go build ./...` passes
- [x] `go test ./...` passes (all existing + 3 new tests)
- [x] `golangci-lint run` — 0 issues
- [x] TestReporter_MaxLatencyTimer — verifies single log line flushed by maxLatencyTimer before logTicker
- [x] TestReporter_BatchSizeFlush — verifies batch size threshold triggers immediate flush
- [x] TestReporter_StateNotifyFlush — verifies step transition triggers immediate state flush
- [x] TestReporter_EphemeralRunnerDeletion — verifies Close/RunDaemon race safety
- [x] TestReporter_RunDaemonClose_Race — verifies concurrent Close safety

Reviewed-on: https://gitea.com/gitea/act_runner/pulls/819
Reviewed-by: Nicolas <173651+bircni@noreply.gitea.com>
Co-authored-by: Bo-Yi Wu <appleboy.tw@gmail.com>
Co-committed-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2026-04-14 11:29:25 +00:00
Lunny Xiao
90c1275f0e Upgrade yaml (#816)
~wait https://gitea.com/gitea/act/pulls/157~

Reviewed-on: https://gitea.com/gitea/act_runner/pulls/816
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
2026-03-28 16:18:47 +00:00
Zettat123
505907eb2a Add run_attempt to context (#632)
Blocked by https://gitea.com/gitea/act/pulls/126
Fix https://github.com/go-gitea/gitea/issues/33135

---------

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-on: https://gitea.com/gitea/act_runner/pulls/632
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
Co-committed-by: Zettat123 <zettat123@gmail.com>
2026-03-26 20:07:22 +00:00
silverwind
9933ea0d92 feat: add configurable bind_workdir option with workspace cleanup for DinD setups (#810)
## Summary

Adds a `container.bind_workdir` config option that exposes the nektos/act `BindWorkdir` setting. When enabled, workspaces are bind-mounted from the host filesystem instead of Docker volumes, which is required for DinD setups where jobs use `docker compose` with bind mounts (e.g. `.:/app`).

Each job gets an isolated workspace at `/workspace/<task_id>/<owner>/<repo>` to prevent concurrent jobs from the same repo interfering with each other. The task directory is cleaned up after job execution.

### Configuration

```yaml
container:
  bind_workdir: true
```

When using this with DinD, also mount the workspace parent into the runner container and add it to `valid_volumes`:
```yaml
container:
  valid_volumes:
    - /workspace/**
```

*This PR was authored by Claude (AI assistant)*

Reviewed-on: https://gitea.com/gitea/act_runner/pulls/810
Reviewed-by: ChristopherHX <38043+christopherhx@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-03-03 10:15:06 +00:00
RoboMagus
5dd5436169 Semver tags for Docker images (#720)
The main Gitea docker image is already distributed with proper semver tags, such that users can pin to e.g. the minor version and still pull in patch releases.  This is something that has been lacking on the act runner images.

This PR expands the docker image tag versioning strategy such that when `v0.2.13` is released the following image tags are produced:

Basic:
 - `0`
 - `0.2`
 - `0.2.13`
 - `latest`

DinD:
 - `0-dind`
 - `0.2-dind`
 - `0.2.13-dind`
 - `latest-dind`

DinD-Rootless:
 - `0-dind-rootless`
 - `0.2-dind-rootless`
 - `0.2.13-dind-rootless`
 - `latest-dind-rootless`

To verify the `docker/metadata-action` produces the expected results in a Gitea workflow environment I've executed a release workflow. Results can be seen in [this run](https://gitea.com/RoboMagus/gitea_act_runner/actions/runs/14). (Note though that as the repository name of my fork changed from `act_runner` to `gitea_act_runner` this is reflected in the produced docker tags in this test run!)

---------

Co-authored-by: RoboMagus <68224306+RoboMagus@users.noreply.github.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: techknowlogick <techknowlogick@noreply.gitea.com>
Reviewed-on: https://gitea.com/gitea/act_runner/pulls/720
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: RoboMagus <robomagus@noreply.gitea.com>
Co-committed-by: RoboMagus <robomagus@noreply.gitea.com>
2026-02-25 19:09:53 +00:00
1937 changed files with 1599 additions and 527331 deletions

View File

@@ -1,6 +0,0 @@
[codespell]
# Ref: https://github.com/codespell-project/codespell#using-a-config-file
skip = .git*,go.sum,package-lock.json,*.min.*,.codespellrc,testdata,./pkg/runner/hashfiles/index.js
check-hidden = true
ignore-regex = .*Te\{0\}st.*
# ignore-words-list =

View File

@@ -1,156 +0,0 @@
name: checks
on: [pull_request, workflow_dispatch]
concurrency:
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.ref }}
env:
ACT_OWNER: ${{ github.repository_owner }}
ACT_REPOSITORY: ${{ github.repository }}
CGO_ENABLED: 0
NO_QEMU: 1
NO_EXTERNAL_IP: 1
DOOD: 1
jobs:
lint:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
- uses: golangci/golangci-lint-action@v8.0.0
with:
version: v2.1.6
- uses: megalinter/megalinter/flavors/go@v9.1.0
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VALIDATE_ALL_CODEBASE: false
GITHUB_STATUS_REPORTER: ${{ !env.ACT }}
GITHUB_COMMENT_REPORTER: ${{ !env.ACT }}
test-linux:
name: test-linux
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 2
- name: Cleanup Docker Engine
run: |
docker ps -a --format '{{ if eq (truncate .Names 4) "act-" }}
{{ .ID }}
{{end}}' | xargs -r docker rm -f || :
docker volume ls --format '{{ if eq (truncate .Name 4) "act-" }}
{{ .Name }}
{{ end }}' | xargs -r docker volume rm -f || :
docker images --format '{{ if eq (truncate .Repository 4) "act-" }}
{{ .ID }}
{{ end }}' | xargs -r docker rmi -f || :
docker images -q | xargs -r docker rmi || :
- name: Set up QEMU
if: '!env.NO_QEMU'
uses: docker/setup-qemu-action@v3
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
- uses: actions/cache@v4
if: ${{ !env.ACT }}
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Install gotestfmt
run: go install github.com/gotesttools/gotestfmt/v2/cmd/gotestfmt@v2.5.0
# Regressions by Gitea Actions CI Migration
# GITHUB_REPOSITORY contains the server url
# ACTIONS_RUNTIME_URL provided to every step, act does not override
- name: Run Tests
run: |
unset ACTIONS_RUNTIME_URL
unset ACTIONS_RESULTS_URL
unset ACTIONS_RUNTIME_TOKEN
export GITHUB_REPOSITORY="${GITHUB_REPOSITORY#${SERVER_URL%/}/}"
export ACT_REPOSITORY="${GITHUB_REPOSITORY#${SERVER_URL%/}/}"
export ACT_OWNER="${ACT_OWNER#${SERVER_URL%/}/}"
env
go test -json -v -cover -coverpkg=./... -coverprofile=coverage.txt -covermode=atomic -timeout 20m ./... | gotestfmt -hide successful-packages,empty-packages 2>&1
env:
SERVER_URL: ${{ github.server_url }}
- name: Run act from cli
run: go run main.go -P ubuntu-latest=node:16-buster-slim -C ./pkg/runner/testdata/ -W ./basic/push.yml
- name: Run act from cli without docker support
run: go run -tags WITHOUT_DOCKER main.go -P ubuntu-latest=-self-hosted -C ./pkg/runner/testdata/ -W ./local-action-js/push.yml
snapshot:
name: snapshot
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
- uses: actions/cache@v4
if: ${{ !env.ACT }}
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: GoReleaser
id: goreleaser
uses: goreleaser/goreleaser-action@v6
with:
version: v2
args: release --snapshot --clean
- name: Setup Node
continue-on-error: true
uses: actions/setup-node@v6
with:
node-version: 20
- name: Install @actions/artifact@2.1.0
continue-on-error: true
run: npm install @actions/artifact@2.1.0
- name: Upload All
uses: actions/github-script@v8
continue-on-error: true
with:
script: |
// We do not use features depending on GITHUB_API_URL so we can hardcode it to avoid the GHES no support error
process.env["GITHUB_SERVER_URL"] = "https://github.com";
const {DefaultArtifactClient} = require('@actions/artifact');
const aartifact = new DefaultArtifactClient();
var artifacts = JSON.parse(process.env.ARTIFACTS);
for(var artifact of artifacts) {
if(artifact.type === "Binary") {
const {id, size} = await aartifact.uploadArtifact(
// name of the artifact
`${artifact.name}-${artifact.target}`,
// files to include (supports absolute and relative paths)
[artifact.path],
process.cwd(),
{
// optional: how long to retain the artifact
// if unspecified, defaults to repository/org retention settings (the limit of this value)
retentionDays: 10
}
);
console.log(`Created artifact with id: ${id} (bytes: ${size}`);
}
}
env:
ARTIFACTS: ${{ steps.goreleaser.outputs.artifacts }}
- name: Chocolatey
uses: ./.github/actions/choco
with:
version: v0.0.0-pr

View File

@@ -39,6 +39,15 @@ jobs:
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
release-image:
runs-on: ubuntu-latest
strategy:
matrix:
variant:
- target: basic
tag_suffix: ""
- target: dind
tag_suffix: "-dind"
- target: dind-rootless
tag_suffix: "-dind-rootless"
container:
image: catthehacker/ubuntu:act-latest
env:
@@ -62,50 +71,33 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Get Meta
id: meta
- name: Repo Meta
id: repo_meta
run: |
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
echo REPO_VERSION=${GITHUB_REF_NAME#v} >> $GITHUB_OUTPUT
- name: "Docker meta"
id: docker_meta
uses: https://github.com/docker/metadata-action@v5
with:
images: |
${{ env.DOCKER_ORG }}/${{ steps.repo_meta.outputs.REPO_NAME }}
tags: |
type=semver,pattern={{major}}.{{minor}}.{{patch}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
flavor: |
latest=true
suffix=${{ matrix.variant.tag_suffix }},onlatest=true
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
target: basic
target: ${{ matrix.variant.target }}
platforms: |
linux/amd64
linux/arm64
push: true
tags: |
${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }}
- name: Build and push dind
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
target: dind
platforms: |
linux/amd64
linux/arm64
push: true
tags: |
${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}-dind
${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }}-dind
- name: Build and push dind-rootless
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
target: dind-rootless
platforms: |
linux/amd64
linux/arm64
push: true
tags: |
${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}-dind-rootless
${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }}-dind-rootless
tags: ${{ steps.docker_meta.outputs.tags }}

View File

@@ -1,72 +0,0 @@
name: release
on:
push:
tags:
- v*
jobs:
release:
# TODO use environment to scope secrets
name: release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
- uses: actions/cache@v4
if: ${{ !env.ACT }}
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
version: latest
args: release --clean -f ./.goreleaser.yml -f ./.goreleaser.gitea.yml
env:
GITEA_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN || github.token }}
- name: Winget
uses: vedantmgoyal2009/winget-releaser@v2
with:
identifier: nektos.act
installers-regex: '_Windows_\w+\.zip$'
token: ${{ secrets.WINGET_TOKEN }}
if: env.ENABLED
env:
ENABLED: ${{ secrets.WINGET_TOKEN && '1' || '' }}
- name: Chocolatey
uses: ./.github/actions/choco
with:
version: ${{ github.ref }}
apiKey: ${{ secrets.CHOCO_APIKEY }}
push: true
if: env.ENABLED
env:
ENABLED: ${{ secrets.CHOCO_APIKEY && '1' || '' }}
# TODO use ssh deployment key
- name: GitHub CLI extension
uses: actions/github-script@v8
with:
github-token: ${{ secrets.CLI_GITHUB_TOKEN || secrets.GORELEASER_GITHUB_TOKEN }}
script: |
const mainRef = (await github.rest.git.getRef({
owner: context.repo.owner,
repo: 'gh-act',
ref: 'heads/main',
})).data;
console.log(mainRef);
github.rest.git.createRef({
owner: 'nektos',
repo: 'gh-act',
ref: context.ref,
sha: mainRef.object.sha,
});
if: env.ENABLED
env:
ENABLED: ${{ (secrets.CLI_GITHUB_TOKEN || secrets.GORELEASER_GITHUB_TOKEN) && '1' || '' }}

View File

@@ -1,88 +0,0 @@
name: Bug report
description: Use this template for reporting bugs/issues.
labels:
- 'kind/bug'
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: textarea
id: act-debug
attributes:
label: Bug report info
render: plain text
description: |
Output of `act --bug-report`
placeholder: |
act --bug-report
validations:
required: true
- type: textarea
id: act-command
attributes:
label: Command used with act
description: |
Please paste your whole command
placeholder: |
act -P ubuntu-latest=node:12 -v -d ...
render: sh
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: Describe issue
description: |
Also tell us what did you expect to happen?
placeholder: |
Describe issue
validations:
required: true
- type: input
id: repo
attributes:
label: Link to GitHub repository
description: |
Provide link to GitHub repository, you can skip it if the repository is private or you don't have it on GitHub, otherwise please provide it as it might help us troubleshoot problem
placeholder: |
https://github.com/nektos/act
validations:
required: false
- type: textarea
id: workflow
attributes:
label: Workflow content
description: |
Please paste your **whole** workflow here
placeholder: |
name: My workflow
on: ['push', 'schedule']
jobs:
test:
runs-on: ubuntu-latest
env:
KEY: VAL
[...]
render: yml
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant log output
description: |
Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. Please verify that the log output doesn't contain any sensitive data.
render: sh
placeholder: |
Use `act -v` for verbose output
validations:
required: true
- type: textarea
id: additional-info
attributes:
label: Additional information
placeholder: |
Additional information that doesn't fit elsewhere
validations:
required: false

View File

@@ -1,8 +0,0 @@
blank_issues_enabled: true
contact_links:
- name: Start a discussion
url: https://github.com/actions-oss/act-cli/discussions/new
about: You can ask for help here!
- name: Want to contribute to act?
url: https://github.com/actions-oss/act-cli/blob/main/CONTRIBUTING.md
about: Be sure to read contributing guidelines!

View File

@@ -1,28 +0,0 @@
name: Feature request
description: Use this template for requesting a feature/enhancement.
labels:
- 'kind/feature-request'
body:
- type: markdown
attributes:
value: |
Please note that incompatibility with GitHub Actions should be opened as a bug report, not a new feature.
- type: input
id: act-version
attributes:
label: Act version
description: |
What version of `act` are you using? Version can be obtained via `act --version`
If you've built it from source, please provide commit hash
placeholder: |
act --version
validations:
required: true
- type: textarea
id: feature
attributes:
label: Feature description
description: Describe feature that you would like to see
placeholder: ...
validations:
required: true

View File

@@ -1,20 +0,0 @@
FROM alpine:3.21
ARG CHOCOVERSION=1.1.0
RUN apk add --no-cache bash ca-certificates git \
&& apk --no-cache --repository http://dl-cdn.alpinelinux.org/alpine/edge/community add mono mono-dev \
&& cert-sync /etc/ssl/certs/ca-certificates.crt \
&& wget "https://github.com/chocolatey/choco/archive/${CHOCOVERSION}.tar.gz" -O- | tar -xzf - \
&& cd choco-"${CHOCOVERSION}" \
&& chmod +x build.sh zip.sh \
&& ./build.sh -v \
&& mv ./code_drop/chocolatey/console /opt/chocolatey \
&& mkdir -p /opt/chocolatey/lib \
&& rm -rf /choco-"${CHOCOVERSION}" \
&& apk del mono-dev \
&& rm -rf /var/cache/apk/*
ENV ChocolateyInstall=/opt/chocolatey
COPY entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -1,16 +0,0 @@
name: 'Chocolatey Packager'
description: 'Create the choco package and push it'
inputs:
version:
description: 'Version of package'
required: false
apiKey:
description: 'API Key for chocolately'
required: false
push:
description: 'Option for if package is going to be pushed'
required: false
default: 'false'
runs:
using: 'docker'
image: 'Dockerfile'

View File

@@ -1,31 +0,0 @@
#!/bin/bash
set -e
function choco {
mono /opt/chocolatey/choco.exe "$@" --allow-unofficial --nocolor
}
function get_version {
local version=${INPUT_VERSION:-$(git describe --tags)}
version=(${version//[!0-9.-]/})
local version_parts=(${version//-/ })
version=${version_parts[0]}
if [ ${#version_parts[@]} -gt 1 ]; then
version=${version_parts}.${version_parts[1]}
fi
echo "$version"
}
## Determine the version to pack
VERSION=$(get_version)
echo "Packing version ${VERSION} of act"
rm -f act-cli.*.nupkg
mkdir -p tools
cp LICENSE tools/LICENSE.txt
cp VERIFICATION tools/VERIFICATION.txt
cp dist/act-cli_windows_amd64*/act.exe tools/
choco pack act-cli.nuspec --version ${VERSION}
if [[ "$INPUT_PUSH" == "true" ]]; then
choco push act-cli.${VERSION}.nupkg --api-key ${INPUT_APIKEY} -s https://push.chocolatey.org/ --timeout 180
fi

View File

@@ -1,23 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: 'github-actions'
directory: '/'
schedule:
interval: 'monthly'
groups:
dependencies:
patterns:
- '*'
- package-ecosystem: 'gomod'
directory: '/'
schedule:
interval: 'monthly'
groups:
dependencies:
patterns:
- '*'

View File

@@ -1 +0,0 @@
test-*.yml

View File

@@ -1,151 +0,0 @@
name: checks
on: [pull_request, workflow_dispatch]
concurrency:
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.ref }}
env:
ACT_OWNER: ${{ github.repository_owner }}
ACT_REPOSITORY: ${{ github.repository }}
CGO_ENABLED: 0
jobs:
lint:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
- uses: golangci/golangci-lint-action@v8.0.0
with:
version: v2.1.6
- uses: megalinter/megalinter/flavors/go@v9.1.0
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VALIDATE_ALL_CODEBASE: false
GITHUB_STATUS_REPORTER: ${{ !env.ACT }}
GITHUB_COMMENT_REPORTER: ${{ !env.ACT }}
test-linux:
name: test-linux
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 2
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
- uses: actions/cache@v4
if: ${{ !env.ACT }}
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Install gotestfmt
run: go install github.com/gotesttools/gotestfmt/v2/cmd/gotestfmt@v2.5.0
- name: Run Tests
run: go test -json -v -cover -coverpkg=./... -coverprofile=coverage.txt -covermode=atomic -timeout 20m ./... | gotestfmt -hide successful-packages,empty-packages 2>&1
- name: Run act from cli
run: go run main.go -P ubuntu-latest=node:16-buster-slim -C ./pkg/runner/testdata/ -W ./basic/push.yml
- name: Run act from cli without docker support
run: go run -tags WITHOUT_DOCKER main.go -P ubuntu-latest=-self-hosted -C ./pkg/runner/testdata/ -W ./local-action-js/push.yml
- name: Upload Codecov report
uses: codecov/codecov-action@v5
with:
files: coverage.txt
fail_ci_if_error: true # optional (default = false)
token: ${{ secrets.CODECOV_TOKEN }}
test-host:
strategy:
matrix:
os:
- windows-latest
- macos-latest
name: test-host-${{matrix.os}}
runs-on: ${{matrix.os}}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 2
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
- name: Install gotestfmt
run: go install github.com/gotesttools/gotestfmt/v2/cmd/gotestfmt@v2.5.0
- name: Run Tests
run: go test -v -cover -coverpkg=./... -coverprofile=coverage.txt -covermode=atomic -timeout 20m -run ^TestRunEventHostEnvironment$ ./...
shell: bash
snapshot:
name: snapshot
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
- uses: actions/cache@v4
if: ${{ !env.ACT }}
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: GoReleaser
id: goreleaser
uses: goreleaser/goreleaser-action@v6
with:
version: v2
args: release --snapshot --clean
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 20
- name: Install @actions/artifact
run: npm install @actions/artifact
- name: Upload All
uses: actions/github-script@v8
with:
script: |
const {DefaultArtifactClient} = require('@actions/artifact');
const aartifact = new DefaultArtifactClient();
var artifacts = JSON.parse(process.env.ARTIFACTS);
for(var artifact of artifacts) {
if(artifact.type === "Binary") {
const {id, size} = await aartifact.uploadArtifact(
// name of the artifact
`${artifact.name}-${artifact.target}`,
// files to include (supports absolute and relative paths)
[artifact.path],
process.cwd(),
{
// optional: how long to retain the artifact
// if unspecified, defaults to repository/org retention settings (the limit of this value)
retentionDays: 10
}
);
console.log(`Created artifact with id: ${id} (bytes: ${size}`);
}
}
env:
ARTIFACTS: ${{ steps.goreleaser.outputs.artifacts }}
- name: Chocolatey
uses: ./.github/actions/choco
with:
version: v0.0.0-pr

View File

@@ -1,23 +0,0 @@
# Codespell configuration is within .codespellrc
---
name: Codespell
on:
push:
branches: [master]
pull_request:
branches: [master]
permissions:
contents: read
jobs:
codespell:
name: Check for spelling errors
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Codespell
uses: codespell-project/actions-codespell@v2

View File

@@ -1,30 +0,0 @@
name: promote
on:
schedule:
- cron: '0 2 1 * *'
workflow_dispatch: {}
jobs:
release:
if: vars.ENABLE_PROMOTE || github.event_name != 'schedule'
name: promote
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
ref: master
token: ${{ secrets.GORELEASER_GITHUB_TOKEN }}
- uses: fregante/setup-git-user@v2
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
- uses: actions/cache@v4
if: ${{ !env.ACT }}
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- run: make promote

View File

@@ -1,72 +0,0 @@
name: release
on:
push:
tags:
- v*
jobs:
release:
# TODO use environment to scope secrets
name: release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
- uses: actions/cache@v4
if: ${{ !env.ACT }}
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN || github.token }}
- name: Winget
uses: vedantmgoyal2009/winget-releaser@v2
with:
identifier: nektos.act
installers-regex: '_Windows_\w+\.zip$'
token: ${{ secrets.WINGET_TOKEN }}
if: env.ENABLED
env:
ENABLED: ${{ secrets.WINGET_TOKEN && '1' || '' }}
- name: Chocolatey
uses: ./.github/actions/choco
with:
version: ${{ github.ref }}
apiKey: ${{ secrets.CHOCO_APIKEY }}
push: true
if: env.ENABLED
env:
ENABLED: ${{ secrets.CHOCO_APIKEY && '1' || '' }}
# TODO use ssh deployment key
- name: GitHub CLI extension
uses: actions/github-script@v8
with:
github-token: ${{ secrets.CLI_GITHUB_TOKEN || secrets.GORELEASER_GITHUB_TOKEN }}
script: |
const mainRef = (await github.rest.git.getRef({
owner: context.repo.owner,
repo: 'gh-act',
ref: 'heads/main',
})).data;
console.log(mainRef);
github.rest.git.createRef({
owner: 'nektos',
repo: 'gh-act',
ref: context.ref,
sha: mainRef.object.sha,
});
if: env.ENABLED
env:
ENABLED: ${{ (secrets.CLI_GITHUB_TOKEN || secrets.GORELEASER_GITHUB_TOKEN) && '1' || '' }}

View File

@@ -1,23 +0,0 @@
name: 'Close stale issues'
on:
schedule:
- cron: '0 0 * * *'
jobs:
stale:
name: Stale
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v10
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: 'Issue is stale and will be closed in 14 days unless there is new activity'
stale-pr-message: 'PR is stale and will be closed in 14 days unless there is new activity'
stale-issue-label: 'stale'
exempt-issue-labels: 'stale-exempt,kind/feature-request'
stale-pr-label: 'stale'
exempt-pr-labels: 'stale-exempt'
remove-stale-when-updated: 'True'
operations-per-run: 500
days-before-stale: 180
days-before-close: 14

1
.gitignore vendored
View File

@@ -2,7 +2,6 @@
.env
.runner
coverage.txt
/gitea-vet
/config.yaml
# Jetbrains

View File

@@ -1,2 +0,0 @@
b910a42edfab7a02b08a52ecef203fd419725642:pkg/container/testdata/docker-pull-options/config.json:generic-api-key:4
710a3ac94c3dc0eaf680d417c87f37f92b4887f4:pkg/container/docker_pull_test.go:generic-api-key:45

View File

@@ -13,6 +13,7 @@ linters:
- forbidigo
- gocheckcompilerdirectives
- gocritic
- goheader
- govet
- ineffassign
- mirror
@@ -35,23 +36,61 @@ linters:
rules:
main:
deny:
- pkg: io/ioutil
desc: use os or io instead
- pkg: golang.org/x/exp
desc: it's experimental and unreliable
- pkg: github.com/pkg/errors
desc: Please use "errors" package from standard library
- pkg: gotest.tools/v3
desc: Please keep tests unified using only github.com/stretchr/testify
- pkg: log
desc: Please keep logging unified using only github.com/sirupsen/logrus
desc: use builtin errors package instead
nolintlint:
allow-unused: false
require-explanation: true
require-specific: true
gocritic:
enabled-checks:
- equalFold
disabled-checks:
- ifElseChain
gocyclo:
min-complexity: 20
importas:
alias:
- pkg: github.com/sirupsen/logrus
alias: log
- pkg: github.com/stretchr/testify/assert
alias: assert
revive:
severity: error
rules:
- name: blank-imports
- name: constant-logical-expr
- name: context-as-argument
- name: context-keys-type
- name: dot-imports
- name: empty-lines
- name: error-return
- name: error-strings
- name: exported
- name: identical-branches
- name: if-return
- name: increment-decrement
- name: modifies-value-receiver
- name: package-comments
- name: redefines-builtin-id
- name: superfluous-else
- name: time-naming
- name: unexported-return
- name: var-declaration
- name: var-naming
staticcheck:
checks:
- all
- -ST1005
usetesting:
os-temp-dir: true
perfsprint:
concat-loop: false
govet:
enable:
- nilness
- unusedwrite
goheader:
values:
regexp:
HEADER: 'Copyright \d{4} The Gitea Authors\. All rights reserved\.(\nCopyright [^\n]+)*\nSPDX-License-Identifier: MIT'
template: '{{ HEADER }}'
exclusions:
generated: lax
presets:
@@ -59,21 +98,28 @@ linters:
- common-false-positives
- legacy
- std-error-handling
paths:
- report
- third_party$
- builtin$
- examples$
rules:
- linters:
- forbidigo
path: cmd
issues:
max-issues-per-linter: 0
max-same-issues: 0
formatters:
enable:
- goimports
- gci
- gofumpt
settings:
gci:
custom-order: true
sections:
- standard
- prefix(gitea.com/gitea/act_runner)
- blank
- default
gofumpt:
extra-rules: true
exclusions:
generated: lax
paths:
- report
- third_party$
- builtin$
- examples$
run:
timeout: 10m

View File

@@ -1,3 +0,0 @@
gitea_urls:
api: https://gitea.com/api/v1/
download: https://gitea.com/

View File

@@ -1,54 +0,0 @@
version: 2
before:
hooks:
- go mod tidy
builds:
- env:
- CGO_ENABLED=0
goos:
- darwin
- linux
- windows
goarch:
- amd64
- '386'
- arm64
- arm
- riscv64
goarm:
- '6'
- '7'
ignore:
- goos: windows
goarm: '6'
binary: act
checksum:
name_template: 'checksums.txt'
archives:
- name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
format_overrides:
- goos: windows
formats:
- zip
changelog:
groups:
- title: 'New Features'
regexp: "^.*feat[(\\w)]*:+.*$"
order: 0
- title: 'Bug fixes'
regexp: "^.*fix[(\\w)]*:+.*$"
order: 1
- title: 'Documentation updates'
regexp: "^.*docs[(\\w)]*:+.*$"
order: 2
- title: 'Other'
order: 999
release:
prerelease: auto
mode: append

View File

@@ -1,12 +0,0 @@
# Default state for all rules
default: true
# MD013/line-length - Line length
MD013:
line_length: 1024
# MD033/no-inline-html - Inline HTML
MD033: false
# MD041/first-line-heading/first-line-h1 - First line in a file should be a top-level heading
MD041: false

View File

@@ -1,20 +0,0 @@
---
APPLY_FIXES: none
DISABLE:
- ACTION
- BASH
- COPYPASTE
- DOCKERFILE
- GO
- JAVASCRIPT
- SPELL
DISABLE_LINTERS:
- YAML_YAMLLINT
- MARKDOWN_MARKDOWN_TABLE_FORMATTER
- MARKDOWN_MARKDOWN_LINK_CHECK
- REPOSITORY_CHECKOV
- REPOSITORY_TRIVY
FILTER_REGEX_EXCLUDE: (.*testdata/*|install.sh|pkg/container/docker_cli.go|pkg/container/DOCKER_LICENSE|VERSION)
MARKDOWN_MARKDOWNLINT_CONFIG_FILE: .markdownlint.yml
PARALLEL: false
PRINT_ALPACA: false

View File

@@ -1,98 +0,0 @@
pull_request_rules:
- name: warn on conflicts
conditions:
- -draft
- -closed
- -merged
- conflict
actions:
comment:
message: '@{{author}} this pull request is now in conflict 😩'
label:
add:
- conflict
- name: remove conflict label if not needed
conditions:
- -conflict
actions:
label:
remove:
- conflict
- name: warn on needs-work
conditions:
- -draft
- -closed
- -merged
- or:
- check-failure=lint
- check-failure=test-linux
- check-failure=codecov/patch
- check-failure=codecov/project
- check-failure=snapshot
actions:
comment:
message: '@{{author}} this pull request has failed checks 🛠'
label:
add:
- needs-work
- name: remove needs-work label if not needed
conditions:
- check-success=lint
- check-success=test-linux
- check-success=codecov/patch
- check-success=codecov/project
- check-success=snapshot
actions:
label:
remove:
- needs-work
- name: Automatic maintainer assignment
conditions:
- '-approved-reviews-by=@nektos/act-maintainers'
- -draft
- -merged
- -closed
- -conflict
- check-success=lint
- check-success=test-linux
- check-success=codecov/patch
- check-success=codecov/project
- check-success=snapshot
actions:
request_reviews:
teams:
- '@nektos/act-maintainers'
- name: Automatic merge on approval
conditions: []
actions:
queue:
queue_rules:
- name: default
queue_conditions:
- '#changes-requested-reviews-by=0'
- or:
- 'approved-reviews-by=@nektos/act-committers'
- 'author~=^dependabot(|-preview)\[bot\]$'
- and:
- 'approved-reviews-by=@nektos/act-maintainers'
- '#approved-reviews-by>=2'
- and:
- 'author=@nektos/act-maintainers'
- 'approved-reviews-by=@nektos/act-maintainers'
- '#approved-reviews-by>=1'
- -draft
- -merged
- -closed
- check-success=lint
- check-success=test-linux
- check-success=codecov/patch
- check-success=codecov/project
- check-success=snapshot
merge_conditions:
- check-success=lint
- check-success=test-linux
- check-success=codecov/patch
- check-success=codecov/project
- check-success=snapshot
merge_method: squash

View File

@@ -1,2 +0,0 @@
**/testdata
pkg/runner/res

View File

@@ -1,7 +0,0 @@
overrides:
- files: '*.yml'
options:
singleQuote: true
- files: '*.json'
options:
singleQuote: false

View File

@@ -1,9 +0,0 @@
{
"recommendations": [
"editorconfig.editorconfig",
"golang.go",
"davidanson.vscode-markdownlint",
"esbenp.prettier-vscode",
"redhat.vscode-yaml"
]
}

14
.vscode/settings.json vendored
View File

@@ -1,14 +0,0 @@
{
"go.lintTool": "golangci-lint",
"go.lintFlags": ["--fix"],
"go.testTimeout": "300s",
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[markdown]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[yaml]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}

View File

@@ -1 +0,0 @@
* @nektos/act-maintainers

View File

@@ -1,69 +0,0 @@
# Contributing to Act
Help wanted! We'd love your contributions to Act. Please review the following guidelines before contributing. Also, feel free to propose changes to these guidelines by updating this file and submitting a pull request.
- [I have a question...](#questions)
- [I found a bug...](#bugs)
- [I have a feature request...](#features)
- [I have a contribution to share...](#process)
## <a id="questions"></a> Have a Question?
Please don't open a GitHub issue for questions about how to use `act`, as the goal is to use issues for managing bugs and feature requests. Issues that are related to general support will be closed and redirected to our gitter room.
For all support related questions, please ask the question in discussions: [actions-oss/act-cli](https://github.com/actions-oss/act-cli/discussions).
## <a id="bugs"></a> Found a Bug?
If you've identified a bug in `act`, please [submit an issue](#issue) to our GitHub repo: [actions-oss/act-cli](https://github.com/actions-oss/act-cli/issues/new). Please also feel free to submit a [Pull Request](#pr) with a fix for the bug!
## <a id="features"></a> Have a Feature Request?
All feature requests should start with [submitting an issue](#issue) documenting the user story and acceptance criteria. Again, feel free to submit a [Pull Request](#pr) with a proposed implementation of the feature.
## <a id="process"></a> Ready to Contribute
### <a id="issue"></a> Create an issue
Before submitting a new issue, please search the issues to make sure there isn't a similar issue doesn't already exist.
Assuming no existing issues exist, please ensure you include required information when submitting the issue to ensure we can quickly reproduce your issue.
We may have additional questions and will communicate through the GitHub issue, so please respond back to our questions to help reproduce and resolve the issue as quickly as possible.
New issues can be created with in our [GitHub repo](https://github.com/actions-oss/act-cli/issues/new).
### <a id="pr"></a>Pull Requests
Pull requests should target the `master` branch. Please also reference the issue from the description of the pull request using [special keyword syntax](https://help.github.com/articles/closing-issues-via-commit-messages/) to auto close the issue when the PR is merged. For example, include the phrase `fixes #14` in the PR description to have issue #14 auto close. Please send documentation updates for the [act user guide](https://actions-oss.github.io/act-docs/) to [actions-oss/act-docs](https://github.com/actions-oss/act-docs).
### <a id="style"></a> Styleguide
When submitting code, please make every effort to follow existing conventions and style in order to keep the code as readable as possible. Here are a few points to keep in mind:
- Please run `go fmt ./...` before committing to ensure code aligns with go standards.
- We use [`golangci-lint`](https://golangci-lint.run/) for linting Go code, run `golangci-lint run --fix` before submitting PR. Editors such as Visual Studio Code or JetBrains IntelliJ; with Go support plugin will offer `golangci-lint` automatically.
- There are additional linters and formatters for files such as Markdown documents or YAML/JSON:
- Please refer to the [Makefile](Makefile) or [`lint` job in our workflow](.github/workflows/checks.yml) to see how to those linters/formatters work.
- You can lint codebase by running `go run main.go -j lint --env RUN_LOCAL=true` or `act -j lint --env RUN_LOCAL=true`
- In `Makefile`, there are tools that require `npx` which is shipped with `nodejs`.
- Our `Makefile` exports `GITHUB_TOKEN` from `~/.config/github/token`, you have been warned.
- You can run `make pr` to cleanup dependencies, format/lint code and run tests.
- All dependencies must be defined in the `go.mod` file.
- Advanced IDEs and code editors (like VSCode) will take care of that, but to be sure, run `go mod tidy` to validate dependencies.
- For details on the approved style, check out [Effective Go](https://golang.org/doc/effective_go.html).
- Before running tests, please be aware that they are multi-architecture so for them to not fail, you need to run `docker run --privileged --rm tonistiigi/binfmt --install amd64,arm64` before ([more info available in #765](https://github.com/nektos/act/issues/765)).
Also, consider the original design principles:
- **Polyglot** - There will be no prescribed language or framework for developing the microservices. The only requirement will be that the service will be run inside a container and exposed via an HTTP endpoint.
- **Cloud Provider** - At this point, the tool will assume AWS for the cloud provider and will not be written in a cloud agnostic manner. However, this does not preclude refactoring to add support for other providers at a later time.
- **Declarative** - All resource administration will be handled in a declarative vs. imperative manner. A file will be used to declared the desired state of the resources and the tool will simply assert the actual state matches the desired state. The tool will accomplish this by generating CloudFormation templates.
- **Stateless** - The tool will not maintain its own state. Rather, it will rely on the CloudFormation stacks to determine the state of the platform.
- **Secure** - All security will be managed by AWS IAM credentials. No additional authentication or authorization mechanisms will be introduced.
### License
By contributing your code, you agree to license your contribution under the terms of the [MIT License](LICENSE).
All files are released with the MIT license.

View File

@@ -1,6 +1,5 @@
DIST := dist
EXECUTABLE := act_runner
GOFMT ?= gofumpt -l
DIST_DIRS := $(DIST)/binaries $(DIST)/release
GO ?= go
SHASUM ?= shasum -a 256
@@ -12,7 +11,6 @@ GXZ_PAGAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.10
LINUX_ARCHS ?= linux/amd64,linux/arm64
DARWIN_ARCHS ?= darwin-12/amd64,darwin-12/arm64
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
@@ -20,7 +18,7 @@ DOCKER_TAG ?= nightly
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.10.1
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1
ifneq ($(shell uname), Darwin)
@@ -68,19 +66,14 @@ else
endif
endif
GO_PACKAGES_TO_VET ?= $(filter-out gitea.com/gitea/act_runner/internal/pkg/client/mocks,$(shell $(GO) list ./...))
TAGS ?=
LDFLAGS ?= -X "gitea.com/gitea/act_runner/internal/pkg/ver.version=v$(RELASE_VERSION)"
all: build
.PHONY: fmt
fmt:
@hash gofumpt > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
$(GO) install mvdan.cc/gofumpt@latest; \
fi
$(GOFMT) -w $(GO_FMT_FILES)
$(GO) run $(GOLANGCI_LINT_PACKAGE) fmt
.PHONY: go-check
go-check:
@@ -93,23 +86,20 @@ go-check:
fi
.PHONY: fmt-check
fmt-check:
@hash gofumpt > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
$(GO) install mvdan.cc/gofumpt@latest; \
fi
@diff=$$($(GOFMT) -d $(GO_FMT_FILES)); \
fmt-check: fmt
@diff=$$(git diff --color=always); \
if [ -n "$$diff" ]; then \
echo "Please run 'make fmt' and commit the result:"; \
echo "$${diff}"; \
printf "%s" "$${diff}"; \
exit 1; \
fi;
fi
.PHONY: deps-tools
deps-tools: ## install tool dependencies
$(GO) install $(GOVULNCHECK_PACKAGE)
.PHONY: lint
lint: lint-go vet
lint: lint-go
.PHONY: lint-go
lint-go: ## lint go files
@@ -139,12 +129,6 @@ tidy-check: tidy
test: fmt-check security-check
@$(GO) test -race -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
.PHONY: vet
vet:
@echo "Running go vet..."
@$(GO) build code.gitea.io/gitea-vet
@$(GO) vet -vettool=gitea-vet $(GO_PACKAGES_TO_VET)
install: $(GOFILES)
$(GO) install -v -tags '$(TAGS)' -ldflags '$(EXTLDFLAGS)-s -w $(LDFLAGS)'

View File

@@ -1,5 +0,0 @@
VERIFICATION
Verification is intended to assist the Chocolatey moderators and community
in verifying that this package's contents are trustworthy.
Checksums: https://github.com/nektos/act/releases, in the checksums.txt file

View File

@@ -1 +0,0 @@
0.4.0

View File

@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Do not remove this test for UTF-8: if “Ω” doesnt appear as greek uppercase omega letter enclosed in quotation marks, you should use an editor that supports UTF-8, not this one. -->
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
<metadata>
<id>act-cli</id>
<version>0.0.0</version>
<packageSourceUrl>https://github.com/nektos/act</packageSourceUrl>
<owners>nektos</owners>
<title>act (GitHub Actions CLI)</title>
<authors>nektos</authors>
<projectUrl>https://github.com/nektos/act</projectUrl>
<iconUrl>https://raw.githubusercontent.com/wiki/nektos/act/img/logo-150.png</iconUrl>
<copyright>Nektos</copyright>
<licenseUrl>https://raw.githubusercontent.com/nektos/act/master/LICENSE</licenseUrl>
<requireLicenseAcceptance>true</requireLicenseAcceptance>
<projectSourceUrl>https://github.com/nektos/act</projectSourceUrl>
<docsUrl>https://raw.githubusercontent.com/nektos/act/master/README.md</docsUrl>
<bugTrackerUrl>https://github.com/nektos/act/issues</bugTrackerUrl>
<tags>act github-actions actions golang ci devops</tags>
<summary>Run your GitHub Actions locally 🚀</summary>
<description>Run your GitHub Actions locally 🚀</description>
</metadata>
<files>
<file src="tools/**" target="tools" />
</files>
</package>

View File

@@ -1,11 +0,0 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build vendor
package main
import (
// for vet
_ "code.gitea.io/gitea-vet"
)

View File

@@ -1,27 +0,0 @@
package cmd
import (
"os"
"path/filepath"
log "github.com/sirupsen/logrus"
)
var (
UserHomeDir string
CacheHomeDir string
)
func init() {
home, err := os.UserHomeDir()
if err != nil {
log.Fatal(err)
}
UserHomeDir = home
if v := os.Getenv("XDG_CACHE_HOME"); v != "" {
CacheHomeDir = v
} else {
CacheHomeDir = filepath.Join(UserHomeDir, ".cache")
}
}

View File

@@ -1,45 +0,0 @@
package cmd
import (
"context"
"os"
"testing"
)
// Helper function to test main with different os.Args
func testMain(args []string) (exitCode int) {
// Save original os.Args and defer restoring it
origArgs := os.Args
defer func() { os.Args = origArgs }()
// Save original os.Exit and defer restoring it
defer func() { exitFunc = os.Exit }()
// Mock os.Exit
fakeExit := func(code int) {
exitCode = code
}
exitFunc = fakeExit
// Mock os.Args
os.Args = args
// Run the main function
Execute(context.Background(), "")
return exitCode
}
func TestMainHelp(t *testing.T) {
exitCode := testMain([]string{"cmd", "--help"})
if exitCode != 0 {
t.Errorf("expected exit code 0, got %d", exitCode)
}
}
func TestMainNoArgsError(t *testing.T) {
exitCode := testMain([]string{"cmd"})
if exitCode != 1 {
t.Errorf("expected exit code 1, got %d", exitCode)
}
}

View File

@@ -1,38 +0,0 @@
package cmd
import (
"os"
"github.com/actions-oss/act-cli/pkg/common"
"github.com/actions-oss/act-cli/pkg/model"
)
func drawGraph(plan *model.Plan) error {
drawings := make([]*common.Drawing, 0)
jobPen := common.NewPen(common.StyleSingleLine, 96)
arrowPen := common.NewPen(common.StyleNoLine, 97)
for i, stage := range plan.Stages {
if i > 0 {
drawings = append(drawings, arrowPen.DrawArrow())
}
ids := make([]string, 0)
for _, r := range stage.Runs {
ids = append(ids, r.String())
}
drawings = append(drawings, jobPen.DrawBoxes(ids...))
}
maxWidth := 0
for _, d := range drawings {
if d.GetWidth() > maxWidth {
maxWidth = d.GetWidth()
}
}
for _, d := range drawings {
d.Draw(os.Stdout, maxWidth)
}
return nil
}

View File

@@ -1,118 +0,0 @@
package cmd
import (
"path/filepath"
log "github.com/sirupsen/logrus"
)
// Input contains the input for the root command
type Input struct {
actor string
workdir string
workflowsPath string
autodetectEvent bool
eventPath string
reuseContainers bool
bindWorkdir bool
secrets []string
vars []string
envs []string
inputs []string
platforms []string
dryrun bool
pullIfNeeded bool
noRebuild bool
noOutput bool
envfile string
inputfile string
secretfile string
varfile string
insecureSecrets bool
defaultBranch string
privileged bool
usernsMode string
containerArchitecture string
containerDaemonSocket string
containerOptions string
workflowRecurse bool
useGitIgnore bool
githubInstance string
gitHubServerURL string
gitHubAPIServerURL string
gitHubGraphQlAPIServerURL string
containerCapAdd []string
containerCapDrop []string
autoRemove bool
artifactServerPath string
artifactServerAddr string
artifactServerPort string
noCacheServer bool
cacheServerPath string
cacheServerAddr string
cacheServerPort uint16
jsonLogger bool
noSkipCheckout bool
remoteName string
replaceGheActionWithGithubCom []string
replaceGheActionTokenWithGithubCom string
matrix []string
actionCachePath string
actionOfflineMode bool
logPrefixJobID bool
networkName string
localRepository []string
listOptions bool
validate bool
strict bool
parallel int
gitea bool
}
func (i *Input) resolve(path string) string {
basedir, err := filepath.Abs(i.workdir)
if err != nil {
log.Fatal(err)
}
if path == "" {
return path
}
if !filepath.IsAbs(path) {
path = filepath.Join(basedir, path)
}
return path
}
// Envfile returns path to .env
func (i *Input) Envfile() string {
return i.resolve(i.envfile)
}
// Secretfile returns path to secrets
func (i *Input) Secretfile() string {
return i.resolve(i.secretfile)
}
func (i *Input) Varfile() string {
return i.resolve(i.varfile)
}
// Workdir returns path to workdir
func (i *Input) Workdir() string {
return i.resolve(".")
}
// WorkflowsPath returns path to workflow file(s)
func (i *Input) WorkflowsPath() string {
return i.resolve(i.workflowsPath)
}
// EventPath returns the path to events file
func (i *Input) EventPath() string {
return i.resolve(i.eventPath)
}
// Inputfile returns the path to the input file
func (i *Input) Inputfile() string {
return i.resolve(i.inputfile)
}

View File

@@ -1,107 +0,0 @@
package cmd
import (
"fmt"
"strconv"
"strings"
"github.com/actions-oss/act-cli/pkg/model"
)
func printList(plan *model.Plan) error {
type lineInfoDef struct {
jobID string
jobName string
stage string
wfName string
wfFile string
events string
}
lineInfos := []lineInfoDef{}
header := lineInfoDef{
jobID: "Job ID",
jobName: "Job name",
stage: "Stage",
wfName: "Workflow name",
wfFile: "Workflow file",
events: "Events",
}
jobs := map[string]bool{}
duplicateJobIDs := false
jobIDMaxWidth := len(header.jobID)
jobNameMaxWidth := len(header.jobName)
stageMaxWidth := len(header.stage)
wfNameMaxWidth := len(header.wfName)
wfFileMaxWidth := len(header.wfFile)
eventsMaxWidth := len(header.events)
for i, stage := range plan.Stages {
for _, r := range stage.Runs {
jobID := r.JobID
line := lineInfoDef{
jobID: jobID,
jobName: r.String(),
stage: strconv.Itoa(i),
wfName: r.Workflow.Name,
wfFile: r.Workflow.File,
events: strings.Join(r.Workflow.On(), `,`),
}
if _, ok := jobs[jobID]; ok {
duplicateJobIDs = true
} else {
jobs[jobID] = true
}
lineInfos = append(lineInfos, line)
if jobIDMaxWidth < len(line.jobID) {
jobIDMaxWidth = len(line.jobID)
}
if jobNameMaxWidth < len(line.jobName) {
jobNameMaxWidth = len(line.jobName)
}
if stageMaxWidth < len(line.stage) {
stageMaxWidth = len(line.stage)
}
if wfNameMaxWidth < len(line.wfName) {
wfNameMaxWidth = len(line.wfName)
}
if wfFileMaxWidth < len(line.wfFile) {
wfFileMaxWidth = len(line.wfFile)
}
if eventsMaxWidth < len(line.events) {
eventsMaxWidth = len(line.events)
}
}
}
jobIDMaxWidth += 2
jobNameMaxWidth += 2
stageMaxWidth += 2
wfNameMaxWidth += 2
wfFileMaxWidth += 2
fmt.Printf("%*s%*s%*s%*s%*s%*s\n",
-stageMaxWidth, header.stage,
-jobIDMaxWidth, header.jobID,
-jobNameMaxWidth, header.jobName,
-wfNameMaxWidth, header.wfName,
-wfFileMaxWidth, header.wfFile,
-eventsMaxWidth, header.events,
)
for _, line := range lineInfos {
fmt.Printf("%*s%*s%*s%*s%*s%*s\n",
-stageMaxWidth, line.stage,
-jobIDMaxWidth, line.jobID,
-jobNameMaxWidth, line.jobName,
-wfNameMaxWidth, line.wfName,
-wfFileMaxWidth, line.wfFile,
-eventsMaxWidth, line.events,
)
}
if duplicateJobIDs {
fmt.Print("\nDetected multiple jobs with the same job name, use `-W` to specify the path to the specific workflow.\n")
}
return nil
}

View File

@@ -1,22 +0,0 @@
package cmd
import (
"strings"
)
func (i *Input) newPlatforms() map[string]string {
platforms := map[string]string{
"ubuntu-latest": "node:16-buster-slim",
"ubuntu-22.04": "node:16-bullseye-slim",
"ubuntu-20.04": "node:16-buster-slim",
"ubuntu-18.04": "node:16-buster-slim",
}
for _, p := range i.platforms {
pParts := strings.SplitN(p, "=", 2)
if len(pParts) == 2 {
platforms[strings.ToLower(pParts[0])] = pParts[1]
}
}
return platforms
}

View File

@@ -1,860 +0,0 @@
package cmd
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"runtime"
"runtime/debug"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/adrg/xdg"
"github.com/andreaskoch/go-fswatch"
docker_container "github.com/docker/docker/api/types/container"
"github.com/joho/godotenv"
gitignore "github.com/sabhiram/go-gitignore"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/spf13/cobra/doc"
"github.com/spf13/pflag"
"gopkg.in/yaml.v3"
"github.com/actions-oss/act-cli/pkg/artifactcache"
"github.com/actions-oss/act-cli/pkg/artifacts"
"github.com/actions-oss/act-cli/pkg/common"
"github.com/actions-oss/act-cli/pkg/container"
"github.com/actions-oss/act-cli/pkg/gh"
"github.com/actions-oss/act-cli/pkg/model"
"github.com/actions-oss/act-cli/pkg/runner"
"github.com/actions-oss/act-cli/pkg/schema"
)
type Flag struct {
Name string `json:"name"`
Default string `json:"default"`
Type string `json:"type"`
Description string `json:"description"`
}
var exitFunc = os.Exit
// Execute is the entry point to running the CLI
func Execute(ctx context.Context, version string) {
input := new(Input)
rootCmd := createRootCommand(ctx, input, version)
if err := rootCmd.Execute(); err != nil {
exitFunc(1)
}
}
func createRootCommand(ctx context.Context, input *Input, version string) *cobra.Command {
rootCmd := &cobra.Command{
Use: "act [event name to run] [flags]\n\nIf no event name passed, will default to \"on: push\"\nIf actions handles only one event it will be used as default instead of \"on: push\"\nSee documentation at: https://gitea.com/actions-oss/act-cli or https://github.com/actions-oss/act-cli",
Short: "Run GitHub actions locally by specifying the event name (e.g. `push`) or an action name directly.",
Args: cobra.MaximumNArgs(1),
RunE: newRunCommand(ctx, input),
PersistentPreRun: setup(input),
Version: version,
SilenceUsage: true,
}
rootCmd.Flags().BoolP("watch", "w", false, "watch the contents of the local repo and run when files change")
rootCmd.Flags().BoolVar(&input.validate, "validate", false, "validate workflows")
rootCmd.Flags().BoolVar(&input.strict, "strict", false, "use strict workflow schema")
rootCmd.Flags().BoolP("list", "l", false, "list workflows")
rootCmd.Flags().BoolP("graph", "g", false, "draw workflows")
rootCmd.Flags().StringP("job", "j", "", "run a specific job ID")
rootCmd.Flags().BoolP("bug-report", "", false, "Display system information for bug report")
rootCmd.Flags().BoolP("man-page", "", false, "Print a generated manual page to stdout")
rootCmd.Flags().StringVar(&input.remoteName, "remote-name", "origin", "git remote name that will be used to retrieve url of git repo")
rootCmd.Flags().StringArrayVarP(&input.secrets, "secret", "s", []string{}, "secret to make available to actions with optional value (e.g. -s mysecret=foo or -s mysecret)")
rootCmd.Flags().StringArrayVar(&input.vars, "var", []string{}, "variable to make available to actions with optional value (e.g. --var myvar=foo or --var myvar)")
rootCmd.Flags().StringArrayVarP(&input.envs, "env", "", []string{}, "env to make available to actions with optional value (e.g. --env myenv=foo or --env myenv)")
rootCmd.Flags().StringArrayVarP(&input.inputs, "input", "", []string{}, "action input to make available to actions (e.g. --input myinput=foo)")
rootCmd.Flags().StringArrayVarP(&input.platforms, "platform", "P", []string{}, "custom image to use per platform (e.g. -P ubuntu-18.04=nektos/act-environments-ubuntu:18.04)")
rootCmd.Flags().BoolVarP(&input.reuseContainers, "reuse", "r", false, "don't remove container(s) on successfully completed workflow(s) to maintain state between runs")
rootCmd.Flags().BoolVarP(&input.bindWorkdir, "bind", "b", false, "bind working directory to container, rather than copy")
rootCmd.Flags().BoolVarP(&input.pullIfNeeded, "pull-if-needed", "", false, "only pull docker image(s) if not present")
rootCmd.Flags().BoolVarP(&input.noRebuild, "no-rebuild", "", false, "don't rebuild local action docker action image(s) if already present for correct platform")
rootCmd.Flags().BoolVarP(&input.autodetectEvent, "detect-event", "", false, "Use first event type from workflow as event that triggered the workflow")
rootCmd.Flags().StringVarP(&input.eventPath, "eventpath", "e", "", "path to event JSON file")
rootCmd.Flags().StringVar(&input.defaultBranch, "defaultbranch", "", "the name of the main branch")
rootCmd.Flags().BoolVar(&input.privileged, "privileged", false, "use privileged mode")
rootCmd.Flags().StringVar(&input.usernsMode, "userns", "", "user namespace to use")
rootCmd.Flags().BoolVar(&input.useGitIgnore, "use-gitignore", true, "Controls whether paths specified in .gitignore should be copied into container")
rootCmd.Flags().StringArrayVarP(&input.containerCapAdd, "container-cap-add", "", []string{}, "kernel capabilities to add to the workflow containers (e.g. --container-cap-add SYS_PTRACE)")
rootCmd.Flags().StringArrayVarP(&input.containerCapDrop, "container-cap-drop", "", []string{}, "kernel capabilities to remove from the workflow containers (e.g. --container-cap-drop SYS_PTRACE)")
rootCmd.Flags().BoolVar(&input.autoRemove, "rm", false, "automatically remove container(s)/volume(s) after a workflow(s) failure")
rootCmd.Flags().StringArrayVarP(&input.replaceGheActionWithGithubCom, "replace-ghe-action-with-github-com", "", []string{}, "If you are using GitHub Enterprise Server and allow specified actions from GitHub (github.com), you can set actions on this. (e.g. --replace-ghe-action-with-github-com =github/super-linter)")
rootCmd.Flags().StringVar(&input.replaceGheActionTokenWithGithubCom, "replace-ghe-action-token-with-github-com", "", "If you are using replace-ghe-action-with-github-com and you want to use private actions on GitHub, you have to set personal access token")
rootCmd.Flags().StringArrayVarP(&input.matrix, "matrix", "", []string{}, "specify which matrix configuration to include (e.g. --matrix java:13")
rootCmd.Flags().IntVarP(&input.parallel, "parallel", "", 0, "number of jobs to run in parallel")
rootCmd.Flags().IntVarP(&input.parallel, "concurrent-jobs", "", 0, "number of jobs to run in parallel")
rootCmd.PersistentFlags().StringVarP(&input.actor, "actor", "a", "nektos/act", "user that triggered the event")
rootCmd.PersistentFlags().StringVarP(&input.workflowsPath, "workflows", "W", "./.github/workflows/", "path to workflow file(s)")
rootCmd.PersistentFlags().BoolVarP(&input.workflowRecurse, "recurse", "", false, "Flag to enable running workflows from subdirectories of specified path in '--workflows'/'-W' flag, this feature doesn't exist on GitHub Actions as of 2024/11")
rootCmd.PersistentFlags().StringVarP(&input.workdir, "directory", "C", ".", "working directory")
rootCmd.PersistentFlags().BoolP("verbose", "v", false, "verbose output")
rootCmd.PersistentFlags().BoolVar(&input.jsonLogger, "json", false, "Output logs in json format")
rootCmd.PersistentFlags().BoolVar(&input.logPrefixJobID, "log-prefix-job-id", false, "Output the job id within non-json logs instead of the entire name")
rootCmd.PersistentFlags().BoolVarP(&input.noOutput, "quiet", "q", false, "disable logging of output from steps")
rootCmd.PersistentFlags().BoolVarP(&input.dryrun, "dryrun", "n", false, "disable container creation, validates only workflow correctness")
rootCmd.PersistentFlags().StringVarP(&input.secretfile, "secret-file", "", ".secrets", "file with list of secrets to read from (e.g. --secret-file .secrets)")
rootCmd.PersistentFlags().StringVarP(&input.varfile, "var-file", "", ".vars", "file with list of vars to read from (e.g. --var-file .vars)")
rootCmd.PersistentFlags().BoolVarP(&input.insecureSecrets, "insecure-secrets", "", false, "NOT RECOMMENDED! Doesn't hide secrets while printing logs.")
rootCmd.PersistentFlags().StringVarP(&input.envfile, "env-file", "", ".env", "environment file to read and use as env in the containers")
rootCmd.PersistentFlags().StringVarP(&input.inputfile, "input-file", "", ".input", "input file to read and use as action input")
rootCmd.PersistentFlags().StringVarP(&input.containerArchitecture, "container-architecture", "", "", "Architecture which should be used to run containers, e.g.: linux/amd64. If not specified, will use host default architecture. Requires Docker server API Version 1.41+. Ignored on earlier Docker server platforms.")
rootCmd.PersistentFlags().StringVarP(&input.containerDaemonSocket, "container-daemon-socket", "", "", "URI to Docker Engine socket (e.g.: unix://~/.docker/run/docker.sock or - to disable bind mounting the socket)")
rootCmd.PersistentFlags().StringVarP(&input.containerOptions, "container-options", "", "", "Custom docker container options for the job container without an options property in the job definition")
rootCmd.PersistentFlags().StringVarP(&input.githubInstance, "github-instance", "", "github.com", "GitHub instance to use. Only use this when using GitHub Enterprise Server.")
rootCmd.PersistentFlags().StringVarP(&input.gitHubServerURL, "github-server-url", "", "", "Fully qualified URL to the GitHub instance to use with http/https protocol. Only use this when using GitHub Enterprise Server or Gitea.")
rootCmd.PersistentFlags().StringVarP(&input.gitHubAPIServerURL, "github-api-server-url", "", "", "Fully qualified URL to the GitHub instance api url to use with http/https protocol. Only use this when using GitHub Enterprise Server or Gitea.")
rootCmd.PersistentFlags().StringVarP(&input.gitHubGraphQlAPIServerURL, "github-graph-ql-api-server-url", "", "", "Fully qualified URL to the GitHub instance graphql api to use with http/https protocol. Only use this when using GitHub Enterprise Server or Gitea.")
rootCmd.PersistentFlags().StringVarP(&input.artifactServerPath, "artifact-server-path", "", "", "Defines the path where the artifact server stores uploads and retrieves downloads from. If not specified the artifact server will not start.")
rootCmd.PersistentFlags().StringVarP(&input.artifactServerAddr, "artifact-server-addr", "", common.GetOutboundIP().String(), "Defines the address to which the artifact server binds.")
rootCmd.PersistentFlags().StringVarP(&input.artifactServerPort, "artifact-server-port", "", "34567", "Defines the port where the artifact server listens.")
rootCmd.PersistentFlags().BoolVarP(&input.noSkipCheckout, "no-skip-checkout", "", false, "Do not skip actions/checkout")
rootCmd.PersistentFlags().BoolVarP(&input.noCacheServer, "no-cache-server", "", false, "Disable cache server")
rootCmd.PersistentFlags().StringVarP(&input.cacheServerPath, "cache-server-path", "", filepath.Join(CacheHomeDir, "actcache"), "Defines the path where the cache server stores caches.")
rootCmd.PersistentFlags().StringVarP(&input.cacheServerAddr, "cache-server-addr", "", common.GetOutboundIP().String(), "Defines the address to which the cache server binds.")
rootCmd.PersistentFlags().Uint16VarP(&input.cacheServerPort, "cache-server-port", "", 0, "Defines the port where the artifact server listens. 0 means a randomly available port.")
rootCmd.PersistentFlags().StringVarP(&input.actionCachePath, "action-cache-path", "", filepath.Join(CacheHomeDir, "act"), "Defines the path where the actions get cached and host workspaces created.")
rootCmd.PersistentFlags().BoolVarP(&input.actionOfflineMode, "action-offline-mode", "", false, "If action contents exists, it will not be fetch and pull again. If turn on this, will turn off force pull")
rootCmd.PersistentFlags().StringVarP(&input.networkName, "network", "", "host", "Sets a docker network name. Defaults to host.")
rootCmd.PersistentFlags().StringArrayVarP(&input.localRepository, "local-repository", "", []string{}, "Replaces the specified repository and ref with a local folder (e.g. https://github.com/test/test@v0=/home/act/test or test/test@v0=/home/act/test, the latter matches any hosts or protocols)")
rootCmd.PersistentFlags().BoolVar(&input.listOptions, "list-options", false, "Print a json structure of compatible options")
rootCmd.PersistentFlags().BoolVar(&input.gitea, "gitea", false, "Use Gitea instead of GitHub")
rootCmd.SetArgs(args())
return rootCmd
}
// Return locations where Act's config can be found in order: XDG spec, .actrc in HOME directory, .actrc in invocation directory
func configLocations() []string {
configFileName := ".actrc"
homePath := filepath.Join(UserHomeDir, configFileName)
invocationPath := filepath.Join(".", configFileName)
// Though named xdg, adrg's lib support macOS and Windows config paths as well
// It also takes cares of creating the parent folder so we don't need to bother later
specPath, err := xdg.ConfigFile("act/actrc")
if err != nil {
specPath = homePath
}
// This order should be enforced since the survey part relies on it
return []string{specPath, homePath, invocationPath}
}
func args() []string {
actrc := configLocations()
args := make([]string, 0)
for _, f := range actrc {
args = append(args, readArgsFile(f, true)...)
}
args = append(args, os.Args[1:]...)
return args
}
func bugReport(ctx context.Context, version string) error {
sprintf := func(key, val string) string {
return fmt.Sprintf("%-24s%s\n", key, val)
}
report := sprintf("act version:", version)
report += sprintf("Variant:", "https://gitea.com/actions-oss/act-cli / https://github.com/actions-oss/act-cli")
report += sprintf("GOOS:", runtime.GOOS)
report += sprintf("GOARCH:", runtime.GOARCH)
report += sprintf("NumCPU:", fmt.Sprint(runtime.NumCPU()))
var dockerHost string
var exists bool
if dockerHost, exists = os.LookupEnv("DOCKER_HOST"); !exists {
dockerHost = "DOCKER_HOST environment variable is not set"
} else if dockerHost == "" {
dockerHost = "DOCKER_HOST environment variable is empty."
}
report += sprintf("Docker host:", dockerHost)
report += fmt.Sprintln("Sockets found:")
for _, p := range container.CommonSocketLocations {
if _, err := os.Lstat(os.ExpandEnv(p)); err != nil {
continue
} else if _, err := os.Stat(os.ExpandEnv(p)); err != nil {
report += fmt.Sprintf("\t%s(broken)\n", p)
} else {
report += fmt.Sprintf("\t%s\n", p)
}
}
report += sprintf("Config files:", "")
for _, c := range configLocations() {
args := readArgsFile(c, false)
if len(args) > 0 {
report += fmt.Sprintf("\t%s:\n", c)
for _, l := range args {
report += fmt.Sprintf("\t\t%s\n", l)
}
}
}
vcs, ok := debug.ReadBuildInfo()
if ok && vcs != nil {
report += fmt.Sprintln("Build info:")
vcs := *vcs
report += sprintf("\tGo version:", vcs.GoVersion)
report += sprintf("\tModule path:", vcs.Path)
report += sprintf("\tMain version:", vcs.Main.Version)
report += sprintf("\tMain path:", vcs.Main.Path)
report += sprintf("\tMain checksum:", vcs.Main.Sum)
report += fmt.Sprintln("\tBuild settings:")
for _, set := range vcs.Settings {
report += sprintf(fmt.Sprintf("\t\t%s:", set.Key), set.Value)
}
}
info, err := container.GetHostInfo(ctx)
if err != nil {
fmt.Println(report)
return err
}
report += fmt.Sprintln("Docker Engine:")
report += sprintf("\tEngine version:", info.ServerVersion)
report += sprintf("\tEngine runtime:", info.DefaultRuntime)
report += sprintf("\tCgroup version:", info.CgroupVersion)
report += sprintf("\tCgroup driver:", info.CgroupDriver)
report += sprintf("\tStorage driver:", info.Driver)
report += sprintf("\tRegistry URI:", info.IndexServerAddress)
report += sprintf("\tOS:", info.OperatingSystem)
report += sprintf("\tOS type:", info.OSType)
report += sprintf("\tOS version:", info.OSVersion)
report += sprintf("\tOS arch:", info.Architecture)
report += sprintf("\tOS kernel:", info.KernelVersion)
report += sprintf("\tOS CPU:", fmt.Sprint(info.NCPU))
report += sprintf("\tOS memory:", fmt.Sprintf("%d MB", info.MemTotal/1024/1024))
report += fmt.Sprintln("\tSecurity options:")
for _, secopt := range info.SecurityOptions {
report += fmt.Sprintf("\t\t%s\n", secopt)
}
fmt.Println(report)
return nil
}
func generateManPage(cmd *cobra.Command) error {
header := &doc.GenManHeader{
Title: "act",
Section: "1",
Source: fmt.Sprintf("act %s", cmd.Version),
}
buf := new(bytes.Buffer)
cobra.CheckErr(doc.GenMan(cmd, header, buf))
fmt.Print(buf.String())
return nil
}
func listOptions(cmd *cobra.Command) error {
flags := []Flag{}
cmd.LocalFlags().VisitAll(func(f *pflag.Flag) {
flags = append(flags, Flag{Name: f.Name, Default: f.DefValue, Description: f.Usage, Type: f.Value.Type()})
})
a, err := json.Marshal(flags)
fmt.Println(string(a))
return err
}
func readArgsFile(file string, split bool) []string {
args := make([]string, 0)
f, err := os.Open(file)
if err != nil {
return args
}
defer func() {
err := f.Close()
if err != nil {
log.Errorf("failed to close args file: %v", err)
}
}()
scanner := bufio.NewScanner(f)
scanner.Buffer(nil, 1024*1024*1024) // increase buffer to 1GB to avoid scanner buffer overflow
for scanner.Scan() {
arg := os.ExpandEnv(strings.TrimSpace(scanner.Text()))
if strings.HasPrefix(arg, "-") && split {
args = append(args, regexp.MustCompile(`\s`).Split(arg, 2)...)
} else if !split {
args = append(args, arg)
}
}
return args
}
func setup(_ *Input) func(*cobra.Command, []string) {
return func(cmd *cobra.Command, _ []string) {
verbose, _ := cmd.Flags().GetBool("verbose")
if verbose {
log.SetLevel(log.DebugLevel)
}
}
}
func parseEnvs(env []string) map[string]string {
envs := make(map[string]string, len(env))
for _, envVar := range env {
e := strings.SplitN(envVar, `=`, 2)
if len(e) == 2 {
envs[e[0]] = e[1]
} else {
envs[e[0]] = ""
}
}
return envs
}
func readYamlFile(file string) (map[string]string, error) {
content, err := os.ReadFile(file)
if err != nil {
return nil, err
}
ret := map[string]string{}
if err = yaml.Unmarshal(content, &ret); err != nil {
return nil, err
}
return ret, nil
}
func readEnvs(path string, envs map[string]string) bool {
return readEnvsEx(path, envs, false)
}
func readEnvsEx(path string, envs map[string]string, caseInsensitive bool) bool {
if _, err := os.Stat(path); err == nil {
var env map[string]string
if ext := filepath.Ext(path); ext == ".yml" || ext == ".yaml" {
env, err = readYamlFile(path)
} else {
env, err = godotenv.Read(path)
}
if err != nil {
log.Fatalf("Error loading from %s: %v", path, err)
}
for k, v := range env {
if caseInsensitive {
k = strings.ToUpper(k)
}
if _, ok := envs[k]; !ok {
envs[k] = v
}
}
return true
}
return false
}
func parseMatrix(matrix []string) map[string]map[string]bool {
// each matrix entry should be of the form - string:string
r := regexp.MustCompile(":")
matrixes := make(map[string]map[string]bool)
for _, m := range matrix {
matrix := r.Split(m, 2)
if len(matrix) < 2 {
log.Fatalf("Invalid matrix format. Failed to parse %s", m)
}
if _, ok := matrixes[matrix[0]]; !ok {
matrixes[matrix[0]] = make(map[string]bool)
}
matrixes[matrix[0]][matrix[1]] = true
}
return matrixes
}
//nolint:gocyclo
func newRunCommand(ctx context.Context, input *Input) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
if input.jsonLogger {
log.SetFormatter(&log.JSONFormatter{})
}
if ok, _ := cmd.Flags().GetBool("bug-report"); ok {
ctx, cancel := common.EarlyCancelContext(ctx)
defer cancel()
return bugReport(ctx, cmd.Version)
}
if ok, _ := cmd.Flags().GetBool("man-page"); ok {
return generateManPage(cmd)
}
if input.listOptions {
return listOptions(cmd)
}
if ret, err := container.GetSocketAndHost(input.containerDaemonSocket); err != nil {
log.Warnf("Couldn't get a valid docker connection: %+v", err)
} else {
os.Setenv("DOCKER_HOST", ret.Host)
input.containerDaemonSocket = ret.Socket
log.Infof("Using docker host '%s', and daemon socket '%s'", ret.Host, ret.Socket)
}
if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" && input.containerArchitecture == "" {
l := log.New()
l.SetFormatter(&log.TextFormatter{
DisableQuote: true,
DisableTimestamp: true,
})
l.Warnf(" \U000026A0 You are using Apple M-series chip and you have not specified container architecture, you might encounter issues while running act. If so, try running it with '--container-architecture linux/amd64'. \U000026A0 \n")
}
log.Debugf("Loading environment from %s", input.Envfile())
envs := parseEnvs(input.envs)
_ = readEnvs(input.Envfile(), envs)
log.Debugf("Loading action inputs from %s", input.Inputfile())
inputs := parseEnvs(input.inputs)
_ = readEnvs(input.Inputfile(), inputs)
log.Debugf("Loading secrets from %s", input.Secretfile())
secrets := newSecrets(input.secrets)
_ = readEnvsEx(input.Secretfile(), secrets, true)
if _, hasGitHubToken := secrets["GITHUB_TOKEN"]; !hasGitHubToken {
ctx, cancel := common.EarlyCancelContext(ctx)
defer cancel()
secrets["GITHUB_TOKEN"], _ = gh.GetToken(ctx, "")
}
log.Debugf("Loading vars from %s", input.Varfile())
vars := newSecrets(input.vars)
_ = readEnvs(input.Varfile(), vars)
log.Debugf("Cleaning up %s old action cache format", input.actionCachePath)
entries, _ := os.ReadDir(input.actionCachePath)
for _, entry := range entries {
if strings.Contains(entry.Name(), "@") {
fullPath := filepath.Join(input.actionCachePath, entry.Name())
log.Debugf("Removing %s", fullPath)
_ = os.RemoveAll(fullPath)
}
}
matrixes := parseMatrix(input.matrix)
log.Debugf("Evaluated matrix inclusions: %v", matrixes)
// TODO switch to Gitea Schema when supported
plannerConfig := model.PlannerConfig{
Recursive: input.workflowRecurse,
Workflow: model.WorkflowConfig{
Strict: input.strict,
},
}
if input.gitea {
plannerConfig.Workflow.Schema = schema.GetGiteaWorkflowSchema()
}
planner, err := model.NewWorkflowPlanner(input.WorkflowsPath(), plannerConfig)
if err != nil {
return err
}
jobID, err := cmd.Flags().GetString("job")
if err != nil {
return err
}
// check if we should just list the workflows
list, err := cmd.Flags().GetBool("list")
if err != nil {
return err
}
// check if we should just validate the workflows
if input.validate {
return err
}
// check if we should just draw the graph
graph, err := cmd.Flags().GetBool("graph")
if err != nil {
return err
}
// collect all events from loaded workflows
events := planner.GetEvents()
// plan with filtered jobs - to be used for filtering only
var filterPlan *model.Plan
// Determine the event name to be filtered
var filterEventName string
if len(args) > 0 {
log.Debugf("Using first passed in arguments event for filtering: %s", args[0])
filterEventName = args[0]
} else if input.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.Debugf("Using first detected workflow event for filtering: %s", events[0])
filterEventName = events[0]
}
var plannerErr error
if jobID != "" {
log.Debugf("Preparing plan with a job: %s", jobID)
filterPlan, plannerErr = planner.PlanJob(jobID)
} else if filterEventName != "" {
log.Debugf("Preparing plan for a event: %s", filterEventName)
filterPlan, plannerErr = planner.PlanEvent(filterEventName)
} else {
log.Debugf("Preparing plan with all jobs")
filterPlan, plannerErr = planner.PlanAll()
}
if filterPlan == nil && plannerErr != nil {
return plannerErr
}
if list {
err = printList(filterPlan)
if err != nil {
return err
}
return plannerErr
}
if graph {
err = drawGraph(filterPlan)
if err != nil {
return err
}
return plannerErr
}
// plan with triggered jobs
var plan *model.Plan
// Determine the event name to be triggered
var eventName string
if len(args) > 0 {
log.Debugf("Using first passed in arguments event: %s", args[0])
eventName = args[0]
} else if len(events) == 1 && len(events[0]) > 0 {
log.Debugf("Using the only detected workflow event: %s", events[0])
eventName = events[0]
} else if input.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.Debugf("Using first detected workflow event: %s", events[0])
eventName = events[0]
} else {
log.Debugf("Using default workflow event: push")
eventName = "push"
}
// build the plan for this run
if jobID != "" {
log.Debugf("Planning job: %s", jobID)
plan, plannerErr = planner.PlanJob(jobID)
} else {
log.Debugf("Planning jobs for event: %s", eventName)
plan, plannerErr = planner.PlanEvent(eventName)
}
if plan != nil {
if len(plan.Stages) == 0 {
plannerErr = fmt.Errorf("could not find any stages to run. View the valid jobs with `act --list`. Use `act --help` to find how to filter by Job ID/Workflow/Event Name")
}
}
if plan == nil && plannerErr != nil {
return plannerErr
}
// check to see if the main branch was defined
defaultbranch, err := cmd.Flags().GetString("defaultbranch")
if err != nil {
return err
}
// Check if platforms flag is set, if not, run default image survey
if len(input.platforms) == 0 {
cfgFound := false
cfgLocations := configLocations()
for _, v := range cfgLocations {
_, err := os.Stat(v)
if os.IsExist(err) {
cfgFound = true
}
}
if !cfgFound && len(cfgLocations) > 0 {
// The first config location refers to the global config folder one
if err := defaultImageSurvey(cfgLocations[0]); err != nil {
log.Fatal(err)
}
input.platforms = readArgsFile(cfgLocations[0], true)
}
}
deprecationWarning := "--%s is deprecated and will be removed soon, please switch to cli: `--container-options \"%[2]s\"` or `.actrc`: `--container-options %[2]s`."
if input.privileged {
log.Warnf(deprecationWarning, "privileged", "--privileged")
}
if len(input.usernsMode) > 0 {
log.Warnf(deprecationWarning, "userns", fmt.Sprintf("--userns=%s", input.usernsMode))
}
if len(input.containerCapAdd) > 0 {
log.Warnf(deprecationWarning, "container-cap-add", fmt.Sprintf("--cap-add=%s", input.containerCapAdd))
}
if len(input.containerCapDrop) > 0 {
log.Warnf(deprecationWarning, "container-cap-drop", fmt.Sprintf("--cap-drop=%s", input.containerCapDrop))
}
// run the plan
config := &runner.Config{
Actor: input.actor,
EventName: eventName,
EventPath: input.EventPath(),
DefaultBranch: defaultbranch,
ForcePull: !input.actionOfflineMode && !input.pullIfNeeded,
ForceRebuild: !input.noRebuild,
ReuseContainers: input.reuseContainers,
Workdir: input.Workdir(),
ActionCacheDir: input.actionCachePath,
ActionOfflineMode: input.actionOfflineMode,
BindWorkdir: input.bindWorkdir,
LogOutput: !input.noOutput,
JSONLogger: input.jsonLogger,
LogPrefixJobID: input.logPrefixJobID,
Env: envs,
Secrets: secrets,
Vars: vars,
Inputs: inputs,
Token: secrets["GITHUB_TOKEN"],
InsecureSecrets: input.insecureSecrets,
Platforms: input.newPlatforms(),
Privileged: input.privileged,
UsernsMode: input.usernsMode,
ContainerArchitecture: input.containerArchitecture,
ContainerDaemonSocket: input.containerDaemonSocket,
ContainerOptions: input.containerOptions,
UseGitIgnore: input.useGitIgnore,
GitHubInstance: input.githubInstance,
GitHubServerURL: input.gitHubServerURL,
GitHubAPIServerURL: input.gitHubAPIServerURL,
GitHubGraphQlAPIServerURL: input.gitHubGraphQlAPIServerURL,
ContainerCapAdd: input.containerCapAdd,
ContainerCapDrop: input.containerCapDrop,
AutoRemove: input.autoRemove,
ArtifactServerPath: input.artifactServerPath,
ArtifactServerAddr: input.artifactServerAddr,
ArtifactServerPort: input.artifactServerPort,
NoSkipCheckout: input.noSkipCheckout,
RemoteName: input.remoteName,
ReplaceGheActionWithGithubCom: input.replaceGheActionWithGithubCom,
ReplaceGheActionTokenWithGithubCom: input.replaceGheActionTokenWithGithubCom,
Matrix: matrixes,
ContainerNetworkMode: docker_container.NetworkMode(input.networkName),
Parallel: input.parallel,
Planner: plannerConfig,
Action: model.ActionConfig{}, // TODO Gitea Action Schema
MainContextNames: []string{"github"},
}
if input.gitea {
config.Action.Schema = schema.GetGiteaActionSchema()
config.MainContextNames = append(config.MainContextNames, "gitea")
}
actionCache := runner.GoGitActionCache{
Path: config.ActionCacheDir,
}
config.ActionCache = &actionCache
if input.actionOfflineMode {
config.ActionCache = &runner.GoGitActionCacheOfflineMode{
Parent: actionCache,
}
}
if len(input.localRepository) > 0 {
localRepositories := map[string]string{}
for _, l := range input.localRepository {
k, v, _ := strings.Cut(l, "=")
localRepositories[k] = v
}
config.ActionCache = &runner.LocalRepositoryCache{
Parent: config.ActionCache,
LocalRepositories: localRepositories,
CacheDirCache: map[string]string{},
}
}
var r runner.Runner
if eventName == "workflow_call" {
// Do not use the totally broken code and instead craft a fake caller
convertedInputs := make(map[string]interface{})
for k, v := range inputs {
var raw interface{}
if err := yaml.Unmarshal([]byte(v), &raw); err != nil {
return fmt.Errorf("failed to unmarshal input %s: %w", k, err)
}
convertedInputs[k] = raw
}
r, err = runner.NewReusableWorkflowRunner(&runner.RunContext{
Config: config,
Name: "_",
JobName: "_",
Run: &model.Run{
JobID: "_",
Workflow: &model.Workflow{
Jobs: map[string]*model.Job{
"_": {
Name: "_",
With: convertedInputs,
},
},
},
},
})
} else {
r, err = runner.New(config)
}
if err != nil {
return err
}
cancel := artifacts.Serve(ctx, input.artifactServerPath, input.artifactServerAddr, input.artifactServerPort)
const cacheURLKey = "ACTIONS_CACHE_URL"
var cacheHandler *artifactcache.Handler
if !input.noCacheServer && envs[cacheURLKey] == "" {
var err error
cacheHandler, err = artifactcache.StartHandler(input.cacheServerPath, input.cacheServerAddr, input.cacheServerPort, common.Logger(ctx))
if err != nil {
return err
}
envs[cacheURLKey] = cacheHandler.ExternalURL() + "/"
}
ctx = common.WithDryrun(ctx, input.dryrun)
if watch, err := cmd.Flags().GetBool("watch"); err != nil {
return err
} else if watch {
err = watchAndRun(ctx, r.NewPlanExecutor(plan))
if err != nil {
return err
}
return plannerErr
}
executor := r.NewPlanExecutor(plan).Finally(func(_ context.Context) error {
cancel()
_ = cacheHandler.Close()
return nil
})
err = executor(ctx)
if err != nil {
return err
}
return plannerErr
}
}
func defaultImageSurvey(actrc string) error {
var answer string
confirmation := &survey.Select{
Message: "Please choose the default image you want to use with act:\n - Large size image: ca. 17GB download + 53.1GB storage, you will need 75GB of free disk space, snapshots of GitHub Hosted Runners without snap and pulled docker images\n - Medium size image: ~500MB, includes only necessary tools to bootstrap actions and aims to be compatible with most actions\n - Micro size image: <200MB, contains only NodeJS required to bootstrap actions, doesn't work with all actions\n\nDefault image and other options can be changed manually in " + configLocations()[0] + " (please refer to https://github.com/nektos/act#configuration for additional information about file structure)",
Help: "If you want to know why act asks you that, please go to https://github.com/actions-oss/act-cli/issues/107",
Default: "Medium",
Options: []string{"Large", "Medium", "Micro"},
}
err := survey.AskOne(confirmation, &answer)
if err != nil {
return err
}
var option string
switch answer {
case "Large":
option = "-P ubuntu-latest=catthehacker/ubuntu:full-latest\n-P ubuntu-22.04=catthehacker/ubuntu:full-22.04\n-P ubuntu-20.04=catthehacker/ubuntu:full-20.04\n-P ubuntu-18.04=catthehacker/ubuntu:full-18.04\n"
case "Medium":
option = "-P ubuntu-latest=catthehacker/ubuntu:act-latest\n-P ubuntu-22.04=catthehacker/ubuntu:act-22.04\n-P ubuntu-20.04=catthehacker/ubuntu:act-20.04\n-P ubuntu-18.04=catthehacker/ubuntu:act-18.04\n"
case "Micro":
option = "-P ubuntu-latest=node:16-buster-slim\n-P ubuntu-22.04=node:16-bullseye-slim\n-P ubuntu-20.04=node:16-buster-slim\n-P ubuntu-18.04=node:16-buster-slim\n"
}
f, err := os.Create(actrc)
if err != nil {
return err
}
_, err = f.WriteString(option)
if err != nil {
_ = f.Close()
return err
}
err = f.Close()
if err != nil {
return err
}
return nil
}
func watchAndRun(ctx context.Context, fn common.Executor) error {
dir, err := os.Getwd()
if err != nil {
return err
}
ignoreFile := filepath.Join(dir, ".gitignore")
ignore := &gitignore.GitIgnore{}
if info, err := os.Stat(ignoreFile); err == nil && !info.IsDir() {
ignore, err = gitignore.CompileIgnoreFile(ignoreFile)
if err != nil {
return fmt.Errorf("compile %q: %w", ignoreFile, err)
}
}
folderWatcher := fswatch.NewFolderWatcher(
dir,
true,
ignore.MatchesPath,
2, // 2 seconds
)
folderWatcher.Start()
defer folderWatcher.Stop()
// run once before watching
if err := fn(ctx); err != nil {
return err
}
earlyCancelCtx, cancel := common.EarlyCancelContext(ctx)
defer cancel()
for folderWatcher.IsRunning() {
log.Debugf("Watching %s for changes", dir)
select {
case <-earlyCancelCtx.Done():
return nil
case changes := <-folderWatcher.ChangeDetails():
log.Debugf("%s", changes.String())
if err := fn(ctx); err != nil {
return err
}
}
}
return nil
}

View File

@@ -1,109 +0,0 @@
package cmd
import (
"context"
"path"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
func TestReadSecrets(t *testing.T) {
secrets := map[string]string{}
ret := readEnvsEx(path.Join("testdata", "secrets.yml"), secrets, true)
assert.True(t, ret)
assert.Equal(t, `line1
line2
line3
`, secrets["MYSECRET"])
}
func TestReadEnv(t *testing.T) {
secrets := map[string]string{}
ret := readEnvs(path.Join("testdata", "secrets.yml"), secrets)
assert.True(t, ret)
assert.Equal(t, `line1
line2
line3
`, secrets["mysecret"])
}
func TestListOptions(t *testing.T) {
rootCmd := createRootCommand(context.Background(), &Input{}, "")
err := newRunCommand(context.Background(), &Input{
listOptions: true,
})(rootCmd, []string{})
assert.NoError(t, err)
}
func TestRun(t *testing.T) {
rootCmd := createRootCommand(context.Background(), &Input{}, "")
err := newRunCommand(context.Background(), &Input{
platforms: []string{"ubuntu-latest=node:16-buster-slim"},
workdir: "../pkg/runner/testdata/",
workflowsPath: "./basic/push.yml",
})(rootCmd, []string{})
assert.NoError(t, err)
}
func TestRunPush(t *testing.T) {
rootCmd := createRootCommand(context.Background(), &Input{}, "")
err := newRunCommand(context.Background(), &Input{
platforms: []string{"ubuntu-latest=node:16-buster-slim"},
workdir: "../pkg/runner/testdata/",
workflowsPath: "./basic/push.yml",
})(rootCmd, []string{"push"})
assert.NoError(t, err)
}
func TestRunPushJsonLogger(t *testing.T) {
rootCmd := createRootCommand(context.Background(), &Input{}, "")
err := newRunCommand(context.Background(), &Input{
platforms: []string{"ubuntu-latest=node:16-buster-slim"},
workdir: "../pkg/runner/testdata/",
workflowsPath: "./basic/push.yml",
jsonLogger: true,
})(rootCmd, []string{"push"})
assert.NoError(t, err)
}
func TestFlags(t *testing.T) {
for _, f := range []string{"graph", "list", "bug-report", "man-page"} {
t.Run("TestFlag-"+f, func(t *testing.T) {
rootCmd := createRootCommand(context.Background(), &Input{}, "")
err := rootCmd.Flags().Set(f, "true")
assert.NoError(t, err)
err = newRunCommand(context.Background(), &Input{
platforms: []string{"ubuntu-latest=node:16-buster-slim"},
workdir: "../pkg/runner/testdata/",
workflowsPath: "./basic/push.yml",
})(rootCmd, []string{})
assert.NoError(t, err)
})
}
}
func TestWorkflowCall(t *testing.T) {
rootCmd := createRootCommand(context.Background(), &Input{}, "")
err := newRunCommand(context.Background(), &Input{
platforms: []string{"ubuntu-latest=node:16-buster-slim"},
workdir: "../pkg/runner/testdata/",
workflowsPath: "./workflow_call_inputs/workflow_call_inputs.yml",
inputs: []string{"required=required input", "boolean=true"},
})(rootCmd, []string{"workflow_call"})
assert.NoError(t, err)
}
func TestLocalRepositories(t *testing.T) {
wd, _ := filepath.Abs("../pkg/runner/testdata/")
rootCmd := createRootCommand(context.Background(), &Input{}, "")
err := newRunCommand(context.Background(), &Input{
githubInstance: "github.com",
platforms: []string{"ubuntu-latest=node:16-buster-slim"},
workdir: wd,
workflowsPath: "./remote-action-composite-action-ref-partial-override/push.yml",
localRepository: []string{"needs/override@main=" + wd + "/actions-environment-and-context-tests"},
})(rootCmd, []string{"push"})
assert.NoError(t, err)
}

View File

@@ -1,42 +0,0 @@
package cmd
import (
"fmt"
"os"
"strings"
log "github.com/sirupsen/logrus"
"golang.org/x/term"
)
type secrets map[string]string
func newSecrets(secretList []string) secrets {
s := make(map[string]string)
for _, secretPair := range secretList {
secretPairParts := strings.SplitN(secretPair, "=", 2)
secretPairParts[0] = strings.ToUpper(secretPairParts[0])
if strings.ToUpper(s[secretPairParts[0]]) == secretPairParts[0] {
log.Errorf("secret %s is already defined (secrets are case insensitive)", secretPairParts[0])
}
if len(secretPairParts) == 2 {
s[secretPairParts[0]] = secretPairParts[1]
} else if env, ok := os.LookupEnv(secretPairParts[0]); ok && env != "" {
s[secretPairParts[0]] = env
} else {
fmt.Printf("Provide value for '%s': ", secretPairParts[0])
val, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
log.Errorf("failed to read input: %v", err)
os.Exit(1)
}
s[secretPairParts[0]] = string(val)
}
}
return s
}
func (s secrets) AsMap() map[string]string {
return s
}

View File

@@ -1,4 +0,0 @@
mysecret: |
line1
line2
line3

View File

@@ -1,12 +0,0 @@
coverage:
status:
project:
default:
target: auto # auto compares coverage to the previous base commit
threshold: 1%
patch:
default:
target: 50%
ignore:
# Files generated by Google Protobuf do not require coverage
- '**/*.pb.go'

50
go.mod
View File

@@ -4,58 +4,62 @@ go 1.26.0
require (
code.gitea.io/actions-proto-go v0.4.1
code.gitea.io/gitea-vet v0.2.3
connectrpc.com/connect v1.19.1
github.com/actions-oss/act-cli v0.0.0 // will be replaced
github.com/avast/retry-go/v4 v4.7.0
github.com/docker/docker v28.5.1+incompatible
github.com/docker/docker v25.0.13+incompatible
github.com/joho/godotenv v1.5.1
github.com/mattn/go-isatty v0.0.20
github.com/nektos/act v0.0.0 // will be replaced
github.com/sirupsen/logrus v1.9.4
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
go.yaml.in/yaml/v4 v4.0.0-rc.3
golang.org/x/term v0.40.0
golang.org/x/time v0.14.0
golang.org/x/time v0.14.0 // indirect
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
gotest.tools/v3 v3.5.2
)
require go.yaml.in/yaml/v4 v4.0.0-rc.3
require github.com/prometheus/client_golang v1.23.2
require (
cyphar.com/go-pathrs v0.2.3 // indirect
dario.cat/mergo v1.0.2 // indirect
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 // indirect
github.com/Masterminds/semver v1.5.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/avast/retry-go v3.0.0+incompatible // indirect
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/containerd v1.7.29 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/creack/pty v1.1.24 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/cli v28.5.1+incompatible // indirect
github.com/docker/cli v25.0.3+incompatible // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/docker-credential-helpers v0.9.5 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.7.0 // indirect
github.com/go-git/go-git/v5 v5.16.5 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/imdario/mergo v0.3.16 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/julienschmidt/httprouter v1.3.0 // indirect
@@ -63,19 +67,28 @@ require (
github.com/kevinburke/ssh_config v1.6.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-runewidth v0.0.20 // indirect
github.com/mattn/go-shellwords v1.0.12 // indirect
github.com/mitchellh/mapstructure v1.1.2 // indirect
github.com/moby/buildkit v0.13.2 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/opencontainers/selinux v1.13.1 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/rhysd/actionlint v1.7.11 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
@@ -89,19 +102,20 @@ require (
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.opentelemetry.io/proto/otlp v1.0.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/tools v0.42.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
google.golang.org/grpc v1.67.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
replace github.com/actions-oss/act-cli => gitea.com/actions-oss/act-cli v0.4.2-0.20260220200604-40ee0f3ef6fc
replace github.com/nektos/act => gitea.com/gitea/act v0.261.10
// Remove after github.com/docker/distribution is updated to support distribution/reference v0.6.0
// (pulled in via moby/buildkit, breaks on undefined: reference.SplitHostname)

109
go.sum
View File

@@ -1,15 +1,13 @@
code.gitea.io/actions-proto-go v0.4.1 h1:l0EYhjsgpUe/1VABo2eK7zcoNX2W44WOnb0MSLrKfls=
code.gitea.io/actions-proto-go v0.4.1/go.mod h1:mn7Wkqz6JbnTOHQpot3yDeHx+O5C9EGhMEE+htvHBas=
code.gitea.io/gitea-vet v0.2.3 h1:gdFmm6WOTM65rE8FUBTRzeQZYzXePKSSB1+r574hWwI=
code.gitea.io/gitea-vet v0.2.3/go.mod h1:zcNbT/aJEmivCAhfmkHOlT645KNOf9W2KnkLgFjGGfE=
connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14=
connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
cyphar.com/go-pathrs v0.2.3 h1:0pH8gep37wB0BgaXrEaN1OtZhUMeS7VvaejSr6i822o=
cyphar.com/go-pathrs v0.2.3/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
gitea.com/actions-oss/act-cli v0.4.2-0.20260220200604-40ee0f3ef6fc h1:KXg17X1FZhnUM4J0bVG3gVS6jQCtkR6U5aV2ch0tJYA=
gitea.com/actions-oss/act-cli v0.4.2-0.20260220200604-40ee0f3ef6fc/go.mod h1:tl2dPJQRui7za899nfJIhPqP3a8ii+ySEvzL18mjC0U=
gitea.com/gitea/act v0.261.10 h1:ndwbtuMXXz1dpYF2iwY1/PkgKNETo4jmPXfinTZt8cs=
gitea.com/gitea/act v0.261.10/go.mod h1:oIkqQHvU0lfuIWwcpqa4FmU+t3prA89tgkuHUTsrI2c=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
@@ -19,26 +17,30 @@ github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF0
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/Microsoft/hcsshim v0.11.7 h1:vl/nj3Bar/CvJSYo7gIQPyRWc9f3c6IeSNavBTSZNZQ=
github.com/Microsoft/hcsshim v0.11.7/go.mod h1:MV8xMfmECjl5HdO7U/3/hFVnkmSBjAjmA09d4bExKcU=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0=
github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
github.com/avast/retry-go/v4 v4.7.0 h1:yjDs35SlGvKwRNSykujfjdMxMhMQQM0TnIjJaHB+Zio=
github.com/avast/retry-go/v4 v4.7.0/go.mod h1:ZMPDa3sY2bKgpLtap9JRUgk2yTAba7cgiFhqxY2Sg6Q=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/containerd v1.7.29 h1:90fWABQsaN9mJhGkoVnuzEY+o1XDPbg9BTC9QTAHnuE=
github.com/containerd/containerd v1.7.29/go.mod h1:azUkWcOvHrWvaiUjSQH0fjzuHIwSPg1WL5PshGP4Szs=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -51,10 +53,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0=
github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/cli v28.5.1+incompatible h1:ESutzBALAD6qyCLqbQSEf1a/U8Ybms5agw59yGVc+yY=
github.com/docker/cli v28.5.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM=
github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/cli v25.0.3+incompatible h1:KLeNs7zws74oFuVhgZQ5ONGZiXUUdgsdy6/EsX/6284=
github.com/docker/cli v25.0.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/docker v25.0.13+incompatible h1:YeBrkUd3q0ZoRDNoEzuopwCLU+uD8GZahDHwBdsTnkU=
github.com/docker/docker v25.0.13+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY=
github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
@@ -65,6 +69,8 @@ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
@@ -82,10 +88,10 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -96,6 +102,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg=
github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
@@ -108,6 +116,8 @@ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNU
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
@@ -119,16 +129,22 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/moby/buildkit v0.13.2 h1:nXNszM4qD9E7QtG7bFWPnDI1teUQFQglBzon/IU3SzI=
github.com/moby/buildkit v0.13.2/go.mod h1:2cyVOv9NoHM7arphK9ZfHIWKn9YVZRFd1wXB8kKmEzY=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
@@ -139,6 +155,8 @@ github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
@@ -153,6 +171,18 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/rhysd/actionlint v1.7.11 h1:m+aSuCpCIClS8X02xMG4Z8s87fCHPsAtYkAoWGQZgEE=
github.com/rhysd/actionlint v1.7.11/go.mod h1:8n50YougV9+50niD7oxgDTZ1KbN/ZnKiQ2xpLFeVhsI=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -192,7 +222,8 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.etcd.io/bbolt v1.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
@@ -205,8 +236,8 @@ go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0 h1:cl5P5/GIfFh4t6xyruOgJP5QiA1pw4fYYdv6nc6CBWw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0/go.mod h1:zgBdWWAu7oEEMC06MMKc5NLbA/1YDXV1sMpSqEeLQLg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0 h1:j9+03ymgYhPKmeXGk5Zu+cIZOlVzd9Zv7QIiyItjFBU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0/go.mod h1:Y5+XiUG4Emn1hTfciPzGPJaSI+RpDts6BnCIir0SLqk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0 h1:digkEZCJWobwBqMwC0cwCq8/wkkRy/OowZg5OArWZrM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0/go.mod h1:/OpE/y70qVkndM0TrxT4KBoN3RsFZP0QaofcfYrj76I=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
@@ -217,32 +248,39 @@ go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZY
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -257,6 +295,7 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
@@ -264,12 +303,13 @@ golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200325010219-a49f79bcc224/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto v0.0.0-20231211222908-989df2bf70f3 h1:1hfbdAfFbkmpg41000wDVqr7jUpK/Yo+LPnIxxGzmkg=
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 h1:wKguEg1hsxI2/L3hUYrpo1RVi48K+uTyzKqprwLXsb8=
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142/go.mod h1:d6be+8HhtEtucleCbxpPW9PA9XwISACu8nvpPqF0BVo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
@@ -285,6 +325,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -1,433 +0,0 @@
#!/bin/sh
set -e
# Code originally generated by godownloader on 2021-12-22T16:10:52Z. DO NOT EDIT.
# (godownloader is deprecated, so changes to this script are maintained in install.sh in https://github.com/nektos/act)
#
usage() {
this=$1
cat <<EOF
$this: download go binaries for nektos/act
Usage: $this [-b bindir] [-d] [-f] [tag]
-b sets bindir or installation directory, Defaults to ./bin
-d turns on debug logging
-f forces installation, bypassing version checks
[tag] is a tag from
https://github.com/nektos/act/releases
If tag is missing, then the latest will be used.
EOF
exit 2
}
parse_args() {
#BINDIR is ./bin unless set be ENV
# over-ridden by flag below
BINDIR=${BINDIR:-./bin}
while getopts "b:dfh?x" arg; do
case "$arg" in
b) BINDIR="$OPTARG" ;;
d) log_set_priority 10 ;;
f) FORCE_INSTALL="true" ;;
h | \?) usage "$0" ;;
x) set -x ;;
esac
done
shift $((OPTIND - 1))
TAG=$1
}
# this function wraps all the destructive operations
# if a curl|bash cuts off the end of the script due to
# network, either nothing will happen or will syntax error
# out preventing half-done work
execute() {
tmpdir=$(mktemp -d)
log_debug "downloading files into ${tmpdir}"
http_download "${tmpdir}/${TARBALL}" "${TARBALL_URL}"
http_download "${tmpdir}/${CHECKSUM}" "${CHECKSUM_URL}"
hash_sha256_verify "${tmpdir}/${TARBALL}" "${tmpdir}/${CHECKSUM}"
srcdir="${tmpdir}"
(cd "${tmpdir}" && untar "${TARBALL}")
test ! -d "${BINDIR}" && install -d "${BINDIR}"
for binexe in $BINARIES; do
if [ "$OS" = "windows" ]; then
binexe="${binexe}.exe"
fi
install "${srcdir}/${binexe}" "${BINDIR}/"
log_info "installed ${BINDIR}/${binexe}"
done
rm -rf "${tmpdir}"
}
get_binaries() {
case "$PLATFORM" in
darwin/386) BINARIES="act" ;;
darwin/amd64) BINARIES="act" ;;
darwin/arm64) BINARIES="act" ;;
darwin/armv6) BINARIES="act" ;;
darwin/armv7) BINARIES="act" ;;
linux/386) BINARIES="act" ;;
linux/amd64) BINARIES="act" ;;
linux/arm64) BINARIES="act" ;;
linux/armv6) BINARIES="act" ;;
linux/armv7) BINARIES="act" ;;
windows/386) BINARIES="act" ;;
windows/amd64) BINARIES="act" ;;
windows/arm64) BINARIES="act" ;;
windows/armv6) BINARIES="act" ;;
windows/armv7) BINARIES="act" ;;
*)
log_crit "platform $PLATFORM is not supported. Make sure this script is up-to-date and file request at https://github.com/${PREFIX}/issues/new"
exit 1
;;
esac
}
tag_to_version() {
if [ -z "${TAG}" ]; then
log_info "checking GitHub for latest tag"
else
log_info "checking GitHub for tag '${TAG}'"
fi
REALTAG=$(github_release "$OWNER/$REPO" "${TAG}") && true
if test -z "$REALTAG"; then
log_crit "unable to find '${TAG}' - use 'latest' or see https://github.com/${PREFIX}/releases for details"
exit 1
fi
# if version starts with 'v', remove it
TAG="$REALTAG"
VERSION=${TAG#v}
}
adjust_format() {
# change format (tar.gz or zip) based on OS
case ${OS} in
windows) FORMAT=zip ;;
esac
true
}
adjust_os() {
# adjust archive name based on OS
case ${OS} in
386) OS=i386 ;;
amd64) OS=x86_64 ;;
darwin) OS=Darwin ;;
linux) OS=Linux ;;
windows) OS=Windows ;;
esac
true
}
adjust_arch() {
# adjust archive name based on ARCH
case ${ARCH} in
386) ARCH=i386 ;;
amd64) ARCH=x86_64 ;;
darwin) ARCH=Darwin ;;
linux) ARCH=Linux ;;
windows) ARCH=Windows ;;
esac
true
}
check_installed_version() {
# Check if force install flag is set
if [ "${FORCE_INSTALL}" = "true" ]; then
log_info "force install enabled. Skipping version check."
return
fi
# Check if the binary exists
if is_command "$BINARY"; then
# Extract installed version using cut
INSTALLED_VERSION=$($BINARY --version | cut -d' ' -f3)
if [ -z "$INSTALLED_VERSION" ]; then
log_err "failed to detect installed version. Proceeding with installation."
return
fi
log_info "found installed version: $INSTALLED_VERSION"
# Compare versions
if [ "$INSTALLED_VERSION" = "$VERSION" ]; then
log_info "$BINARY version $INSTALLED_VERSION is already installed."
exit 0
else
log_debug "updating $BINARY from version $INSTALLED_VERSION to $VERSION..."
fi
else
log_debug "$BINARY is not installed. Proceeding with installation..."
fi
}
cat /dev/null <<EOF
------------------------------------------------------------------------
https://github.com/client9/shlib - portable posix shell functions
Public domain - http://unlicense.org
https://github.com/client9/shlib/blob/master/LICENSE.md
but credit (and pull requests) appreciated.
------------------------------------------------------------------------
EOF
is_command() {
command -v "$1" >/dev/null
}
echoerr() {
echo "$@" 1>&2
}
log_prefix() {
echo "$0"
}
_logp=6
log_set_priority() {
_logp="$1"
}
log_priority() {
if test -z "$1"; then
echo "$_logp"
return
fi
[ "$1" -le "$_logp" ]
}
log_tag() {
case $1 in
0) echo "emerg" ;;
1) echo "alert" ;;
2) echo "crit" ;;
3) echo "err" ;;
4) echo "warning" ;;
5) echo "notice" ;;
6) echo "info" ;;
7) echo "debug" ;;
*) echo "$1" ;;
esac
}
log_debug() {
log_priority 7 || return 0
echoerr "$(log_prefix)" "$(log_tag 7)" "$@"
}
log_info() {
log_priority 6 || return 0
echoerr "$(log_prefix)" "$(log_tag 6)" "$@"
}
log_err() {
log_priority 3 || return 0
echoerr "$(log_prefix)" "$(log_tag 3)" "$@"
}
log_crit() {
log_priority 2 || return 0
echoerr "$(log_prefix)" "$(log_tag 2)" "$@"
}
uname_os() {
os=$(uname -s | tr '[:upper:]' '[:lower:]')
case "$os" in
cygwin_nt*) os="windows" ;;
mingw*) os="windows" ;;
msys_nt*) os="windows" ;;
esac
echo "$os"
}
uname_arch() {
arch=$(uname -m)
case $arch in
x86_64) arch="amd64" ;;
x86) arch="386" ;;
i686) arch="386" ;;
i386) arch="386" ;;
aarch64) arch="arm64" ;;
armv5*) arch="armv5" ;;
armv6*) arch="armv6" ;;
armv7*) arch="armv7" ;;
esac
echo ${arch}
}
uname_os_check() {
os=$(uname_os)
case "$os" in
darwin) return 0 ;;
dragonfly) return 0 ;;
freebsd) return 0 ;;
linux) return 0 ;;
android) return 0 ;;
nacl) return 0 ;;
netbsd) return 0 ;;
openbsd) return 0 ;;
plan9) return 0 ;;
solaris) return 0 ;;
windows) return 0 ;;
esac
log_crit "uname_os_check '$(uname -s)' got converted to '$os' which is not a GOOS value. Please file bug at https://github.com/client9/shlib"
return 1
}
uname_arch_check() {
arch=$(uname_arch)
case "$arch" in
386) return 0 ;;
amd64) return 0 ;;
arm64) return 0 ;;
armv5) return 0 ;;
armv6) return 0 ;;
armv7) return 0 ;;
ppc64) return 0 ;;
ppc64le) return 0 ;;
mips) return 0 ;;
mipsle) return 0 ;;
mips64) return 0 ;;
mips64le) return 0 ;;
s390x) return 0 ;;
amd64p32) return 0 ;;
esac
log_crit "uname_arch_check '$(uname -m)' got converted to '$arch' which is not a GOARCH value. Please file bug report at https://github.com/client9/shlib"
return 1
}
untar() {
tarball=$1
case "${tarball}" in
*.tar.gz | *.tgz) tar --no-same-owner -xzf "${tarball}" ;;
*.tar) tar --no-same-owner -xf "${tarball}" ;;
*.zip) unzip "${tarball}" ;;
*)
log_err "untar unknown archive format for ${tarball}"
return 1
;;
esac
}
http_download_curl() {
local_file=$1
source_url=$2
header=$3
if [ -z "$header" ]; then
code=$(curl -w '%{http_code}' -sL -o "$local_file" "$source_url")
else
code=$(curl -w '%{http_code}' -sL -H "$header" -o "$local_file" "$source_url")
fi
if [ "$code" != "200" ]; then
log_debug "http_download_curl received HTTP status $code"
return 1
fi
return 0
}
http_download_wget() {
local_file=$1
source_url=$2
header=$3
if [ -z "$header" ]; then
wget -q -O "$local_file" "$source_url"
else
wget -q --header "$header" -O "$local_file" "$source_url"
fi
}
http_download() {
log_debug "http_download $2"
if is_command curl; then
http_download_curl "$@"
return
elif is_command wget; then
http_download_wget "$@"
return
fi
log_crit "http_download unable to find wget or curl"
return 1
}
http_copy() {
tmp=$(mktemp)
http_download "${tmp}" "$1" "$2" || return 1
body=$(cat "$tmp")
rm -f "${tmp}"
echo "$body"
}
github_release() {
owner_repo=$1
version=$2
test -z "$version" && version="latest"
giturl="https://github.com/${owner_repo}/releases/${version}"
json=$(http_copy "$giturl" "Accept:application/json")
test -z "$json" && return 1
version=$(echo "$json" | tr -s '\n' ' ' | sed 's/.*"tag_name":"//' | sed 's/".*//')
test -z "$version" && return 1
echo "$version"
}
hash_sha256() {
TARGET=${1:-/dev/stdin}
if is_command gsha256sum; then
hash=$(gsha256sum "$TARGET") || return 1
echo "$hash" | cut -d ' ' -f 1
elif is_command sha256sum; then
hash=$(sha256sum "$TARGET") || return 1
echo "$hash" | cut -d ' ' -f 1
elif is_command shasum; then
hash=$(shasum -a 256 "$TARGET" 2>/dev/null) || return 1
echo "$hash" | cut -d ' ' -f 1
elif is_command openssl; then
hash=$(openssl -dst openssl dgst -sha256 "$TARGET") || return 1
echo "$hash" | cut -d ' ' -f a
else
log_crit "hash_sha256 unable to find command to compute sha-256 hash"
return 1
fi
}
hash_sha256_verify() {
TARGET=$1
checksums=$2
if [ -z "$checksums" ]; then
log_err "hash_sha256_verify checksum file not specified in arg2"
return 1
fi
BASENAME=${TARGET##*/}
want=$(grep "${BASENAME}" "${checksums}" 2>/dev/null | tr '\t' ' ' | cut -d ' ' -f 1)
if [ -z "$want" ]; then
log_err "hash_sha256_verify unable to find checksum for '${TARGET}' in '${checksums}'"
return 1
fi
got=$(hash_sha256 "$TARGET")
if [ "$want" != "$got" ]; then
log_err "hash_sha256_verify checksum for '$TARGET' did not verify ${want} vs $got"
return 1
fi
}
cat /dev/null <<EOF
------------------------------------------------------------------------
End of functions from https://github.com/client9/shlib
------------------------------------------------------------------------
EOF
PROJECT_NAME="act"
OWNER=nektos
REPO="act"
BINARY=act
FORMAT=tar.gz
OS=$(uname_os)
ARCH=$(uname_arch)
PREFIX="$OWNER/$REPO"
# use in logging routines
log_prefix() {
echo "$PREFIX"
}
PLATFORM="${OS}/${ARCH}"
GITHUB_DOWNLOAD=https://github.com/${OWNER}/${REPO}/releases/download
uname_os_check "$OS"
uname_arch_check "$ARCH"
parse_args "$@"
get_binaries
tag_to_version
check_installed_version
adjust_format
adjust_os
adjust_arch
log_info "found version: ${VERSION} for ${TAG}/${OS}/${ARCH}"
NAME=${PROJECT_NAME}_${OS}_${ARCH}
TARBALL=${NAME}.${FORMAT}
TARBALL_URL=${GITHUB_DOWNLOAD}/${TAG}/${TARBALL}
CHECKSUM=checksums.txt
CHECKSUM_URL=${GITHUB_DOWNLOAD}/${TAG}/${CHECKSUM}
execute

View File

@@ -10,7 +10,7 @@ import (
"gitea.com/gitea/act_runner/internal/pkg/config"
"github.com/actions-oss/act-cli/pkg/artifactcache"
"github.com/nektos/act/pkg/artifactcache"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)

View File

@@ -8,10 +8,10 @@ import (
"fmt"
"os"
"github.com/spf13/cobra"
"gitea.com/gitea/act_runner/internal/pkg/config"
"gitea.com/gitea/act_runner/internal/pkg/ver"
"github.com/spf13/cobra"
)
func Execute(ctx context.Context) {

View File

@@ -16,18 +16,19 @@ import (
"strings"
"time"
"connectrpc.com/connect"
"github.com/mattn/go-isatty"
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/metrics"
"gitea.com/gitea/act_runner/internal/pkg/ver"
"connectrpc.com/connect"
"github.com/mattn/go-isatty"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) func(cmd *cobra.Command, args []string) error {
@@ -149,6 +150,15 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
resp.Msg.Runner.Name, resp.Msg.Runner.Version, resp.Msg.Runner.Labels)
}
if cfg.Metrics.Enabled {
metrics.Init()
metrics.RunnerInfo.WithLabelValues(ver.Version(), resp.Msg.Runner.Name).Set(1)
metrics.RunnerCapacity.Set(float64(cfg.Runner.Capacity))
metrics.RegisterUptimeFunc(time.Now())
metrics.RegisterRunningJobsFunc(runner.RunningCount, cfg.Runner.Capacity)
metrics.StartServer(ctx, cfg.Metrics.Addr)
}
poller := poll.New(cfg, cli, runner)
if daemArgs.Once || reg.Ephemeral {

View File

@@ -13,16 +13,15 @@ import (
"path/filepath"
"strconv"
"strings"
"time"
"gitea.com/gitea/act_runner/internal/app/run"
"github.com/actions-oss/act-cli/pkg/artifactcache"
"github.com/actions-oss/act-cli/pkg/artifacts"
"github.com/actions-oss/act-cli/pkg/common"
"github.com/actions-oss/act-cli/pkg/model"
"github.com/actions-oss/act-cli/pkg/runner"
"github.com/actions-oss/act-cli/pkg/schema"
"github.com/docker/docker/api/types/container"
"github.com/joho/godotenv"
"github.com/nektos/act/pkg/artifactcache"
"github.com/nektos/act/pkg/artifacts"
"github.com/nektos/act/pkg/common"
"github.com/nektos/act/pkg/model"
"github.com/nektos/act/pkg/runner"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"golang.org/x/term"
@@ -313,12 +312,7 @@ func runExecList(planner model.WorkflowPlanner, execArgs *executeArgs) error {
func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command, args []string) error {
return func(cmd *cobra.Command, args []string) error {
planner, err := model.NewWorkflowPlanner(execArgs.WorkflowsPath(), model.PlannerConfig{
Recursive: !execArgs.noWorkflowRecurse,
Workflow: model.WorkflowConfig{
Schema: schema.GetGiteaWorkflowSchema(),
},
})
planner, err := model.NewWorkflowPlanner(execArgs.WorkflowsPath(), execArgs.noWorkflowRecurse)
if err != nil {
return err
}
@@ -367,11 +361,10 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
}
}
// TODO GITEA
// maxLifetime := 3 * time.Hour
// if deadline, ok := ctx.Deadline(); ok {
// maxLifetime = time.Until(deadline)
// }
maxLifetime := 3 * time.Hour
if deadline, ok := ctx.Deadline(); ok {
maxLifetime = time.Until(deadline)
}
// init a cache server
handler, err := artifactcache.StartHandler("", "", 0, log.StandardLogger().WithField("module", "cache_request"))
@@ -428,16 +421,14 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
NoSkipCheckout: execArgs.noSkipCheckout,
// PresetGitHubContext: preset,
// EventJSON: string(eventJSON),
// TODO GITEA
// ContainerNamePrefix: "GITEA-ACTIONS-TASK-" + eventName,
// ContainerMaxLifetime: maxLifetime,
ContainerNamePrefix: "GITEA-ACTIONS-TASK-" + eventName,
ContainerMaxLifetime: maxLifetime,
ContainerNetworkMode: container.NetworkMode(execArgs.network),
// TODO GITEA
// DefaultActionInstance: execArgs.defaultActionsURL,
// PlatformPicker: func(_ []string) string {
// return execArgs.image
// },
// ValidVolumes: []string{"**"}, // All volumes are allowed for `exec` command
DefaultActionInstance: execArgs.defaultActionsURL,
PlatformPicker: func(_ []string) string {
return execArgs.image
},
ValidVolumes: []string{"**"}, // All volumes are allowed for `exec` command
}
config.Env["ACT_EXEC"] = "true"
@@ -448,8 +439,10 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
config.Token = t
}
// TODO GITEA
ctx = runner.WithJobLoggerFactory(ctx, &run.JobLoggerFactoryWithInfoLevel{})
if !execArgs.debug {
logLevel := log.InfoLevel
config.JobLoggerLevel = &logLevel
}
r, err := runner.New(config)
if err != nil {

View File

@@ -14,17 +14,17 @@ import (
"strings"
"time"
"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"
pingv1 "code.gitea.io/actions-proto-go/ping/v1"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"connectrpc.com/connect"
"github.com/mattn/go-isatty"
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"
)
// runRegister registers a runner to the server

View File

@@ -7,22 +7,29 @@ import (
"context"
"errors"
"fmt"
"math/rand/v2"
"sync"
"sync/atomic"
"time"
"gitea.com/gitea/act_runner/internal/pkg/client"
"gitea.com/gitea/act_runner/internal/pkg/config"
"gitea.com/gitea/act_runner/internal/pkg/metrics"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"connectrpc.com/connect"
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"
)
// TaskRunner abstracts task execution so the poller can be tested
// without a real runner.
type TaskRunner interface {
Run(ctx context.Context, task *runnerv1.Task) error
}
type Poller struct {
client client.Client
runner *run.Runner
runner TaskRunner
cfg *config.Config
tasksVersion atomic.Int64 // tasksVersion used to store the version of the last task fetched from the Gitea.
@@ -35,7 +42,19 @@ type Poller struct {
done chan struct{}
}
func New(cfg *config.Config, client client.Client, runner *run.Runner) *Poller {
// workerState holds the single poller's backoff state. Consecutive empty or
// error responses drive exponential backoff; a successful task fetch resets
// both counters so the next poll fires immediately.
type workerState struct {
consecutiveEmpty int64
consecutiveErrors int64
// lastBackoff is the last interval reported to the PollBackoffSeconds gauge;
// used to suppress redundant no-op Set calls when the backoff plateaus
// (e.g. at FetchIntervalMax).
lastBackoff time.Duration
}
func New(cfg *config.Config, client client.Client, runner TaskRunner) *Poller {
pollingCtx, shutdownPolling := context.WithCancel(context.Background())
jobsCtx, shutdownJobs := context.WithCancel(context.Background())
@@ -58,25 +77,57 @@ func New(cfg *config.Config, client client.Client, runner *run.Runner) *Poller {
}
func (p *Poller) Poll() {
limiter := rate.NewLimiter(rate.Every(p.cfg.Runner.FetchInterval), 1)
sem := make(chan struct{}, p.cfg.Runner.Capacity)
wg := &sync.WaitGroup{}
for i := 0; i < p.cfg.Runner.Capacity; i++ {
wg.Add(1)
go p.poll(wg, limiter)
}
wg.Wait()
s := &workerState{}
// signal that we shutdown
defer func() {
wg.Wait()
close(p.done)
}()
for {
select {
case sem <- struct{}{}:
case <-p.pollingCtx.Done():
return
}
task, ok := p.fetchTask(p.pollingCtx, s)
if !ok {
<-sem
if !p.waitBackoff(s) {
return
}
continue
}
s.resetBackoff()
wg.Add(1)
go func(t *runnerv1.Task) {
defer wg.Done()
defer func() { <-sem }()
p.runTaskWithRecover(p.jobsCtx, t)
}(task)
}
}
func (p *Poller) PollOnce() {
limiter := rate.NewLimiter(rate.Every(p.cfg.Runner.FetchInterval), 1)
p.pollOnce(limiter)
// signal that we're done
close(p.done)
defer close(p.done)
s := &workerState{}
for {
task, ok := p.fetchTask(p.pollingCtx, s)
if !ok {
if !p.waitBackoff(s) {
return
}
continue
}
s.resetBackoff()
p.runTaskWithRecover(p.jobsCtx, task)
return
}
}
func (p *Poller) Shutdown(ctx context.Context) error {
@@ -89,13 +140,13 @@ func (p *Poller) Shutdown(ctx context.Context) error {
// 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
// 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
if !ok {
// Both the timeout and the graceful shutdown may fire
// simultaneously. Do a non-blocking check to avoid forcing
// a shutdown when graceful already completed.
select {
case <-p.done:
return nil
default:
}
// force a shutdown of all running jobs
@@ -108,36 +159,60 @@ func (p *Poller) Shutdown(ctx context.Context) error {
}
}
func (p *Poller) poll(wg *sync.WaitGroup, limiter *rate.Limiter) {
defer wg.Done()
for {
p.pollOnce(limiter)
func (s *workerState) resetBackoff() {
s.consecutiveEmpty = 0
s.consecutiveErrors = 0
s.lastBackoff = 0
}
// waitBackoff sleeps for the current backoff interval (with jitter).
// Returns false if the polling context was cancelled during the wait.
func (p *Poller) waitBackoff(s *workerState) bool {
base := p.calculateInterval(s)
if base != s.lastBackoff {
metrics.PollBackoffSeconds.Set(base.Seconds())
s.lastBackoff = base
}
timer := time.NewTimer(addJitter(base))
select {
case <-timer.C:
return true
case <-p.pollingCtx.Done():
return
default:
continue
}
timer.Stop()
return false
}
}
func (p *Poller) pollOnce(limiter *rate.Limiter) {
for {
if err := limiter.Wait(p.pollingCtx); err != nil {
if p.pollingCtx.Err() != nil {
log.WithError(err).Debug("limiter wait failed")
}
return
}
task, ok := p.fetchTask(p.pollingCtx)
if !ok {
continue
// calculateInterval returns the polling interval with exponential backoff based on
// consecutive empty or error responses. The interval starts at FetchInterval and
// doubles with each consecutive empty/error, capped at FetchIntervalMax.
func (p *Poller) calculateInterval(s *workerState) time.Duration {
base := p.cfg.Runner.FetchInterval
maxInterval := p.cfg.Runner.FetchIntervalMax
n := max(s.consecutiveEmpty, s.consecutiveErrors)
if n <= 1 {
return base
}
p.runTaskWithRecover(p.jobsCtx, task)
return
// Capped exponential backoff: base * 2^(n-1), max shift=5 so multiplier <= 32
shift := min(n-1, 5)
interval := base * time.Duration(int64(1)<<shift)
return min(interval, maxInterval)
}
// addJitter adds +/- 20% random jitter to the given duration to avoid thundering herd.
func addJitter(d time.Duration) time.Duration {
if d <= 0 {
return d
}
// jitter range: [-20%, +20%] of d
jitterRange := int64(d) * 2 / 5 // 40% total range
if jitterRange <= 0 {
return d
}
jitter := rand.Int64N(jitterRange) - jitterRange/2
return d + time.Duration(jitter)
}
func (p *Poller) runTaskWithRecover(ctx context.Context, task *runnerv1.Task) {
@@ -153,24 +228,42 @@ func (p *Poller) runTaskWithRecover(ctx context.Context, task *runnerv1.Task) {
}
}
func (p *Poller) fetchTask(ctx context.Context) (*runnerv1.Task, bool) {
func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task, bool) {
reqCtx, cancel := context.WithTimeout(ctx, p.cfg.Runner.FetchTimeout)
defer cancel()
// Load the version value that was in the cache when the request was sent.
v := p.tasksVersion.Load()
start := time.Now()
resp, err := p.client.FetchTask(reqCtx, connect.NewRequest(&runnerv1.FetchTaskRequest{
TasksVersion: v,
}))
// DeadlineExceeded is the designed idle path for a long-poll: the server
// found no work within FetchTimeout. Treat it as an empty response and do
// not record the duration — the timeout value would swamp the histogram.
if errors.Is(err, context.DeadlineExceeded) {
err = nil
s.consecutiveEmpty++
s.consecutiveErrors = 0 // timeout is a healthy idle response
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultEmpty).Inc()
return nil, false
}
metrics.PollFetchDuration.Observe(time.Since(start).Seconds())
if err != nil {
log.WithError(err).Error("failed to fetch task")
s.consecutiveErrors++
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultError).Inc()
metrics.ClientErrors.WithLabelValues(metrics.LabelMethodFetchTask).Inc()
return nil, false
}
// Successful response — reset error counter.
s.consecutiveErrors = 0
if resp == nil || resp.Msg == nil {
s.consecutiveEmpty++
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultEmpty).Inc()
return nil, false
}
@@ -179,11 +272,14 @@ func (p *Poller) fetchTask(ctx context.Context) (*runnerv1.Task, bool) {
}
if resp.Msg.Task == nil {
s.consecutiveEmpty++
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultEmpty).Inc()
return nil, false
}
// got a task, set `tasksVersion` to zero to focre query db in next request.
// got a task, set `tasksVersion` to zero to force query db in next request.
p.tasksVersion.CompareAndSwap(resp.Msg.TasksVersion, 0)
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultTask).Inc()
return resp.Msg.Task, true
}

View File

@@ -0,0 +1,260 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package poll
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"gitea.com/gitea/act_runner/internal/pkg/client/mocks"
"gitea.com/gitea/act_runner/internal/pkg/config"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
connect_go "connectrpc.com/connect"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
// TestPoller_WorkerStateCounters verifies that workerState correctly tracks
// consecutive empty responses independently per state instance, and that
// fetchTask increments only the relevant counter.
func TestPoller_WorkerStateCounters(t *testing.T) {
client := mocks.NewClient(t)
client.On("FetchTask", mock.Anything, mock.Anything).Return(
func(_ context.Context, _ *connect_go.Request[runnerv1.FetchTaskRequest]) (*connect_go.Response[runnerv1.FetchTaskResponse], error) {
// Always return an empty response.
return connect_go.NewResponse(&runnerv1.FetchTaskResponse{}), nil
},
)
cfg, err := config.LoadDefault("")
require.NoError(t, err)
p := &Poller{client: client, cfg: cfg}
ctx := context.Background()
s1 := &workerState{}
s2 := &workerState{}
// Each worker independently observes one empty response.
_, ok := p.fetchTask(ctx, s1)
require.False(t, ok)
_, ok = p.fetchTask(ctx, s2)
require.False(t, ok)
assert.Equal(t, int64(1), s1.consecutiveEmpty, "worker 1 should only count its own empty response")
assert.Equal(t, int64(1), s2.consecutiveEmpty, "worker 2 should only count its own empty response")
// Worker 1 sees a second empty; worker 2 stays at 1.
_, ok = p.fetchTask(ctx, s1)
require.False(t, ok)
assert.Equal(t, int64(2), s1.consecutiveEmpty)
assert.Equal(t, int64(1), s2.consecutiveEmpty, "worker 2's counter must not be affected by worker 1's empty fetches")
}
// TestPoller_FetchErrorIncrementsErrorsOnly verifies that a fetch error
// increments only the per-worker error counter, not the empty counter.
func TestPoller_FetchErrorIncrementsErrorsOnly(t *testing.T) {
client := mocks.NewClient(t)
client.On("FetchTask", mock.Anything, mock.Anything).Return(
func(_ context.Context, _ *connect_go.Request[runnerv1.FetchTaskRequest]) (*connect_go.Response[runnerv1.FetchTaskResponse], error) {
return nil, errors.New("network unreachable")
},
)
cfg, err := config.LoadDefault("")
require.NoError(t, err)
p := &Poller{client: client, cfg: cfg}
s := &workerState{}
_, ok := p.fetchTask(context.Background(), s)
require.False(t, ok)
assert.Equal(t, int64(1), s.consecutiveErrors)
assert.Equal(t, int64(0), s.consecutiveEmpty)
}
// TestPoller_CalculateInterval verifies the exponential backoff math is
// correctly driven by the workerState counters.
func TestPoller_CalculateInterval(t *testing.T) {
cfg, err := config.LoadDefault("")
require.NoError(t, err)
cfg.Runner.FetchInterval = 2 * time.Second
cfg.Runner.FetchIntervalMax = 60 * time.Second
p := &Poller{cfg: cfg}
cases := []struct {
name string
empty, errs int64
wantInterval time.Duration
}{
{"first poll, no backoff", 0, 0, 2 * time.Second},
{"single empty, still base", 1, 0, 2 * time.Second},
{"two empties, doubled", 2, 0, 4 * time.Second},
{"five empties, capped path", 5, 0, 32 * time.Second},
{"many empties, capped at max", 20, 0, 60 * time.Second},
{"errors drive backoff too", 0, 3, 8 * time.Second},
{"max(empty, errors) wins", 2, 4, 16 * time.Second},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s := &workerState{consecutiveEmpty: tc.empty, consecutiveErrors: tc.errs}
assert.Equal(t, tc.wantInterval, p.calculateInterval(s))
})
}
}
// atomicMax atomically updates target to max(target, val).
func atomicMax(target *atomic.Int64, val int64) {
for {
old := target.Load()
if val <= old || target.CompareAndSwap(old, val) {
break
}
}
}
type mockRunner struct {
delay time.Duration
running atomic.Int64
maxConcurrent atomic.Int64
totalCompleted atomic.Int64
}
func (m *mockRunner) Run(ctx context.Context, _ *runnerv1.Task) error {
atomicMax(&m.maxConcurrent, m.running.Add(1))
select {
case <-time.After(m.delay):
case <-ctx.Done():
}
m.running.Add(-1)
m.totalCompleted.Add(1)
return nil
}
// TestPoller_ConcurrencyLimitedByCapacity verifies that with capacity=3 and
// 6 available tasks, at most 3 tasks run concurrently, and FetchTask is
// never called concurrently (single poller).
func TestPoller_ConcurrencyLimitedByCapacity(t *testing.T) {
const (
capacity = 3
totalTasks = 6
taskDelay = 50 * time.Millisecond
)
var (
tasksReturned atomic.Int64
fetchConcur atomic.Int64
maxFetchConcur atomic.Int64
)
cli := mocks.NewClient(t)
cli.On("FetchTask", mock.Anything, mock.Anything).Return(
func(_ context.Context, _ *connect_go.Request[runnerv1.FetchTaskRequest]) (*connect_go.Response[runnerv1.FetchTaskResponse], error) {
atomicMax(&maxFetchConcur, fetchConcur.Add(1))
defer fetchConcur.Add(-1)
n := tasksReturned.Add(1)
if n <= totalTasks {
return connect_go.NewResponse(&runnerv1.FetchTaskResponse{
Task: &runnerv1.Task{Id: n},
}), nil
}
return connect_go.NewResponse(&runnerv1.FetchTaskResponse{}), nil
},
)
runner := &mockRunner{delay: taskDelay}
cfg, err := config.LoadDefault("")
require.NoError(t, err)
cfg.Runner.Capacity = capacity
cfg.Runner.FetchInterval = 10 * time.Millisecond
cfg.Runner.FetchIntervalMax = 10 * time.Millisecond
poller := New(cfg, cli, runner)
var wg sync.WaitGroup
wg.Go(poller.Poll)
require.Eventually(t, func() bool {
return runner.totalCompleted.Load() >= totalTasks
}, 2*time.Second, 10*time.Millisecond, "all tasks should complete")
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
err = poller.Shutdown(ctx)
require.NoError(t, err)
wg.Wait()
assert.LessOrEqual(t, runner.maxConcurrent.Load(), int64(capacity),
"concurrent running tasks must not exceed capacity")
assert.GreaterOrEqual(t, runner.maxConcurrent.Load(), int64(2),
"with 6 tasks and capacity 3, at least 2 should overlap")
assert.Equal(t, int64(1), maxFetchConcur.Load(),
"FetchTask must never be called concurrently (single poller)")
assert.Equal(t, int64(totalTasks), runner.totalCompleted.Load(),
"all tasks should have been executed")
}
// TestPoller_ShutdownForcesJobsOnTimeout locks in the fix for a
// pre-existing bug where Shutdown's timeout branch used a blocking
// `<-p.done` receive, leaving p.shutdownJobs() unreachable. With a
// task parked on jobsCtx and a Shutdown deadline shorter than the
// task's natural completion, Shutdown must force-cancel via
// shutdownJobs() and return ctx.Err() promptly — not block until the
// task would have finished on its own.
func TestPoller_ShutdownForcesJobsOnTimeout(t *testing.T) {
var served atomic.Bool
cli := mocks.NewClient(t)
cli.On("FetchTask", mock.Anything, mock.Anything).Return(
func(_ context.Context, _ *connect_go.Request[runnerv1.FetchTaskRequest]) (*connect_go.Response[runnerv1.FetchTaskResponse], error) {
if served.CompareAndSwap(false, true) {
return connect_go.NewResponse(&runnerv1.FetchTaskResponse{
Task: &runnerv1.Task{Id: 1},
}), nil
}
return connect_go.NewResponse(&runnerv1.FetchTaskResponse{}), nil
},
)
// delay >> Shutdown timeout: Run only returns when jobsCtx is
// cancelled by shutdownJobs().
runner := &mockRunner{delay: 30 * time.Second}
cfg, err := config.LoadDefault("")
require.NoError(t, err)
cfg.Runner.Capacity = 1
cfg.Runner.FetchInterval = 10 * time.Millisecond
cfg.Runner.FetchIntervalMax = 10 * time.Millisecond
poller := New(cfg, cli, runner)
var wg sync.WaitGroup
wg.Go(poller.Poll)
require.Eventually(t, func() bool {
return runner.running.Load() == 1
}, time.Second, 10*time.Millisecond, "task should start running")
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
start := time.Now()
err = poller.Shutdown(ctx)
elapsed := time.Since(start)
require.ErrorIs(t, err, context.DeadlineExceeded)
// With the fix, Shutdown returns shortly after the deadline once
// the forced job unwinds. Without the fix, the blocking <-p.done
// would hang for the full 30s mockRunner delay.
assert.Less(t, elapsed, 5*time.Second,
"Shutdown must not block on the parked task; shutdownJobs() must run on timeout")
wg.Wait()
assert.Equal(t, int64(1), runner.totalCompleted.Load(),
"the parked task must be cancelled and unwound")
}

View File

@@ -5,34 +5,20 @@ package run
import (
"io"
"os"
"gitea.com/gitea/act_runner/internal/pkg/report"
log "github.com/sirupsen/logrus"
)
type JobLoggerFactoryWithInfoLevel struct{}
// NullLogger is used to create a new JobLogger to discard logs. This
// will prevent these logs from being logged to the stdout, but
// forward them to the Reporter via its hook.
type NullLogger struct{}
// WithJobLogger implements [runner.JobLoggerFactory].
func (j *JobLoggerFactoryWithInfoLevel) WithJobLogger() *log.Logger {
jobLogger := log.New()
jobLogger.SetLevel(log.InfoLevel)
return jobLogger
}
// WithJobLogger creates a new logrus.Logger that will discard all logs.
func (n NullLogger) WithJobLogger() *log.Logger {
logger := log.New()
logger.SetOutput(io.Discard)
logger.SetLevel(log.TraceLevel)
type JobLoggerWithReporter struct {
Reporter *report.Reporter
LogToTerminal bool
}
// WithJobLogger implements [runner.JobLoggerFactory].
func (j *JobLoggerWithReporter) WithJobLogger() *log.Logger {
jobLogger := log.New()
if j.LogToTerminal {
jobLogger.SetOutput(os.Stdout)
} else {
jobLogger.SetOutput(io.Discard)
}
jobLogger.AddHook(j.Reporter)
return jobLogger
return logger
}

View File

@@ -8,24 +8,28 @@ import (
"encoding/json"
"fmt"
"maps"
"os"
"path/filepath"
"strings"
"sync"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"connectrpc.com/connect"
"github.com/actions-oss/act-cli/pkg/artifactcache"
"github.com/actions-oss/act-cli/pkg/model"
"github.com/actions-oss/act-cli/pkg/runner"
"github.com/actions-oss/act-cli/pkg/schema"
"github.com/docker/docker/api/types/container"
log "github.com/sirupsen/logrus"
"sync/atomic"
"time"
"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/metrics"
"gitea.com/gitea/act_runner/internal/pkg/report"
"gitea.com/gitea/act_runner/internal/pkg/ver"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"connectrpc.com/connect"
"github.com/docker/docker/api/types/container"
"github.com/nektos/act/pkg/artifactcache"
"github.com/nektos/act/pkg/common"
"github.com/nektos/act/pkg/model"
"github.com/nektos/act/pkg/runner"
log "github.com/sirupsen/logrus"
)
// Runner runs the pipeline.
@@ -39,6 +43,7 @@ type Runner struct {
envs map[string]string
runningTasks sync.Map
runningCount atomic.Int64
}
func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client) *Runner {
@@ -94,16 +99,25 @@ func (r *Runner) Run(ctx context.Context, task *runnerv1.Task) error {
r.runningTasks.Store(task.Id, struct{}{})
defer r.runningTasks.Delete(task.Id)
r.runningCount.Add(1)
start := time.Now()
ctx, cancel := context.WithTimeout(ctx, r.cfg.Runner.Timeout)
defer cancel()
reporter := report.NewReporter(ctx, cancel, r.client, task)
reporter := report.NewReporter(ctx, cancel, r.client, task, r.cfg)
var runErr error
defer func() {
r.runningCount.Add(-1)
lastWords := ""
if runErr != nil {
lastWords = runErr.Error()
}
_ = reporter.Close(lastWords)
metrics.JobDuration.Observe(time.Since(start).Seconds())
metrics.JobsTotal.WithLabelValues(metrics.ResultToStatusLabel(reporter.Result())).Inc()
}()
reporter.RunDaemon()
runErr = r.run(ctx, task, reporter)
@@ -136,20 +150,12 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
return err
}
// TODO GITEA
plan, err := model.CombineWorkflowPlanner(workflow).PlanJob(jobID)
if err != nil {
return err
}
job := workflow.GetJob(jobID)
var stepIds []string
for i, v := range job.Steps {
if v.ID == "" {
v.ID = fmt.Sprint(i)
}
stepIds = append(stepIds, v.ID)
}
reporter.SetStepIdMapping(stepIds...)
reporter.ResetSteps(len(job.Steps))
taskContext := task.Context.Fields
@@ -161,6 +167,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
Event: taskContext["event"].GetStructValue().AsMap(),
RunID: taskContext["run_id"].GetStringValue(),
RunNumber: taskContext["run_number"].GetStringValue(),
RunAttempt: taskContext["run_attempt"].GetStringValue(),
Actor: taskContext["actor"].GetStringValue(),
Repository: taskContext["repository"].GetStringValue(),
EventName: taskContext["event_name"].GetStringValue(),
@@ -193,27 +200,28 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
}
r.envs["ACTIONS_RUNTIME_TOKEN"] = giteaRuntimeToken
// TODO GITEA
eventJSON, err := json.Marshal(preset.Event)
if err != nil {
return err
}
// maxLifetime := 3 * time.Hour
// if deadline, ok := ctx.Deadline(); ok {
// maxLifetime = time.Until(deadline)
// }
maxLifetime := 3 * time.Hour
if deadline, ok := ctx.Deadline(); ok {
maxLifetime = time.Until(deadline)
}
actCtx := map[string]interface{}{}
forgeCtx := task.Context.AsMap()
actCtx["github"] = forgeCtx
actCtx["gitea"] = forgeCtx
workdirParent := strings.TrimLeft(r.cfg.Container.WorkdirParent, "/")
if r.cfg.Container.BindWorkdir {
// Append the task ID to isolate concurrent jobs from the same repo.
workdirParent = fmt.Sprintf("%s/%d", workdirParent, task.Id)
}
workdir := filepath.FromSlash(fmt.Sprintf("/%s/%s", workdirParent, preset.Repository))
runnerConfig := &runner.Config{
// On Linux, Workdir will be like "/<parent_directory>/<owner>/<repo>"
// On Windows, Workdir will be like "\<parent_directory>\<owner>\<repo>"
Workdir: filepath.FromSlash(fmt.Sprintf("/%s/%s", strings.TrimLeft(r.cfg.Container.WorkdirParent, "/"), preset.Repository)),
BindWorkdir: false,
Workdir: workdir,
BindWorkdir: r.cfg.Container.BindWorkdir,
ActionCacheDir: filepath.FromSlash(r.cfg.Host.WorkdirParent),
ReuseContainers: false,
@@ -223,40 +231,22 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
JSONLogger: false,
Env: r.envs,
Secrets: task.Secrets,
// GitHubInstance: strings.TrimSuffix(r.client.Address(), "/"),
GitHubInstance: strings.TrimSuffix(r.client.Address(), "/"),
AutoRemove: true,
NoSkipCheckout: true,
// TODO GITEA
// PresetGitHubContext: preset,
PresetGitHubContext: preset,
EventJSON: string(eventJSON),
// ContainerNamePrefix: fmt.Sprintf("GITEA-ACTIONS-TASK-%d", task.Id),
// ContainerMaxLifetime: maxLifetime,
ContainerNamePrefix: fmt.Sprintf("GITEA-ACTIONS-TASK-%d", task.Id),
ContainerMaxLifetime: maxLifetime,
ContainerNetworkMode: container.NetworkMode(r.cfg.Container.Network),
ContainerOptions: r.cfg.Container.Options,
ContainerDaemonSocket: r.cfg.Container.DockerHost,
Privileged: r.cfg.Container.Privileged,
Platforms: map[string]string{
"dummy": "-self-hosted",
},
// TODO GITEA
// DefaultActionInstance: r.getDefaultActionsURL(task),
// PlatformPicker: r.labels.PickPlatform,
DefaultActionInstance: r.getDefaultActionsURL(task),
PlatformPicker: r.labels.PickPlatform,
Vars: task.Vars,
// TODO GITEA
// ValidVolumes: r.cfg.Container.ValidVolumes,
// InsecureSkipTLS: r.cfg.Runner.Insecure,
GitHubServerURL: strings.TrimSuffix(r.client.Address(), "/"),
GitHubAPIServerURL: strings.TrimSuffix(r.client.Address(), "/") + "/api/v1",
// Invalid but ok
GitHubGraphQlAPIServerURL: strings.TrimSuffix(r.client.Address(), "/api/graphql"),
MainContextNames: []string{"gitea", "github"},
Action: model.ActionConfig{
Schema: schema.GetGiteaActionSchema(),
},
ContextData: actCtx,
ValidVolumes: r.cfg.Container.ValidVolumes,
InsecureSkipTLS: r.cfg.Runner.Insecure,
}
rr, err := runner.New(runnerConfig)
@@ -267,14 +257,31 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
reporter.Logf("workflow prepared")
// TODO GITEA
ctx = runner.WithJobLoggerFactory(ctx, &JobLoggerWithReporter{Reporter: reporter, LogToTerminal: log.IsLevelEnabled(log.DebugLevel)})
// add logger recorders
ctx = common.WithLoggerHook(ctx, reporter)
if !log.IsLevelEnabled(log.DebugLevel) {
ctx = runner.WithJobLoggerFactory(ctx, NullLogger{})
}
execErr := executor(ctx)
reporter.SetOutputs(job.Outputs)
if r.cfg.Container.BindWorkdir {
// Remove the entire task-specific directory (e.g. /workspace/<task_id>).
taskDir := filepath.FromSlash("/" + workdirParent)
if err := os.RemoveAll(taskDir); err != nil {
log.Warnf("failed to clean up workspace %s: %v", taskDir, err)
}
}
return execErr
}
func (r *Runner) RunningCount() int64 {
return r.runningCount.Load()
}
func (r *Runner) Declare(ctx context.Context, labels []string) (*connect.Response[runnerv1.DeclareResponse], error) {
return r.client.Declare(ctx, connect.NewRequest(&runnerv1.DeclareRequest{
Version: ver.Version(),

View File

@@ -10,24 +10,12 @@ import (
"strings"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"github.com/actions-oss/act-cli/pkg/model"
"github.com/actions-oss/act-cli/pkg/schema"
"gopkg.in/yaml.v3"
"github.com/nektos/act/pkg/model"
"go.yaml.in/yaml/v4"
)
func generateWorkflow(task *runnerv1.Task) (*model.Workflow, string, error) {
workflow, err := model.ReadWorkflow(bytes.NewReader(task.WorkflowPayload), model.WorkflowConfig{
// Schema: schema.GetGiteaWorkflowSchema(),
// Allow everything
Schema: &schema.Schema{
Definitions: map[string]schema.Definition{
"workflow-root": {
Context: []string{"github", "gitea", "env", "job", "matrix", "strategy", "inputs", "vars", "runner", "steps", "needs"},
OneOf: &[]string{"any"},
},
},
},
})
workflow, err := model.ReadWorkflow(bytes.NewReader(task.WorkflowPayload))
if err != nil {
return nil, "", err
}
@@ -60,9 +48,7 @@ func generateWorkflow(task *runnerv1.Task) (*model.Workflow, string, error) {
})
}
// TODO GITEA
workflow.Jobs[jobID].RawNeeds = rawNeeds
workflow.Jobs[jobID].RawRunsOn.Encode("dummy")
return workflow, jobID, nil
}

View File

@@ -7,7 +7,7 @@ import (
"testing"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"github.com/actions-oss/act-cli/pkg/model"
"github.com/nektos/act/pkg/model"
"github.com/stretchr/testify/require"
"go.yaml.in/yaml/v4"
"gotest.tools/v3/assert"

View File

@@ -1,122 +0,0 @@
package functions
import (
"fmt"
"strconv"
"strings"
)
// Format evaluates a format string with the supplied arguments.
// It behaves like the C# implementation in the repository
// it supports escaped braces and numeric argument indices.
// Format specifiers (e.g. :D) are recognised but currently ignored.
func Format(formatStr string, args ...interface{}) (string, error) {
var sb strings.Builder
i := 0
for i < len(formatStr) {
lbrace := strings.IndexByte(formatStr[i:], '{')
rbrace := strings.IndexByte(formatStr[i:], '}')
// left brace
if lbrace >= 0 && (rbrace < 0 || rbrace > lbrace) {
l := i + lbrace
sb.WriteString(formatStr[i:l])
// escaped left brace
if l+1 < len(formatStr) && formatStr[l+1] == '{' {
sb.WriteString(formatStr[l : l+1])
i = l + 2
continue
}
// normal placeholder
if rbrace > lbrace+1 {
// read index
idx, endIdx, ok := readArgIndex(formatStr, l+1)
if !ok {
return "", fmt.Errorf("invalid format string: %s", formatStr)
}
// read optional format specifier
spec, r, ok := readFormatSpecifiers(formatStr, endIdx+1)
if !ok {
return "", fmt.Errorf("invalid format string: %s", formatStr)
}
if idx >= len(args) {
return "", fmt.Errorf("argument index %d out of range", idx)
}
// append argument (format specifier is ignored here)
arg := args[idx]
sb.WriteString(fmt.Sprintf("%v", arg))
if spec != "" {
// placeholder for future specifier handling
_ = spec
}
i = r + 1
continue
}
return "", fmt.Errorf("invalid format string: %s", formatStr)
}
// right brace
if rbrace >= 0 {
// escaped right brace
if i+rbrace+1 < len(formatStr) && formatStr[i+rbrace+1] == '}' {
sb.WriteString(formatStr[i : i+rbrace+1])
i += rbrace + 2
continue
}
return "", fmt.Errorf("invalid format string: %s", formatStr)
}
// rest of string
sb.WriteString(formatStr[i:])
break
}
return sb.String(), nil
}
// readArgIndex parses a decimal number starting at pos.
// It returns the parsed value, the index of the last digit and true on success.
func readArgIndex(s string, pos int) (int, int, bool) {
start := pos
for pos < len(s) && s[pos] >= '0' && s[pos] <= '9' {
pos++
}
if start == pos {
return 0, 0, false
}
idx, err := strconv.Atoi(s[start:pos])
if err != nil {
return 0, 0, false
}
return idx, pos - 1, true
}
// readFormatSpecifiers reads an optional format specifier block.
// It returns the specifier string, the index of the closing '}' and true on success.
func readFormatSpecifiers(s string, pos int) (string, int, bool) {
if pos >= len(s) {
return "", 0, false
}
if s[pos] == '}' {
return "", pos, true
}
if s[pos] != ':' {
return "", 0, false
}
pos++ // skip ':'
start := pos
for pos < len(s) {
if s[pos] == '}' {
return s[start:pos], pos, true
}
if s[pos] == '}' && pos+1 < len(s) && s[pos+1] == '}' {
// escaped '}'
pos += 2
continue
}
pos++
}
return "", 0, false
}

View File

@@ -1,14 +0,0 @@
package functions
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
func TestFormat(t *testing.T) {
s, err := Format("Hello {0}, you have {1} new messages", "Alice", 5)
assert.NoError(t, err)
fmt.Println(s) // Hello Alice, you have 5 new messages
}

View File

@@ -1,474 +0,0 @@
package v2
import (
"fmt"
"math"
"strconv"
"strings"
)
// ValueKind represents the type of a value in the evaluation engine.
// The values mirror the C# ValueKind enum.
//
// Note: The names are kept identical to the C# implementation for easier mapping.
//
// The lexer is intentionally simple it only tokenises the subset of
// expressions that are used in GitHub Actions workflow `if:` expressions.
// It does not evaluate the expression that is left to the parser.
type ValueKind int
const (
ValueKindNull ValueKind = iota
ValueKindBoolean
ValueKindNumber
ValueKindString
ValueKindObject
ValueKindArray
)
type ReadOnlyArray[T any] interface {
GetAt(i int64) T
GetEnumerator() []T
}
type ReadOnlyObject[T any] interface {
Get(key string) T
GetKv(key string) (string, T) // Returns the actual key used (for case-insensitive objects)
GetEnumerator() map[string]T
}
type BasicArray[T any] []T
func (a BasicArray[T]) GetAt(i int64) T {
if int(i) >= len(a) {
var zero T
return zero
}
return a[i]
}
func (a BasicArray[T]) GetEnumerator() []T {
return a
}
type CaseInsensitiveObject[T any] map[string]T
func (o CaseInsensitiveObject[T]) Get(key string) T {
_, v := o.GetKv(key)
return v
}
func (o CaseInsensitiveObject[T]) GetKv(key string) (k string, v T) {
for k, v := range o {
if strings.EqualFold(k, key) {
return k, v
}
}
var zero T
return key, zero
}
func (o CaseInsensitiveObject[T]) GetEnumerator() map[string]T {
return o
}
type CaseSensitiveObject[T any] map[string]T
func (o CaseSensitiveObject[T]) Get(key string) T {
return o[key]
}
func (o CaseSensitiveObject[T]) GetKv(key string) (string, T) {
return key, o[key]
}
func (o CaseSensitiveObject[T]) GetEnumerator() map[string]T {
return o
}
// EvaluationResult holds the result of evaluating an expression node.
// It mirrors the C# EvaluationResult class.
type EvaluationResult struct {
context *EvaluationContext
level int
value interface{}
kind ValueKind
raw interface{}
omitTracing bool
}
// NewEvaluationResult creates a new EvaluationResult.
func NewEvaluationResult(context *EvaluationContext, level int, val interface{}, kind ValueKind, raw interface{}, omitTracing bool) *EvaluationResult {
er := &EvaluationResult{context: context, level: level, value: val, kind: kind, raw: raw, omitTracing: omitTracing}
if !omitTracing {
er.traceValue()
}
return er
}
// Kind returns the ValueKind of the result.
func (er *EvaluationResult) Kind() ValueKind { return er.kind }
// Raw returns the raw value that was passed to the constructor.
func (er *EvaluationResult) Raw() interface{} { return er.raw }
// Value returns the canonical value.
func (er *EvaluationResult) Value() interface{} { return er.value }
// IsFalsy implements the logic from the C# class.
func (er *EvaluationResult) IsFalsy() bool {
switch er.kind {
case ValueKindNull:
return true
case ValueKindBoolean:
return !er.value.(bool)
case ValueKindNumber:
v := er.value.(float64)
return v == 0 || isNaN(v)
case ValueKindString:
return er.value.(string) == ""
default:
return false
}
}
func isNaN(v float64) bool { return v != v }
// IsPrimitive returns true if the kind is a primitive type.
func (er *EvaluationResult) IsPrimitive() bool { return er.kind <= ValueKindString }
// IsTruthy is the negation of IsFalsy.
func (er *EvaluationResult) IsTruthy() bool { return !er.IsFalsy() }
// AbstractEqual compares two EvaluationResults using the abstract equality algorithm.
func (er *EvaluationResult) AbstractEqual(other *EvaluationResult) bool {
return abstractEqual(er.value, other.value)
}
// AbstractGreaterThan compares two EvaluationResults.
func (er *EvaluationResult) AbstractGreaterThan(other *EvaluationResult) bool {
return abstractGreaterThan(er.value, other.value)
}
// AbstractGreaterThanOrEqual
func (er *EvaluationResult) AbstractGreaterThanOrEqual(other *EvaluationResult) bool {
return er.AbstractEqual(other) || er.AbstractGreaterThan(other)
}
// AbstractLessThan
func (er *EvaluationResult) AbstractLessThan(other *EvaluationResult) bool {
return abstractLessThan(er.value, other.value)
}
// AbstractLessThanOrEqual
func (er *EvaluationResult) AbstractLessThanOrEqual(other *EvaluationResult) bool {
return er.AbstractEqual(other) || er.AbstractLessThan(other)
}
// AbstractNotEqual
func (er *EvaluationResult) AbstractNotEqual(other *EvaluationResult) bool {
return !er.AbstractEqual(other)
}
// ConvertToNumber converts the value to a float64.
func (er *EvaluationResult) ConvertToNumber() float64 { return convertToNumber(er.value) }
// ConvertToString converts the value to a string.
func (er *EvaluationResult) ConvertToString() string {
switch er.kind {
case ValueKindNull:
return ""
case ValueKindBoolean:
if er.value.(bool) {
return ExpressionConstants.True
}
return ExpressionConstants.False
case ValueKindNumber:
return fmt.Sprintf(ExpressionConstants.NumberFormat, er.value.(float64))
case ValueKindString:
return er.value.(string)
default:
return fmt.Sprintf("%v", er.value)
}
}
// TryGetCollectionInterface returns the underlying collection if the value is an array or object.
func (er *EvaluationResult) TryGetCollectionInterface() (interface{}, bool) {
switch v := er.value.(type) {
case ReadOnlyArray[any]:
return v, true
case ReadOnlyObject[any]:
return v, true
default:
return nil, false
}
}
// CreateIntermediateResult creates an EvaluationResult from an arbitrary object.
func CreateIntermediateResult(context *EvaluationContext, obj interface{}) *EvaluationResult {
val, kind, raw := convertToCanonicalValue(obj)
return NewEvaluationResult(context, 0, val, kind, raw, true)
}
// --- Helper functions and constants ---------------------------------------
// ExpressionConstants holds string constants used in conversions.
var ExpressionConstants = struct {
True string
False string
NumberFormat string
}{
True: "true",
False: "false",
NumberFormat: "%.15g",
}
// convertToCanonicalValue converts an arbitrary Go value to a canonical form.
func convertToCanonicalValue(obj interface{}) (interface{}, ValueKind, interface{}) {
switch v := obj.(type) {
case nil:
return nil, ValueKindNull, nil
case bool:
return v, ValueKindBoolean, v
case int, int8, int16, int32, int64:
f := float64(toInt64(v))
return f, ValueKindNumber, f
case uint, uint8, uint16, uint32, uint64:
f := float64(toUint64(v))
return f, ValueKindNumber, f
case float32, float64:
f := toFloat64(v)
return f, ValueKindNumber, f
case string:
return v, ValueKindString, v
case []interface{}:
return BasicArray[any](v), ValueKindArray, v
case ReadOnlyArray[any]:
return v, ValueKindArray, v
case map[string]interface{}:
return CaseInsensitiveObject[any](v), ValueKindObject, v
case ReadOnlyObject[any]:
return v, ValueKindObject, v
default:
// Fallback: treat as object
return v, ValueKindObject, v
}
}
func toInt64(v interface{}) int64 {
switch i := v.(type) {
case int:
return int64(i)
case int8:
return int64(i)
case int16:
return int64(i)
case int32:
return int64(i)
case int64:
return i
default:
return 0
}
}
func toUint64(v interface{}) uint64 {
switch i := v.(type) {
case uint:
return uint64(i)
case uint8:
return uint64(i)
case uint16:
return uint64(i)
case uint32:
return uint64(i)
case uint64:
return i
default:
return 0
}
}
func toFloat64(v interface{}) float64 {
switch f := v.(type) {
case float32:
return float64(f)
case float64:
return f
default:
return 0
}
}
// coerceTypes implements the C# CoerceTypes logic.
// It converts values to compatible types before comparison.
func coerceTypes(left, right interface{}) (interface{}, interface{}, ValueKind, ValueKind) {
leftKind := getKind(left)
rightKind := getKind(right)
// same kind nothing to do
if leftKind == rightKind {
return left, right, leftKind, rightKind
}
// Number <-> String
if leftKind == ValueKindNumber && rightKind == ValueKindString {
right = convertToNumber(right)
rightKind = ValueKindNumber
return left, right, leftKind, rightKind
}
if leftKind == ValueKindString && rightKind == ValueKindNumber {
left = convertToNumber(left)
leftKind = ValueKindNumber
return left, right, leftKind, rightKind
}
// Boolean or Null -> Number
if leftKind == ValueKindBoolean || leftKind == ValueKindNull {
left = convertToNumber(left)
return coerceTypes(left, right)
}
if rightKind == ValueKindBoolean || rightKind == ValueKindNull {
right = convertToNumber(right)
return coerceTypes(left, right)
}
// otherwise keep as is
return left, right, leftKind, rightKind
}
// abstractEqual uses coerceTypes before comparing.
func abstractEqual(left, right interface{}) bool {
left, right, leftKind, rightKind := coerceTypes(left, right)
if leftKind != rightKind {
return false
}
switch leftKind {
case ValueKindNull:
return true
case ValueKindNumber:
l := left.(float64)
r := right.(float64)
if isNaN(l) || isNaN(r) {
return false
}
return l == r
case ValueKindString:
return strings.EqualFold(left.(string), right.(string))
case ValueKindBoolean:
return left.(bool) == right.(bool)
// Compare object equality fails via panic
// case ValueKindObject, ValueKindArray:
// return left == right
}
return false
}
// abstractGreaterThan uses coerceTypes before comparing.
func abstractGreaterThan(left, right interface{}) bool {
left, right, leftKind, rightKind := coerceTypes(left, right)
if leftKind != rightKind {
return false
}
switch leftKind {
case ValueKindNumber:
l := left.(float64)
r := right.(float64)
if isNaN(l) || isNaN(r) {
return false
}
return l > r
case ValueKindString:
return strings.Compare(left.(string), right.(string)) > 0
case ValueKindBoolean:
return left.(bool) && !right.(bool)
}
return false
}
// abstractLessThan uses coerceTypes before comparing.
func abstractLessThan(left, right interface{}) bool {
left, right, leftKind, rightKind := coerceTypes(left, right)
if leftKind != rightKind {
return false
}
switch leftKind {
case ValueKindNumber:
l := left.(float64)
r := right.(float64)
if isNaN(l) || isNaN(r) {
return false
}
return l < r
case ValueKindString:
return strings.Compare(left.(string), right.(string)) < 0
case ValueKindBoolean:
return !left.(bool) && right.(bool)
}
return false
}
// convertToNumber converts a value to a float64 following JavaScript rules.
func convertToNumber(v interface{}) float64 {
switch val := v.(type) {
case nil:
return 0
case bool:
if val {
return 1
}
return 0
case float64:
return val
case float32:
return float64(val)
case string:
// parsenumber
if val == "" {
return float64(0)
}
if len(val) > 2 {
switch val[:2] {
case "0x", "0o":
if i, err := strconv.ParseInt(val, 0, 32); err == nil {
return float64(i)
}
}
}
if f, err := strconv.ParseFloat(val, 64); err == nil {
return f
}
return math.NaN()
default:
return math.NaN()
}
}
// getKind returns the ValueKind for a Go value.
func getKind(v interface{}) ValueKind {
switch v.(type) {
case nil:
return ValueKindNull
case bool:
return ValueKindBoolean
case float64, float32, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
return ValueKindNumber
case string:
return ValueKindString
case []interface{}:
return ValueKindArray
case map[string]interface{}:
return ValueKindObject
default:
return ValueKindObject
}
}
// traceValue is a placeholder for tracing logic.
func (er *EvaluationResult) traceValue() {
// No-op in this simplified implementation.
}
// --- End of file ---------------------------------------

View File

@@ -1,276 +0,0 @@
package v2
import (
"errors"
"fmt"
exprparser "github.com/actions-oss/act-cli/internal/expr"
)
// EvaluationContext holds variables that can be referenced in expressions.
type EvaluationContext struct {
Variables ReadOnlyObject[any]
Functions ReadOnlyObject[Function]
}
func NewEvaluationContext() *EvaluationContext {
return &EvaluationContext{}
}
type Function interface {
Evaluate(eval *Evaluator, args []exprparser.Node) (*EvaluationResult, error)
}
// Evaluator evaluates workflow expressions using the lexer and parser from workflow.
type Evaluator struct {
ctx *EvaluationContext
}
// NewEvaluator creates an Evaluator with the supplied context.
func NewEvaluator(ctx *EvaluationContext) *Evaluator {
return &Evaluator{ctx: ctx}
}
func (e *Evaluator) Context() *EvaluationContext {
return e.ctx
}
func (e *Evaluator) Evaluate(root exprparser.Node) (*EvaluationResult, error) {
result, err := e.evalNode(root)
if err != nil {
return nil, err
}
return result, nil
}
// EvaluateBoolean parses and evaluates the expression, returning a boolean result.
func (e *Evaluator) EvaluateBoolean(expr string) (bool, error) {
root, err := exprparser.Parse(expr)
if err != nil {
return false, fmt.Errorf("parse error: %w", err)
}
result, err := e.evalNode(root)
if err != nil {
return false, err
}
return result.IsTruthy(), nil
}
func (e *Evaluator) ToRaw(result *EvaluationResult) (interface{}, error) {
if col, ok := result.TryGetCollectionInterface(); ok {
switch node := col.(type) {
case ReadOnlyObject[any]:
rawMap := map[string]interface{}{}
for k, v := range node.GetEnumerator() {
rawRes, err := e.ToRaw(CreateIntermediateResult(e.Context(), v))
if err != nil {
return nil, err
}
rawMap[k] = rawRes
}
return rawMap, nil
case ReadOnlyArray[any]:
rawArray := []interface{}{}
for _, v := range node.GetEnumerator() {
rawRes, err := e.ToRaw(CreateIntermediateResult(e.Context(), v))
if err != nil {
return nil, err
}
rawArray = append(rawArray, rawRes)
}
return rawArray, nil
}
}
return result.Value(), nil
}
// Evaluate parses and evaluates the expression, returning a boolean result.
func (e *Evaluator) EvaluateRaw(expr string) (interface{}, error) {
root, err := exprparser.Parse(expr)
if err != nil {
return false, fmt.Errorf("parse error: %w", err)
}
result, err := e.evalNode(root)
if err != nil {
return false, err
}
return e.ToRaw(result)
}
type FilteredArray []interface{}
func (a FilteredArray) GetAt(i int64) interface{} {
if int(i) > len(a) {
return nil
}
return a[i]
}
func (a FilteredArray) GetEnumerator() []interface{} {
return a
}
// evalNode recursively evaluates a parser node and returns an EvaluationResult.
func (e *Evaluator) evalNode(n exprparser.Node) (*EvaluationResult, error) {
switch node := n.(type) {
case *exprparser.ValueNode:
return e.evalValueNode(node)
case *exprparser.FunctionNode:
return e.evalFunctionNode(node)
case *exprparser.BinaryNode:
return e.evalBinaryNode(node)
case *exprparser.UnaryNode:
return e.evalUnaryNode(node)
}
return nil, errors.New("unknown node type")
}
func (e *Evaluator) evalValueNode(node *exprparser.ValueNode) (*EvaluationResult, error) {
if node.Kind == exprparser.TokenKindNamedValue {
if e.ctx != nil {
val := e.ctx.Variables.Get(node.Value.(string))
if val == nil {
return nil, fmt.Errorf("undefined variable %s", node.Value)
}
return CreateIntermediateResult(e.Context(), val), nil
}
return nil, errors.New("no evaluation context")
}
return CreateIntermediateResult(e.Context(), node.Value), nil
}
func (e *Evaluator) evalFunctionNode(node *exprparser.FunctionNode) (*EvaluationResult, error) {
fn := e.ctx.Functions.Get(node.Name)
if fn == nil {
return nil, fmt.Errorf("unknown function %v", node.Name)
}
return fn.Evaluate(e, node.Args)
}
func (e *Evaluator) evalBinaryNode(node *exprparser.BinaryNode) (*EvaluationResult, error) {
left, err := e.evalNode(node.Left)
if err != nil {
return nil, err
}
if res, err := e.evalBinaryNodeLeft(node, left); res != nil || err != nil {
return res, err
}
right, err := e.evalNode(node.Right)
if err != nil {
return nil, err
}
return e.evalBinaryNodeRight(node, left, right)
}
func (e *Evaluator) evalBinaryNodeLeft(node *exprparser.BinaryNode, left *EvaluationResult) (*EvaluationResult, error) {
switch node.Op {
case "&&":
if left.IsFalsy() {
return left, nil
}
case "||":
if left.IsTruthy() {
return left, nil
}
case ".":
if v, ok := node.Right.(*exprparser.ValueNode); ok && v.Kind == exprparser.TokenKindWildcard {
var ret FilteredArray
if col, ok := left.TryGetCollectionInterface(); ok {
if farray, ok := col.(FilteredArray); ok {
for _, subcol := range farray.GetEnumerator() {
ret = processStar(CreateIntermediateResult(e.Context(), subcol).Value(), ret)
}
} else {
ret = processStar(col, ret)
}
}
return CreateIntermediateResult(e.Context(), ret), nil
}
}
return nil, nil
}
func (e *Evaluator) evalBinaryNodeRight(node *exprparser.BinaryNode, left *EvaluationResult, right *EvaluationResult) (*EvaluationResult, error) {
switch node.Op {
case "&&":
return right, nil
case "||":
return right, nil
case "==":
// Use abstract equality per spec
return CreateIntermediateResult(e.Context(), left.AbstractEqual(right)), nil
case "!=":
return CreateIntermediateResult(e.Context(), left.AbstractNotEqual(right)), nil
case ">":
return CreateIntermediateResult(e.Context(), left.AbstractGreaterThan(right)), nil
case "<":
return CreateIntermediateResult(e.Context(), left.AbstractLessThan(right)), nil
case ">=":
return CreateIntermediateResult(e.Context(), left.AbstractGreaterThanOrEqual(right)), nil
case "<=":
return CreateIntermediateResult(e.Context(), left.AbstractLessThanOrEqual(right)), nil
case ".", "[":
if farray, ok := left.Value().(FilteredArray); ok {
var ret FilteredArray
for _, subcol := range farray.GetEnumerator() {
res := processIndex(CreateIntermediateResult(e.Context(), subcol).Value(), right)
if res != nil {
ret = append(ret, res)
}
}
if ret == nil {
return CreateIntermediateResult(e.Context(), nil), nil
}
return CreateIntermediateResult(e.Context(), ret), nil
}
col, _ := left.TryGetCollectionInterface()
result := processIndex(col, right)
return CreateIntermediateResult(e.Context(), result), nil
default:
return nil, fmt.Errorf("unsupported operator %s", node.Op)
}
}
func (e *Evaluator) evalUnaryNode(node *exprparser.UnaryNode) (*EvaluationResult, error) {
operand, err := e.evalNode(node.Operand)
if err != nil {
return nil, err
}
switch node.Op {
case "!":
return CreateIntermediateResult(e.Context(), !operand.IsTruthy()), nil
default:
return nil, fmt.Errorf("unsupported unary operator %s", node.Op)
}
}
func processIndex(col interface{}, right *EvaluationResult) interface{} {
if mapVal, ok := col.(ReadOnlyObject[any]); ok {
key, ok := right.Value().(string)
if !ok {
return nil
}
val := mapVal.Get(key)
return val
}
if arrayVal, ok := col.(ReadOnlyArray[any]); ok {
key, ok := right.Value().(float64)
if !ok || key < 0 {
return nil
}
val := arrayVal.GetAt(int64(key))
return val
}
return nil
}
func processStar(subcol interface{}, ret FilteredArray) FilteredArray {
if array, ok := subcol.(ReadOnlyArray[any]); ok {
ret = append(ret, array.GetEnumerator()...)
} else if obj, ok := subcol.(ReadOnlyObject[any]); ok {
for _, v := range obj.GetEnumerator() {
ret = append(ret, v)
}
}
return ret
}

View File

@@ -1,117 +0,0 @@
package v2
import (
"testing"
)
// Test boolean and comparison operations using the evaluator.
func TestEvaluator_BooleanOps(t *testing.T) {
ctx := &EvaluationContext{Variables: CaseInsensitiveObject[any](map[string]interface{}{"a": 5, "b": 3})}
eval := NewEvaluator(ctx)
tests := []struct {
expr string
want bool
}{
{"1 == 1", true},
{"1 != 2", true},
{"5 > 3", true},
{"2 < 4", true},
{"5 >= 5", true},
{"3 <= 4", true},
{"true && false", false},
{"!false", true},
{"a > b", true},
}
for _, tt := range tests {
got, err := eval.EvaluateBoolean(tt.expr)
if err != nil {
t.Fatalf("evaluate %s error: %v", tt.expr, err)
}
if got != tt.want {
t.Fatalf("evaluate %s expected %v got %v", tt.expr, tt.want, got)
}
}
}
func TestEvaluator_Raw(t *testing.T) {
ctx := &EvaluationContext{
Variables: CaseInsensitiveObject[any](map[string]any{"a": 5, "b": 3}),
Functions: GetFunctions(),
}
eval := NewEvaluator(ctx)
tests := []struct {
expr string
want interface{}
}{
{"a.b['x']", nil},
{"(a.b).c['x']", nil},
{"(a.b).*['x']", nil},
{"(a['x'])", nil},
{"true || false", true},
{"false || false", false},
{"false || true", true},
{"false || true || false", true},
{"contains('', '') || contains('', '') || contains('', '')", true},
{"1 == 1", true},
{"1 != 2", true},
{"5 > 3", true},
{"2 < 4", true},
{"5 >= 5", true},
{"3 <= 4", true},
{"true && false", false},
{"!false", true},
{"a > b", true},
{"!(a > b)", false},
{"!(a > b) || !0", true},
{"!(a > b) || !(1)", false},
{"'Hello World'", "Hello World"},
{"23.5", 23.5},
{"fromjson('{\"twst\":\"x\"}')['twst']", "x"},
{"fromjson('{\"Twst\":\"x\"}')['twst']", "x"},
{"fromjson('{\"TwsT\":\"x\"}')['twst']", "x"},
{"fromjson('{\"TwsT\":\"x\"}')['tWst']", "x"},
{"fromjson('{\"TwsT\":{\"a\":\"y\"}}').TwsT.a", "y"},
{"fromjson('{\"TwsT\":{\"a\":\"y\"}}')['TwsT'].a", "y"},
{"fromjson('{\"TwsT\":{\"a\":\"y\"}}')['TwsT']['a']", "y"},
{"fromjson('{\"TwsT\":{\"a\":\"y\"}}').TwsT['a']", "y"},
// {"fromjson('{\"TwsT\":\"x\"}').*[0]", "x"},
{"fromjson('{\"TwsT\":[\"x\"]}')['TwsT'][0]", "x"},
{"fromjson('[]')['tWst']", nil},
{"fromjson('[]').tWst", nil},
{"contains('a', 'a')", true},
{"contains('bab', 'a')", true},
{"contains('bab', 'ac')", false},
{"contains(fromjson('[\"ac\"]'), 'ac')", true},
{"contains(fromjson('[\"ac\"]'), 'a')", false},
// {"fromjson('{\"TwsT\":{\"a\":\"y\"}}').*['a']", "y"},
{"fromjson(tojson(fromjson('{\"TwsT\":{\"a\":\"y\"}}').*.a))[0]", "y"},
{"fromjson(tojson(fromjson('{\"TwsT\":{\"a\":\"y\"}}').*['a']))[0]", "y"},
{"fromjson('{}').x", nil},
{"format('{0}', fromjson('{}').x)", ""},
{"format('{0}', fromjson('{}')[0])", ""},
{"fromjson(tojson(fromjson('[[3,5],[5,6]]').*[1]))[1]", float64(6)},
{"contains(fromjson('[[3,5],[5,6]]').*[1], 5)", true},
{"contains(fromjson('[[3,5],[5,6]]').*[1], 6)", true},
{"contains(fromjson('[[3,5],[5,6]]').*[1], 3)", false},
{"contains(fromjson('[[3,5],[5,6]]').*[1], '6')", true},
{"case(6 == 6, 0, 1)", 0.0},
{"case(6 != 6, 0, 1)", 1.0},
{"case(6 != 6, 0, 'test')", "test"},
{"case(contains(fromjson('[\"ac\"]'), 'a'), 0, 'test')", "test"},
{"case(0 == 1, 0, 2 == 2, 1, 0)", 1.0},
{"case(0 == 1, 0, 2 != 2, 1, 0)", 0.0},
}
for _, tt := range tests {
got, err := eval.EvaluateRaw(tt.expr)
if err != nil {
t.Fatalf("evaluate %s error: %v", tt.expr, err)
}
if got != tt.want {
t.Fatalf("evaluate %s expected %v got %v", tt.expr, tt.want, got)
}
}
}

View File

@@ -1,202 +0,0 @@
package v2
import (
"encoding/json"
"errors"
"strings"
"github.com/actions-oss/act-cli/internal/eval/functions"
exprparser "github.com/actions-oss/act-cli/internal/expr"
)
type FromJSON struct {
}
func (FromJSON) Evaluate(eval *Evaluator, args []exprparser.Node) (*EvaluationResult, error) {
r, err := eval.Evaluate(args[0])
if err != nil {
return nil, err
}
var res any
if err := json.Unmarshal([]byte(r.ConvertToString()), &res); err != nil {
return nil, err
}
return CreateIntermediateResult(eval.Context(), res), nil
}
type ToJSON struct {
}
func (ToJSON) Evaluate(eval *Evaluator, args []exprparser.Node) (*EvaluationResult, error) {
r, err := eval.Evaluate(args[0])
if err != nil {
return nil, err
}
raw, err := eval.ToRaw(r)
if err != nil {
return nil, err
}
data, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return nil, err
}
return CreateIntermediateResult(eval.Context(), string(data)), nil
}
type Contains struct {
}
func (Contains) Evaluate(eval *Evaluator, args []exprparser.Node) (*EvaluationResult, error) {
collection, err := eval.Evaluate(args[0])
if err != nil {
return nil, err
}
el, err := eval.Evaluate(args[1])
if err != nil {
return nil, err
}
// Array
if col, ok := collection.TryGetCollectionInterface(); ok {
if node, ok := col.(ReadOnlyArray[any]); ok {
for _, v := range node.GetEnumerator() {
canon := CreateIntermediateResult(eval.Context(), v)
if canon.AbstractEqual(el) {
return CreateIntermediateResult(eval.Context(), true), nil
}
}
}
return CreateIntermediateResult(eval.Context(), false), nil
}
// String
return CreateIntermediateResult(eval.Context(), strings.Contains(strings.ToLower(collection.ConvertToString()), strings.ToLower(el.ConvertToString()))), nil
}
type StartsWith struct {
}
func (StartsWith) Evaluate(eval *Evaluator, args []exprparser.Node) (*EvaluationResult, error) {
collection, err := eval.Evaluate(args[0])
if err != nil {
return nil, err
}
el, err := eval.Evaluate(args[1])
if err != nil {
return nil, err
}
// String
return CreateIntermediateResult(eval.Context(), strings.HasPrefix(strings.ToLower(collection.ConvertToString()), strings.ToLower(el.ConvertToString()))), nil
}
type EndsWith struct {
}
func (EndsWith) Evaluate(eval *Evaluator, args []exprparser.Node) (*EvaluationResult, error) {
collection, err := eval.Evaluate(args[0])
if err != nil {
return nil, err
}
el, err := eval.Evaluate(args[1])
if err != nil {
return nil, err
}
// String
return CreateIntermediateResult(eval.Context(), strings.HasSuffix(strings.ToLower(collection.ConvertToString()), strings.ToLower(el.ConvertToString()))), nil
}
type Format struct {
}
func (Format) Evaluate(eval *Evaluator, args []exprparser.Node) (*EvaluationResult, error) {
collection, err := eval.Evaluate(args[0])
if err != nil {
return nil, err
}
sargs := []interface{}{}
for _, arg := range args[1:] {
el, err := eval.Evaluate(arg)
if err != nil {
return nil, err
}
sargs = append(sargs, el.ConvertToString())
}
ret, err := functions.Format(collection.ConvertToString(), sargs...)
return CreateIntermediateResult(eval.Context(), ret), err
}
type Join struct {
}
func (Join) Evaluate(eval *Evaluator, args []exprparser.Node) (*EvaluationResult, error) {
collection, err := eval.Evaluate(args[0])
if err != nil {
return nil, err
}
var el *EvaluationResult
if len(args) > 1 {
if el, err = eval.Evaluate(args[1]); err != nil {
return nil, err
}
}
// Array
if col, ok := collection.TryGetCollectionInterface(); ok {
var elements []string
if node, ok := col.(ReadOnlyArray[any]); ok {
for _, v := range node.GetEnumerator() {
elements = append(elements, CreateIntermediateResult(eval.Context(), v).ConvertToString())
}
}
var sep string
if el != nil {
sep = el.ConvertToString()
} else {
sep = ","
}
return CreateIntermediateResult(eval.Context(), strings.Join(elements, sep)), nil
}
// Primitive
if collection.IsPrimitive() {
return CreateIntermediateResult(eval.Context(), collection.ConvertToString()), nil
}
return CreateIntermediateResult(eval.Context(), ""), nil
}
type Case struct {
}
func (Case) Evaluate(eval *Evaluator, args []exprparser.Node) (*EvaluationResult, error) {
if len(args)%2 == 0 {
return nil, errors.New("case function requires an odd number of arguments")
}
for i := 0; i < len(args)-1; i += 2 {
condition, err := eval.Evaluate(args[i])
if err != nil {
return nil, err
}
if condition.kind != ValueKindBoolean {
return nil, errors.New("case function conditions must evaluate to boolean")
}
if condition.IsTruthy() {
return eval.Evaluate(args[i+1])
}
}
return eval.Evaluate(args[len(args)-1])
}
func GetFunctions() CaseInsensitiveObject[Function] {
return CaseInsensitiveObject[Function](map[string]Function{
"fromjson": &FromJSON{},
"tojson": &ToJSON{},
"contains": &Contains{},
"startswith": &StartsWith{},
"endswith": &EndsWith{},
"format": &Format{},
"join": &Join{},
"case": &Case{},
})
}

View File

@@ -1,27 +0,0 @@
package workflow
import "testing"
func TestExpressionParser(t *testing.T) {
node, err := Parse("github.event_name")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
t.Logf("Parsed expression: %+v", node)
}
func TestExpressionParserWildcard(t *testing.T) {
node, err := Parse("github.commits.*.message")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
t.Logf("Parsed expression: %+v", node)
}
func TestExpressionParserDot(t *testing.T) {
node, err := Parse("github.head_commit.message")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
t.Logf("Parsed expression: %+v", node)
}

View File

@@ -1,306 +0,0 @@
package workflow
import (
"errors"
"fmt"
"strings"
)
// Node represents a node in the expression tree.
// It is intentionally minimal only the fields needed for the parser.
// Users can extend it with more information if required.
type Node interface {
String() string
}
// ValueNode represents a literal value (number, string, boolean, null) or a named value.
// The Kind field indicates the type.
// For named values the Value is nil.
type ValueNode struct {
Kind TokenKind
Value interface{}
}
// FunctionNode represents a function call with arguments.
type FunctionNode struct {
Name string
Args []Node
}
// BinaryNode represents a binary operator.
type BinaryNode struct {
Op string
Left Node
Right Node
}
// UnaryNode represents a unary operator.
type UnaryNode struct {
Op string
Operand Node
}
// Parser holds the lexer and the stacks used by the shuntingyard algorithm.
type Parser struct {
lexer *Lexer
tokens []Token
pos int
ops []OpToken
vals []Node
}
type OpToken struct {
Token
StartPos int
}
func precedence(tkn Token) int {
switch tkn.Kind {
case TokenKindStartGroup:
return 20
case TokenKindStartIndex, TokenKindStartParameters, TokenKindDereference:
return 19
case TokenKindLogicalOperator:
switch tkn.Raw {
case "!":
return 16
case ">", ">=", "<", "<=":
return 11
case "==", "!=":
return 10
case "&&":
return 6
case "||":
return 5
}
case TokenKindEndGroup, TokenKindEndIndex, TokenKindEndParameters, TokenKindSeparator:
return 1
}
return 0
}
// Parse parses the expression and returns the root node.
func Parse(expression string) (Node, error) {
lexer := NewLexer(expression, 0)
p := &Parser{}
// Tokenise all tokens
if err := p.initWithLexer(lexer); err != nil {
return nil, err
}
return p.parse()
}
func (p *Parser) parse() (Node, error) {
// Shuntingyard algorithm
for p.pos < len(p.tokens) {
tok := p.tokens[p.pos]
p.pos++
switch tok.Kind {
case TokenKindNumber, TokenKindString, TokenKindBoolean, TokenKindNull:
p.pushValue(&ValueNode{Kind: tok.Kind, Value: tok.Value})
case TokenKindNamedValue, TokenKindPropertyName, TokenKindWildcard:
p.pushValue(&ValueNode{Kind: tok.Kind, Value: tok.Raw})
case TokenKindFunction:
p.pushFunc(tok, len(p.vals))
case TokenKindStartParameters, TokenKindStartGroup, TokenKindStartIndex, TokenKindLogicalOperator, TokenKindDereference:
if err := p.pushOp(tok); err != nil {
return nil, err
}
case TokenKindSeparator:
if err := p.popGroup(TokenKindStartParameters); err != nil {
return nil, err
}
case TokenKindEndParameters:
if err := p.pushFuncValue(); err != nil {
return nil, err
}
case TokenKindEndGroup:
if err := p.popGroup(TokenKindStartGroup); err != nil {
return nil, err
}
p.ops = p.ops[:len(p.ops)-1]
case TokenKindEndIndex:
if err := p.popGroup(TokenKindStartIndex); err != nil {
return nil, err
}
// pop the start parameters
p.ops = p.ops[:len(p.ops)-1]
right := p.vals[len(p.vals)-1]
p.vals = p.vals[:len(p.vals)-1]
left := p.vals[len(p.vals)-1]
p.vals = p.vals[:len(p.vals)-1]
p.vals = append(p.vals, &BinaryNode{Op: "[", Left: left, Right: right})
}
}
for len(p.ops) > 0 {
if err := p.popOp(); err != nil {
return nil, err
}
}
if len(p.vals) != 1 {
return nil, errors.New("invalid expression")
}
return p.vals[0], nil
}
func (p *Parser) pushFuncValue() error {
if err := p.popGroup(TokenKindStartParameters); err != nil {
return err
}
// pop the start parameters
p.ops = p.ops[:len(p.ops)-1]
// create function node
fnTok := p.ops[len(p.ops)-1]
if fnTok.Kind != TokenKindFunction {
return errors.New("expected function token")
}
p.ops = p.ops[:len(p.ops)-1]
// collect arguments
args := []Node{}
for len(p.vals) > fnTok.StartPos {
args = append([]Node{p.vals[len(p.vals)-1]}, args...)
p.vals = p.vals[:len(p.vals)-1]
}
p.pushValue(&FunctionNode{Name: fnTok.Raw, Args: args})
return nil
}
func (p *Parser) initWithLexer(lexer *Lexer) error {
p.lexer = lexer
for {
tok := lexer.Next()
if tok == nil {
break
}
if tok.Kind == TokenKindUnexpected {
return fmt.Errorf("unexpected token %s at position %d", tok.Raw, tok.Index)
}
p.tokens = append(p.tokens, *tok)
}
return nil
}
func (p *Parser) popGroup(kind TokenKind) error {
for len(p.ops) > 0 && p.ops[len(p.ops)-1].Kind != kind {
if err := p.popOp(); err != nil {
return err
}
}
if len(p.ops) == 0 {
return errors.New("mismatched parentheses")
}
return nil
}
func (p *Parser) pushValue(v Node) {
p.vals = append(p.vals, v)
}
func (p *Parser) pushOp(t Token) error {
for len(p.ops) > 0 {
top := p.ops[len(p.ops)-1]
if precedence(top.Token) >= precedence(t) &&
top.Kind != TokenKindStartGroup &&
top.Kind != TokenKindStartIndex &&
top.Kind != TokenKindStartParameters &&
top.Kind != TokenKindSeparator {
if err := p.popOp(); err != nil {
return err
}
} else {
break
}
}
p.ops = append(p.ops, OpToken{Token: t})
return nil
}
func (p *Parser) pushFunc(t Token, start int) {
p.ops = append(p.ops, OpToken{Token: t, StartPos: start})
}
func (p *Parser) popOp() error {
if len(p.ops) == 0 {
return nil
}
op := p.ops[len(p.ops)-1]
p.ops = p.ops[:len(p.ops)-1]
switch op.Kind {
case TokenKindLogicalOperator:
if op.Raw == "!" {
if len(p.vals) < 1 {
return errors.New("insufficient operands")
}
right := p.vals[len(p.vals)-1]
p.vals = p.vals[:len(p.vals)-1]
p.vals = append(p.vals, &UnaryNode{Op: op.Raw, Operand: right})
} else {
if len(p.vals) < 2 {
return errors.New("insufficient operands")
}
right := p.vals[len(p.vals)-1]
left := p.vals[len(p.vals)-2]
p.vals = p.vals[:len(p.vals)-2]
p.vals = append(p.vals, &BinaryNode{Op: op.Raw, Left: left, Right: right})
}
case TokenKindStartParameters:
// unary operator '!' handled elsewhere
case TokenKindDereference:
if len(p.vals) < 2 {
return errors.New("insufficient operands")
}
right := p.vals[len(p.vals)-1]
left := p.vals[len(p.vals)-2]
p.vals = p.vals[:len(p.vals)-2]
p.vals = append(p.vals, &BinaryNode{Op: ".", Left: left, Right: right})
}
return nil
}
// String returns a string representation of the node.
func (n *ValueNode) String() string { return fmt.Sprintf("%v", n.Value) }
// String returns a string representation of the node.
func (n *FunctionNode) String() string {
return fmt.Sprintf("%s(%s)", n.Name, strings.Join(funcArgs(n.Args), ", "))
}
func funcArgs(args []Node) []string {
res := []string{}
for _, a := range args {
res = append(res, a.String())
}
return res
}
// String returns a string representation of the node.
func (n *BinaryNode) String() string {
return fmt.Sprintf("(%s %s %s)", n.Left.String(), n.Op, n.Right.String())
}
// String returns a string representation of the node.
func (n *UnaryNode) String() string { return fmt.Sprintf("(%s%s)", n.Op, n.Operand.String()) }
func VisitNode(exprNode Node, callback func(node Node)) {
callback(exprNode)
switch node := exprNode.(type) {
case *FunctionNode:
for _, arg := range node.Args {
VisitNode(arg, callback)
}
case *UnaryNode:
VisitNode(node.Operand, callback)
case *BinaryNode:
VisitNode(node.Left, callback)
VisitNode(node.Right, callback)
}
}

View File

@@ -1,361 +0,0 @@
package workflow
import (
"math"
"slices"
"strconv"
"strings"
"unicode"
)
// TokenKind represents the type of token returned by the lexer.
// The values mirror the C# TokenKind enum.
//
// Note: The names are kept identical to the C# implementation for
// easier mapping when porting the parser.
//
// The lexer is intentionally simple it only tokenises the subset of
// expressions that are used in GitHub Actions workflow `if:` expressions.
// It does not evaluate the expression that is left to the parser.
type TokenKind int
const (
TokenKindStartGroup TokenKind = iota
TokenKindStartIndex
TokenKindEndGroup
TokenKindEndIndex
TokenKindSeparator
TokenKindDereference
TokenKindWildcard
TokenKindLogicalOperator
TokenKindNumber
TokenKindString
TokenKindBoolean
TokenKindNull
TokenKindPropertyName
TokenKindFunction
TokenKindNamedValue
TokenKindStartParameters
TokenKindEndParameters
TokenKindUnexpected
)
// Token represents a single lexical token.
// Raw holds the original text, Value holds the parsed value when applicable.
// Index is the start position in the source string.
//
// The struct is intentionally minimal it only contains what the parser
// needs. If you need more information (e.g. token length) you can add it.
type Token struct {
Kind TokenKind
Raw string
Value interface{}
Index int
}
// Lexer holds the state while tokenising an expression.
// It is a direct port of the C# LexicalAnalyzer.
//
// Flags can be used to enable/disable features for now we only support
// a single flag that mirrors ExpressionFlags.DTExpressionsV1.
//
// The lexer is not threadsafe reuse a single instance per expression.
type Lexer struct {
expr string
flags int
index int
last *Token
stack []TokenKind // unclosed start tokens
}
// NewLexer creates a new lexer for the given expression.
func NewLexer(expr string, flags int) *Lexer {
return &Lexer{expr: expr, flags: flags}
}
func testTokenBoundary(c rune) bool {
switch c {
case '(', '[', ')', ']', ',', '.',
'!', '>', '<', '=', '&', '|':
return true
default:
return unicode.IsSpace(c)
}
}
// Next returns the next token or nil if the end of the expression is reached.
func (l *Lexer) Next() *Token {
// Skip whitespace
for l.index < len(l.expr) && unicode.IsSpace(rune(l.expr[l.index])) {
l.index++
}
if l.index >= len(l.expr) {
return nil
}
c := l.expr[l.index]
switch c {
case '(':
l.index++
// Function call or logical grouping
if l.last != nil && l.last.Kind == TokenKindFunction {
return l.createToken(TokenKindStartParameters, "(")
}
if l.flags&FlagV1 != 0 {
// V1 does not support grouping treat as unexpected
return l.createToken(TokenKindUnexpected, "(")
}
return l.createToken(TokenKindStartGroup, "(")
case '[':
l.index++
return l.createToken(TokenKindStartIndex, "[")
case ')':
l.index++
if len(l.stack) > 0 && l.stack[len(l.stack)-1] == TokenKindStartParameters {
return l.createToken(TokenKindEndParameters, ")")
}
return l.createToken(TokenKindEndGroup, ")")
case ']':
l.index++
return l.createToken(TokenKindEndIndex, "]")
case ',':
l.index++
return l.createToken(TokenKindSeparator, ",")
case '*':
l.index++
return l.createToken(TokenKindWildcard, "*")
case '\'':
return l.readString()
case '!', '>', '<', '=', '&', '|':
if l.flags&FlagV1 != 0 {
l.index++
return l.createToken(TokenKindUnexpected, string(c))
}
return l.readOperator()
default:
return l.defaultNext(c)
}
}
func (l *Lexer) defaultNext(c byte) *Token {
if c == '.' {
// Could be number or dereference
if l.last == nil || l.last.Kind == TokenKindSeparator || l.last.Kind == TokenKindStartGroup || l.last.Kind == TokenKindStartIndex || l.last.Kind == TokenKindStartParameters || l.last.Kind == TokenKindLogicalOperator {
return l.readNumber()
}
l.index++
return l.createToken(TokenKindDereference, ".")
}
if c == '-' || c == '+' || unicode.IsDigit(rune(c)) {
return l.readNumber()
}
return l.readKeyword()
}
// Helper to create a token and update lexer state.
func (l *Lexer) createToken(kind TokenKind, raw string) *Token {
// Token order check
if !l.checkLastToken(kind, raw) {
// Illegal token sequence
return &Token{Kind: TokenKindUnexpected, Raw: raw, Index: l.index}
}
tok := &Token{Kind: kind, Raw: raw, Index: l.index}
l.last = tok
// Manage stack for grouping
switch kind {
case TokenKindStartGroup, TokenKindStartIndex, TokenKindStartParameters:
l.stack = append(l.stack, kind)
case TokenKindEndGroup, TokenKindEndIndex, TokenKindEndParameters:
if len(l.stack) > 0 {
l.stack = l.stack[:len(l.stack)-1]
}
}
return tok
}
// nil last token represented by nil
func (l *Lexer) getLastKind() *TokenKind {
var lastKind *TokenKind
if l.last != nil {
lastKind = &l.last.Kind
}
return lastKind
}
// checkLastToken verifies that the token sequence is legal based on the last token.
func (l *Lexer) checkLastToken(kind TokenKind, raw string) bool {
lastKind := l.getLastKind()
// Helper to check if lastKind is in allowed list
allowed := func(allowedKinds ...TokenKind) bool {
return lastKind != nil && slices.Contains(allowedKinds, *lastKind)
}
// For nil last, we treat as no previous token
// Define allowed previous kinds for each token kind
switch kind {
case TokenKindStartGroup:
return lastKind == nil || allowed(TokenKindSeparator, TokenKindStartGroup, TokenKindStartParameters, TokenKindStartIndex, TokenKindLogicalOperator)
case TokenKindStartIndex:
return allowed(TokenKindEndGroup, TokenKindEndParameters, TokenKindEndIndex, TokenKindWildcard, TokenKindPropertyName, TokenKindNamedValue)
case TokenKindStartParameters:
return allowed(TokenKindFunction)
case TokenKindEndGroup:
return allowed(TokenKindEndGroup, TokenKindEndParameters, TokenKindEndIndex, TokenKindWildcard, TokenKindNull, TokenKindBoolean, TokenKindNumber, TokenKindString, TokenKindPropertyName, TokenKindNamedValue)
case TokenKindEndIndex:
return allowed(TokenKindEndGroup, TokenKindEndParameters, TokenKindEndIndex, TokenKindWildcard, TokenKindNull, TokenKindBoolean, TokenKindNumber, TokenKindString, TokenKindPropertyName, TokenKindNamedValue)
case TokenKindEndParameters:
return allowed(TokenKindStartParameters, TokenKindEndGroup, TokenKindEndParameters, TokenKindEndIndex, TokenKindWildcard, TokenKindNull, TokenKindBoolean, TokenKindNumber, TokenKindString, TokenKindPropertyName, TokenKindNamedValue)
case TokenKindSeparator:
return allowed(TokenKindEndGroup, TokenKindEndParameters, TokenKindEndIndex, TokenKindWildcard, TokenKindNull, TokenKindBoolean, TokenKindNumber, TokenKindString, TokenKindPropertyName, TokenKindNamedValue)
case TokenKindWildcard:
return allowed(TokenKindStartIndex, TokenKindDereference)
case TokenKindDereference:
return allowed(TokenKindEndGroup, TokenKindEndParameters, TokenKindEndIndex, TokenKindWildcard, TokenKindPropertyName, TokenKindNamedValue)
case TokenKindLogicalOperator:
if raw == "!" { // "!"
return lastKind == nil || allowed(TokenKindSeparator, TokenKindStartGroup, TokenKindStartParameters, TokenKindStartIndex, TokenKindLogicalOperator)
}
return allowed(TokenKindEndGroup, TokenKindEndParameters, TokenKindEndIndex, TokenKindWildcard, TokenKindNull, TokenKindBoolean, TokenKindNumber, TokenKindString, TokenKindPropertyName, TokenKindNamedValue)
case TokenKindNull, TokenKindBoolean, TokenKindNumber, TokenKindString:
return lastKind == nil || allowed(TokenKindSeparator, TokenKindStartIndex, TokenKindStartGroup, TokenKindStartParameters, TokenKindLogicalOperator)
case TokenKindPropertyName:
return allowed(TokenKindDereference)
case TokenKindFunction, TokenKindNamedValue:
return lastKind == nil || allowed(TokenKindSeparator, TokenKindStartIndex, TokenKindStartGroup, TokenKindStartParameters, TokenKindLogicalOperator)
default:
return true
}
}
// readNumber parses a numeric literal.
func (l *Lexer) readNumber() *Token {
start := l.index
periods := 0
for l.index < len(l.expr) {
ch := l.expr[l.index]
if ch == '.' {
periods++
}
if testTokenBoundary(rune(ch)) && ch != '.' {
break
}
l.index++
}
raw := l.expr[start:l.index]
if len(raw) > 2 {
switch raw[:2] {
case "0x", "0o":
tok := l.createToken(TokenKindNumber, raw)
if i, err := strconv.ParseInt(raw, 0, 32); err == nil {
tok.Value = float64(i)
return tok
}
}
}
// Try to parse as float64
var val interface{} = raw
if f, err := strconv.ParseFloat(raw, 64); err == nil {
val = f
}
tok := l.createToken(TokenKindNumber, raw)
tok.Value = val
return tok
}
// readString parses a singlequoted string literal.
func (l *Lexer) readString() *Token {
start := l.index
l.index++ // skip opening quote
var sb strings.Builder
closed := false
for l.index < len(l.expr) {
ch := l.expr[l.index]
l.index++
if ch == '\'' {
if l.index < len(l.expr) && l.expr[l.index] == '\'' {
// escaped quote
sb.WriteByte('\'')
l.index++
continue
}
closed = true
break
}
sb.WriteByte(ch)
}
raw := l.expr[start:l.index]
tok := l.createToken(TokenKindString, raw)
if closed {
tok.Value = sb.String()
} else {
tok.Kind = TokenKindUnexpected
}
return tok
}
// readOperator parses logical operators (==, !=, >, >=, etc.).
func (l *Lexer) readOperator() *Token {
start := l.index
l.index++
if l.index < len(l.expr) {
two := l.expr[start : l.index+1]
switch two {
case "!=", ">=", "<=", "==", "&&", "||":
l.index++
return l.createToken(TokenKindLogicalOperator, two)
}
}
ch := l.expr[start]
switch ch {
case '!', '>', '<':
return l.createToken(TokenKindLogicalOperator, string(ch))
}
return l.createToken(TokenKindUnexpected, string(ch))
}
// readKeyword parses identifiers, booleans, null, etc.
func (l *Lexer) readKeyword() *Token {
start := l.index
for l.index < len(l.expr) && !unicode.IsSpace(rune(l.expr[l.index])) && !strings.ContainsRune("()[],.!<>==&|*", rune(l.expr[l.index])) {
l.index++
}
raw := l.expr[start:l.index]
if l.last != nil && l.last.Kind == TokenKindDereference {
return l.createToken(TokenKindPropertyName, raw)
}
switch raw {
case "true":
tok := l.createToken(TokenKindBoolean, raw)
tok.Value = true
return tok
case "false":
tok := l.createToken(TokenKindBoolean, raw)
tok.Value = false
return tok
case "null":
return l.createToken(TokenKindNull, raw)
case "NaN":
tok := l.createToken(TokenKindNumber, raw)
tok.Value = math.NaN()
return tok
case "Infinity":
tok := l.createToken(TokenKindNumber, raw)
tok.Value = math.Inf(1)
return tok
}
if l.index < len(l.expr) && l.expr[l.index] == '(' {
return l.createToken(TokenKindFunction, raw)
}
return l.createToken(TokenKindNamedValue, raw)
}
// Flag constants only V1 is used for now.
const FlagV1 = 1
// UnclosedTokens returns the stack of unclosed start tokens.
func (l *Lexer) UnclosedTokens() []TokenKind {
return l.stack
}

View File

@@ -1,112 +0,0 @@
package workflow
import (
"testing"
"github.com/stretchr/testify/assert"
)
// TestLexerMultiple runs a set of expressions through the lexer and
// verifies that the produced token kinds and values match expectations.
func TestLexerMultiple(t *testing.T) {
cases := []struct {
expr string
expected []TokenKind
values []interface{} // optional, nil if not checking values
}{
{
expr: "github.event_name == 'push'",
expected: []TokenKind{
TokenKindNamedValue, // github
TokenKindDereference,
TokenKindPropertyName, // event_name
TokenKindLogicalOperator, // ==
TokenKindString, // 'push'
},
},
{
expr: "github.event_name == 'push' && github.ref == 'refs/heads/main'",
expected: []TokenKind{
TokenKindNamedValue, TokenKindDereference, TokenKindPropertyName, TokenKindLogicalOperator, TokenKindString,
TokenKindLogicalOperator, // &&
TokenKindNamedValue, TokenKindDereference, TokenKindPropertyName, TokenKindLogicalOperator, TokenKindString,
},
},
{
expr: "contains(github.ref, 'refs/heads/')",
expected: []TokenKind{
TokenKindFunction, // contains
TokenKindStartParameters,
TokenKindNamedValue, TokenKindDereference, TokenKindPropertyName, // github.ref
TokenKindSeparator,
TokenKindString,
TokenKindEndParameters,
},
},
{
expr: "matrix[0].name",
expected: []TokenKind{
TokenKindNamedValue, // matrix
TokenKindStartIndex,
TokenKindNumber,
TokenKindEndIndex,
TokenKindDereference,
TokenKindPropertyName, // name
},
},
{
expr: "github.*",
expected: []TokenKind{
TokenKindNamedValue, TokenKindDereference, TokenKindWildcard,
},
},
{
expr: "null",
expected: []TokenKind{TokenKindNull},
},
{
expr: "true",
expected: []TokenKind{TokenKindBoolean},
values: []interface{}{true},
},
{
expr: "123",
expected: []TokenKind{TokenKindNumber},
values: []interface{}{123.0},
},
{
expr: "(a && b)",
expected: []TokenKind{TokenKindStartGroup, TokenKindNamedValue, TokenKindLogicalOperator, TokenKindNamedValue, TokenKindEndGroup},
},
{
expr: "[1,2]", // Syntax Error
expected: []TokenKind{TokenKindUnexpected, TokenKindNumber, TokenKindSeparator, TokenKindNumber, TokenKindEndIndex},
},
{
expr: "'Hello i''s escaped'",
expected: []TokenKind{TokenKindString},
values: []interface{}{"Hello i's escaped"},
},
}
for _, tc := range cases {
lexer := NewLexer(tc.expr, 0)
var tokens []*Token
for {
tok := lexer.Next()
if tok == nil {
break
}
tokens = append(tokens, tok)
}
assert.Equal(t, len(tc.expected), len(tokens), "expression: %s", tc.expr)
for i, kind := range tc.expected {
assert.Equal(t, kind, tokens[i].Kind, "expr %s token %d", tc.expr, i)
}
if tc.values != nil {
for i, val := range tc.values {
assert.Equal(t, val, tokens[i].Value, "expr %s token %d value", tc.expr, i)
}
}
}
}

View File

@@ -1,56 +0,0 @@
package workflow
import (
"math"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLexer(t *testing.T) {
input := "github.event_name == 'push' && github.ref == 'refs/heads/main'"
lexer := NewLexer(input, 0)
var tokens []*Token
for {
tok := lexer.Next()
if tok == nil || tok.Kind == TokenKindUnexpected {
break
}
tokens = append(tokens, tok)
}
for i, tok := range tokens {
t.Logf("Token %d: Kind=%v, Value=%v", i, tok.Kind, tok.Value)
}
assert.Equal(t, tokens[1].Kind, TokenKindDereference)
}
func TestLexerNumbers(t *testing.T) {
table := []struct {
in string
out interface{}
}{
{"-Infinity", math.Inf(-1)},
{"Infinity", math.Inf(1)},
{"2.5", float64(2.5)},
{"3.3", float64(3.3)},
{"1", float64(1)},
{"-1", float64(-1)},
{"0x34", float64(0x34)},
{"0o34", float64(0o34)},
}
for _, cs := range table {
lexer := NewLexer(cs.in, 0)
var tokens []*Token
for {
tok := lexer.Next()
if tok == nil || tok.Kind == TokenKindUnexpected {
break
}
tokens = append(tokens, tok)
}
require.Len(t, tokens, 1)
assert.Equal(t, cs.out, tokens[0].Value)
assert.Equal(t, cs.in, tokens[0].Raw)
}
}

View File

@@ -1,30 +0,0 @@
package model
import (
"errors"
"gopkg.in/yaml.v3"
)
// Assumes there is no cycle ensured via test TestVerifyCycleIsInvalid
func resolveAliases(node *yaml.Node) error {
switch node.Kind {
case yaml.AliasNode:
aliasTarget := node.Alias
if aliasTarget == nil {
return errors.New("unresolved alias node")
}
*node = *aliasTarget
if err := resolveAliases(node); err != nil {
return err
}
case yaml.DocumentNode, yaml.MappingNode, yaml.SequenceNode:
for _, child := range node.Content {
if err := resolveAliases(child); err != nil {
return err
}
}
}
return nil
}

View File

@@ -1,242 +0,0 @@
package model
import (
"errors"
"fmt"
"strings"
"gopkg.in/yaml.v3"
)
// TraceWriter is an interface for logging trace information.
// Implementations can write to console, file, or any other sink.
type TraceWriter interface {
Info(format string, args ...interface{})
}
// StrategyResult holds the result of expanding a strategy.
// FlatMatrix contains the expanded matrix entries.
// IncludeMatrix contains entries that were added via include.
// FailFast indicates whether the job should fail fast.
// MaxParallel is the maximum parallelism allowed.
// MatrixKeys is the set of keys present in the matrix.
type StrategyResult struct {
FlatMatrix []map[string]yaml.Node
IncludeMatrix []map[string]yaml.Node
FailFast bool
MaxParallel *float64
MatrixKeys map[string]struct{}
}
type strategyContext struct {
jobTraceWriter TraceWriter
failFast bool
maxParallel float64
matrix map[string][]yaml.Node
flatMatrix []map[string]yaml.Node
includeMatrix []map[string]yaml.Node
include []yaml.Node
exclude []yaml.Node
}
func (strategyContext *strategyContext) handleInclude() error {
// Handle include logic
if len(strategyContext.include) > 0 {
for _, incNode := range strategyContext.include {
if incNode.Kind != yaml.MappingNode {
return fmt.Errorf("include entry is not a mapping node")
}
incMap := make(map[string]yaml.Node)
for i := 0; i < len(incNode.Content); i += 2 {
keyNode := incNode.Content[i]
valNode := incNode.Content[i+1]
if keyNode.Kind != yaml.ScalarNode {
return fmt.Errorf("include key is not scalar")
}
incMap[keyNode.Value] = *valNode
}
matched := false
for _, row := range strategyContext.flatMatrix {
match := true
for k, v := range incMap {
if rv, ok := row[k]; ok && !nodesEqual(rv, v) {
match = false
break
}
}
if match {
matched = true
// Add missing keys
strategyContext.jobTraceWriter.Info("Add missing keys %v", incMap)
for k, v := range incMap {
if _, ok := row[k]; !ok {
row[k] = v
}
}
}
}
if !matched {
if strategyContext.jobTraceWriter != nil {
strategyContext.jobTraceWriter.Info("Append include entry %v", incMap)
}
strategyContext.includeMatrix = append(strategyContext.includeMatrix, incMap)
}
}
}
return nil
}
func (strategyContext *strategyContext) handleExclude() error {
// Handle exclude logic
if len(strategyContext.exclude) > 0 {
for _, exNode := range strategyContext.exclude {
// exNode is expected to be a mapping node
if exNode.Kind != yaml.MappingNode {
return fmt.Errorf("exclude entry is not a mapping node")
}
// Convert mapping to map[string]yaml.Node
exMap := make(map[string]yaml.Node)
for i := 0; i < len(exNode.Content); i += 2 {
keyNode := exNode.Content[i]
valNode := exNode.Content[i+1]
if keyNode.Kind != yaml.ScalarNode {
return fmt.Errorf("exclude key is not scalar")
}
exMap[keyNode.Value] = *valNode
}
// Remove matching rows
filtered := []map[string]yaml.Node{}
for _, row := range strategyContext.flatMatrix {
match := true
for k, v := range exMap {
if rv, ok := row[k]; !ok || !nodesEqual(rv, v) {
match = false
break
}
}
if !match {
filtered = append(filtered, row)
} else if strategyContext.jobTraceWriter != nil {
strategyContext.jobTraceWriter.Info("Removing %v from matrix due to exclude entry %v", row, exMap)
}
}
strategyContext.flatMatrix = filtered
}
}
return nil
}
// ExpandStrategy expands the given strategy into a flat matrix and include matrix.
// It mimics the behavior of the C# StrategyUtils. The strategy parameter is expected
// to be populated from a YAML mapping that follows the GitHub Actions strategy schema.
func ExpandStrategy(strategy *Strategy, jobTraceWriter TraceWriter) (*StrategyResult, error) {
if strategy == nil {
return &StrategyResult{FlatMatrix: []map[string]yaml.Node{{}}, IncludeMatrix: []map[string]yaml.Node{}, FailFast: true}, nil
}
// Initialize defaults
strategyContext := &strategyContext{
jobTraceWriter: jobTraceWriter,
failFast: strategy.FailFast,
maxParallel: strategy.MaxParallel,
matrix: strategy.Matrix,
flatMatrix: []map[string]yaml.Node{{}},
}
// Process matrix entries
for key, values := range strategyContext.matrix {
switch key {
case "include":
strategyContext.include = values
case "exclude":
strategyContext.exclude = values
default:
// Other keys are treated as matrix dimensions
// Expand each existing row with the new key/value pairs
next := []map[string]yaml.Node{}
for _, row := range strategyContext.flatMatrix {
for _, val := range values {
newRow := make(map[string]yaml.Node)
for k, v := range row {
newRow[k] = v
}
newRow[key] = val
next = append(next, newRow)
}
}
strategyContext.flatMatrix = next
}
}
if err := strategyContext.handleExclude(); err != nil {
return nil, err
}
if len(strategyContext.flatMatrix) == 0 {
if jobTraceWriter != nil {
jobTraceWriter.Info("Matrix is empty, adding an empty entry")
}
strategyContext.flatMatrix = []map[string]yaml.Node{{}}
}
// Enforce job matrix limit of github
if len(strategyContext.flatMatrix) > 256 {
if jobTraceWriter != nil {
jobTraceWriter.Info("Failure: Matrix contains more than 256 entries after exclude")
}
return nil, errors.New("matrix contains more than 256 entries")
}
// Build matrix keys set
matrixKeys := make(map[string]struct{})
if len(strategyContext.flatMatrix) > 0 {
for k := range strategyContext.flatMatrix[0] {
matrixKeys[k] = struct{}{}
}
}
if err := strategyContext.handleInclude(); err != nil {
return nil, err
}
return &StrategyResult{
FlatMatrix: strategyContext.flatMatrix,
IncludeMatrix: strategyContext.includeMatrix,
FailFast: strategyContext.failFast,
MaxParallel: &strategyContext.maxParallel,
MatrixKeys: matrixKeys,
}, nil
}
// nodesEqual compares two yaml.Node values for equality.
func nodesEqual(a, b yaml.Node) bool {
return DeepEquals(a, b, true)
}
// GetDefaultDisplaySuffix returns a string like "(foo, bar, baz)".
// Empty items are ignored. If all items are empty the result is "".
func GetDefaultDisplaySuffix(items []string) string {
var b strings.Builder // efficient string concatenation
first := true // true until we write the first nonempty item
for _, mk := range items {
if mk == "" { // Go has no null string, so we only need to check for empty
continue
}
if first {
b.WriteString("(")
first = false
} else {
b.WriteString(", ")
}
b.WriteString(mk)
}
if !first { // we wrote at least one item
b.WriteString(")")
}
return b.String()
}

View File

@@ -1,68 +0,0 @@
package model
import (
"testing"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
type EmptyTraceWriter struct {
}
func (e *EmptyTraceWriter) Info(_ string, _ ...interface{}) {
}
func TestStrategy(t *testing.T) {
table := []struct {
content string
flatmatrix int
includematrix int
}{
{`
matrix:
label:
- a
- b
fields:
- a
- b
`, 4, 0},
{`
matrix:
label:
- a
- b
include:
- label: a
x: self`, 2, 0,
},
{`
matrix:
label:
- a
- b
include:
- label: c
x: self`, 2, 1,
},
{`
matrix:
label:
- a
- b
exclude:
- label: a`, 1, 0,
},
}
for _, tc := range table {
var strategy Strategy
err := yaml.Unmarshal([]byte(tc.content), &strategy)
require.NoError(t, err)
res, err := ExpandStrategy(&strategy, &EmptyTraceWriter{})
require.NoError(t, err)
require.Len(t, res.FlatMatrix, tc.flatmatrix)
require.Len(t, res.IncludeMatrix, tc.includematrix)
}
}

View File

@@ -1,148 +0,0 @@
package model
import (
"strings"
v2 "github.com/actions-oss/act-cli/internal/eval/v2"
"gopkg.in/yaml.v3"
)
// DeepEquals compares two yaml.Node values recursively.
// It supports scalar, mapping and sequence nodes and allows
// an optional partial match for mappings and sequences.
func DeepEquals(a, b yaml.Node, partialMatch bool) bool {
// Scalar comparison
if a.Kind == yaml.ScalarNode && b.Kind == yaml.ScalarNode {
return scalarEquals(a, b)
}
// Mapping comparison
if a.Kind == yaml.MappingNode && b.Kind == yaml.MappingNode {
return deepMapEquals(a, b, partialMatch)
}
// Sequence comparison
if a.Kind == yaml.SequenceNode && b.Kind == yaml.SequenceNode {
return deepSequenceEquals(a, b, partialMatch)
}
// Different kinds are not equal
return false
}
func scalarEquals(a, b yaml.Node) bool {
var left, right any
return a.Decode(&left) == nil && b.Decode(&right) == nil && v2.CreateIntermediateResult(v2.NewEvaluationContext(), left).AbstractEqual(v2.CreateIntermediateResult(v2.NewEvaluationContext(), right))
}
func deepMapEquals(a, b yaml.Node, partialMatch bool) bool {
mapA := make(map[string]yaml.Node)
for i := 0; i < len(a.Content); i += 2 {
keyNode := a.Content[i]
valNode := a.Content[i+1]
if keyNode.Kind != yaml.ScalarNode {
return false
}
mapA[strings.ToLower(keyNode.Value)] = *valNode
}
mapB := make(map[string]yaml.Node)
for i := 0; i < len(b.Content); i += 2 {
keyNode := b.Content[i]
valNode := b.Content[i+1]
if keyNode.Kind != yaml.ScalarNode {
return false
}
mapB[strings.ToLower(keyNode.Value)] = *valNode
}
if partialMatch {
if len(mapA) < len(mapB) {
return false
}
} else {
if len(mapA) != len(mapB) {
return false
}
}
for k, vB := range mapB {
vA, ok := mapA[k]
if !ok || !DeepEquals(vA, vB, partialMatch) {
return false
}
}
return true
}
func deepSequenceEquals(a, b yaml.Node, partialMatch bool) bool {
if partialMatch {
if len(a.Content) < len(b.Content) {
return false
}
} else {
if len(a.Content) != len(b.Content) {
return false
}
}
limit := len(b.Content)
if !partialMatch {
limit = len(a.Content)
}
for i := 0; i < limit; i++ {
if !DeepEquals(*a.Content[i], *b.Content[i], partialMatch) {
return false
}
}
return true
}
// traverse walks a YAML node recursively.
func traverse(node *yaml.Node, omitKeys bool, result *[]*yaml.Node) {
if node == nil {
return
}
*result = append(*result, node)
switch node.Kind {
case yaml.MappingNode:
if omitKeys {
// node.Content: key0, val0, key1, val1, …
for i := 1; i < len(node.Content); i += 2 { // only the values
traverse(node.Content[i], omitKeys, result)
}
} else {
for _, child := range node.Content {
traverse(child, omitKeys, result)
}
}
case yaml.SequenceNode:
// For all other node kinds (Scalar, Sequence, Alias, etc.)
for _, child := range node.Content {
traverse(child, omitKeys, result)
}
}
}
// GetDisplayStrings implements the LINQ expression:
//
// from displayitem in keys.SelectMany(key => item[key].Traverse(true))
// where !(displayitem is SequenceToken || displayitem is MappingToken)
// select displayitem.ToString()
func GetDisplayStrings(keys []string, item map[string]*yaml.Node) []string {
var res []string
for _, k := range keys {
if node, ok := item[k]; ok {
var all []*yaml.Node
traverse(node, true, &all) // include the parent node itself
for _, n := range all {
// Keep only scalars everything else is dropped
if n.Kind == yaml.ScalarNode {
res = append(res, n.Value)
}
}
}
}
return res
}

View File

@@ -1,277 +0,0 @@
package model
import "gopkg.in/yaml.v3"
type JobStatus int
const (
JobStatusPending JobStatus = iota
JobStatusDependenciesReady
JobStatusBlocked
JobStatusCompleted
)
type JobState struct {
JobID string // Workflow path to job, incl matrix and parent jobids
Result string // Actions Job Result
Outputs map[string]string // Returned Outputs
State JobStatus
Strategy []MatrixJobState
}
type MatrixJobState struct {
Matrix map[string]any
Name string
Result string
Outputs map[string]string // Returned Outputs
State JobStatus
}
type WorkflowStatus int
const (
WorkflowStatusPending WorkflowStatus = iota
WorkflowStatusDependenciesReady
WorkflowStatusBlocked
WorkflowStatusCompleted
)
type WorkflowState struct {
Name string
RunName string
Jobs JobState
StateWorkflowStatus WorkflowStatus
}
type Workflow struct {
On *On `yaml:"on,omitempty"`
Name string `yaml:"name,omitempty"`
Description string `yaml:"description,omitempty"`
RunName yaml.Node `yaml:"run-name,omitempty"`
Permissions *Permissions `yaml:"permissions,omitempty"`
Env yaml.Node `yaml:"env,omitempty"`
Defaults yaml.Node `yaml:"defaults,omitempty"`
Concurrency yaml.Node `yaml:"concurrency,omitempty"` // Two layouts
Jobs map[string]Job `yaml:"jobs,omitempty"`
}
type On struct {
Data map[string]yaml.Node `yaml:"-"`
WorkflowDispatch *WorkflowDispatch `yaml:"workflow_dispatch,omitempty"`
WorkflowCall *WorkflowCall `yaml:"workflow_call,omitempty"`
Schedule []Cron `yaml:"schedule,omitempty"`
}
type Cron struct {
Cron string `yaml:"cron,omitempty"`
}
func (a *On) UnmarshalYAML(node *yaml.Node) error {
switch node.Kind {
case yaml.ScalarNode:
var s string
if err := node.Decode(&s); err != nil {
return err
}
a.Data = map[string]yaml.Node{}
a.Data[s] = yaml.Node{}
case yaml.SequenceNode:
var s []string
if err := node.Decode(&s); err != nil {
return err
}
a.Data = map[string]yaml.Node{}
for _, v := range s {
a.Data[v] = yaml.Node{}
}
default:
if err := node.Decode(&a.Data); err != nil {
return err
}
type OnObj On
if err := node.Decode((*OnObj)(a)); err != nil {
return err
}
}
return nil
}
func (a *On) MarshalYAML() (interface{}, error) {
return a.Data, nil
}
var (
_ yaml.Unmarshaler = &On{}
_ yaml.Marshaler = &On{}
_ yaml.Unmarshaler = &Concurrency{}
_ yaml.Unmarshaler = &RunsOn{}
_ yaml.Unmarshaler = &ImplicitStringArray{}
_ yaml.Unmarshaler = &Environment{}
)
type WorkflowDispatch struct {
Inputs map[string]Input `yaml:"inputs,omitempty"`
}
type Input struct {
Description string `yaml:"description,omitempty"`
Type string `yaml:"type,omitempty"`
Default string `yaml:"default,omitempty"`
Required bool `yaml:"required,omitempty"`
}
type WorkflowCall struct {
Inputs map[string]Input `yaml:"inputs,omitempty"`
Secrets map[string]Secret `yaml:"secrets,omitempty"`
Outputs map[string]Output `yaml:"outputs,omitempty"`
}
type Secret struct {
Description string `yaml:"description,omitempty"`
Required bool `yaml:"required,omitempty"`
}
type Output struct {
Description string `yaml:"description,omitempty"`
Value yaml.Node `yaml:"value,omitempty"`
}
type Job struct {
Needs ImplicitStringArray `yaml:"needs,omitempty"`
Permissions *Permissions `yaml:"permissions,omitempty"`
Strategy yaml.Node `yaml:"strategy,omitempty"`
Name yaml.Node `yaml:"name,omitempty"`
Concurrency yaml.Node `yaml:"concurrency,omitempty"`
// Reusable Workflow
Uses yaml.Node `yaml:"uses,omitempty"`
With yaml.Node `yaml:"with,omitempty"`
Secrets yaml.Node `yaml:"secrets,omitempty"`
// Runner Job
RunsOn yaml.Node `yaml:"runs-on,omitempty"`
Defaults yaml.Node `yaml:"defaults,omitempty"`
TimeoutMinutes yaml.Node `yaml:"timeout-minutes,omitempty"`
Container yaml.Node `yaml:"container,omitempty"`
Services yaml.Node `yaml:"services,omitempty"`
Env yaml.Node `yaml:"env,omitempty"`
Steps []yaml.Node `yaml:"steps,omitempty"`
Outputs yaml.Node `yaml:"outputs,omitempty"`
}
type ImplicitStringArray []string
func (a *ImplicitStringArray) UnmarshalYAML(node *yaml.Node) error {
if node.Kind == yaml.ScalarNode {
var s string
if err := node.Decode(&s); err != nil {
return err
}
*a = []string{s}
return nil
}
return node.Decode((*[]string)(a))
}
type Permissions map[string]string
func (p *Permissions) UnmarshalYAML(node *yaml.Node) error {
if node.Kind == yaml.ScalarNode {
var s string
if err := node.Decode(&s); err != nil {
return err
}
var perm string
switch s {
case "read-all":
perm = "read"
case "write-all":
perm = "write"
default:
return nil
}
(*p)["actions"] = perm
(*p)["attestations"] = perm
(*p)["contents"] = perm
(*p)["checks"] = perm
(*p)["deployments"] = perm
(*p)["discussions"] = perm
(*p)["id-token"] = perm
(*p)["issues"] = perm
(*p)["models"] = perm
(*p)["packages"] = perm
(*p)["pages"] = perm
(*p)["pull-requests"] = perm
(*p)["repository-projects"] = perm
(*p)["security-events"] = perm
(*p)["statuses"] = perm
return nil
}
return node.Decode((*map[string]string)(p))
}
type Strategy struct {
Matrix map[string][]yaml.Node `yaml:"matrix"`
MaxParallel float64 `yaml:"max-parallel"`
FailFast bool `yaml:"fail-fast"`
}
type Concurrency struct {
Group string `yaml:"group"`
CancelInProgress bool `yaml:"cancel-in-progress"`
}
func (c *Concurrency) UnmarshalYAML(node *yaml.Node) error {
if node.Kind == yaml.ScalarNode {
var s string
if err := node.Decode(&s); err != nil {
return err
}
c.Group = s
return nil
}
type ConcurrencyObj Concurrency
return node.Decode((*ConcurrencyObj)(c))
}
type Environment struct {
Name string `yaml:"name"`
URL yaml.Node `yaml:"url"`
}
func (e *Environment) UnmarshalYAML(node *yaml.Node) error {
if node.Kind == yaml.ScalarNode {
var s string
if err := node.Decode(&s); err != nil {
return err
}
e.Name = s
return nil
}
type EnvironmentObj Environment
return node.Decode((*EnvironmentObj)(e))
}
type RunsOn struct {
Labels []string `yaml:"labels"`
Group string `yaml:"group,omitempty"`
}
func (a *RunsOn) UnmarshalYAML(node *yaml.Node) error {
if node.Kind == yaml.ScalarNode {
var s string
if err := node.Decode(&s); err != nil {
return err
}
a.Labels = []string{s}
return nil
}
if node.Kind == yaml.SequenceNode {
var s []string
if err := node.Decode(&s); err != nil {
return err
}
a.Labels = s
return nil
}
type RunsOnObj RunsOn
return node.Decode((*RunsOnObj)(a))
}

View File

@@ -1,141 +0,0 @@
package model
import (
"context"
"testing"
v2 "github.com/actions-oss/act-cli/internal/eval/v2"
"github.com/actions-oss/act-cli/internal/templateeval"
"github.com/actions-oss/act-cli/pkg/schema"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
func TestParseWorkflow(t *testing.T) {
ee := &templateeval.ExpressionEvaluator{
EvaluationContext: v2.EvaluationContext{
Variables: v2.CaseInsensitiveObject[any]{},
Functions: v2.GetFunctions(),
},
}
var node yaml.Node
err := yaml.Unmarshal([]byte(`
on: push
run-name: ${{ fromjson('{}') }}
jobs:
_:
name: ${{ github.ref_name }}
steps:
- run: echo Hello World
env:
TAG: ${{ env.global }}
`), &node)
require.NoError(t, err)
err = ee.EvaluateYamlNode(context.Background(), node.Content[0], &schema.Node{
Definition: "workflow-root",
Schema: schema.GetWorkflowSchema(),
})
require.NoError(t, err)
ee.RestrictEval = true
ee.EvaluationContext.Variables = v2.CaseInsensitiveObject[any]{
"github": v2.CaseInsensitiveObject[any]{
"ref_name": "self",
},
"vars": v2.CaseInsensitiveObject[any]{},
"inputs": v2.CaseInsensitiveObject[any]{},
}
err = ee.EvaluateYamlNode(context.Background(), node.Content[0], &schema.Node{
Definition: "workflow-root",
Schema: schema.GetWorkflowSchema(),
})
require.Error(t, err)
var myw Workflow
require.NoError(t, node.Decode(&myw))
}
func TestParseWorkflowCall(t *testing.T) {
ee := &templateeval.ExpressionEvaluator{
EvaluationContext: v2.EvaluationContext{
Variables: v2.CaseInsensitiveObject[any]{},
Functions: v2.GetFunctions(),
},
}
var node yaml.Node
// jobs.test.outputs.test
err := yaml.Unmarshal([]byte(`
on:
workflow_call:
outputs:
test:
value: ${{ jobs.test.outputs.test }} # tojson(vars.raw)
run-name: ${{ github.ref_name }}
jobs:
_:
runs-on: ubuntu-latest
name: ${{ github.ref_name }}
steps:
- run: echo Hello World
env:
TAG: ${{ env.global }}
`), &node)
require.NoError(t, err)
require.NoError(t, resolveAliases(node.Content[0]))
require.NoError(t, (&schema.Node{
Definition: "workflow-root",
Schema: schema.GetWorkflowSchema(),
}).UnmarshalYAML(node.Content[0]))
err = ee.EvaluateYamlNode(context.Background(), node.Content[0], &schema.Node{
Definition: "workflow-root",
Schema: schema.GetWorkflowSchema(),
})
require.NoError(t, err)
var raw any
err = node.Content[0].Decode(&raw)
assert.NoError(t, err)
ee.RestrictEval = true
ee.EvaluationContext.Variables = v2.CaseInsensitiveObject[any]{
"github": v2.CaseInsensitiveObject[any]{
"ref_name": "self",
},
"vars": v2.CaseInsensitiveObject[any]{
"raw": raw,
},
"inputs": v2.CaseInsensitiveObject[any]{},
"jobs": v2.CaseInsensitiveObject[any]{
"test": v2.CaseInsensitiveObject[any]{
"outputs": v2.CaseInsensitiveObject[any]{
"test": "Hello World",
},
},
},
}
err = ee.EvaluateYamlNode(context.Background(), node.Content[0], &schema.Node{
RestrictEval: true,
Definition: "workflow-root",
Schema: schema.GetWorkflowSchema(),
})
require.NoError(t, err)
var myw Workflow
require.NoError(t, node.Decode(&myw))
workflowCall := myw.On.WorkflowCall
if workflowCall != nil {
for _, out := range workflowCall.Outputs {
err = ee.EvaluateYamlNode(context.Background(), &out.Value, &schema.Node{
RestrictEval: true,
Definition: "workflow-output-context",
Schema: schema.GetWorkflowSchema(),
})
require.NoError(t, err)
require.Equal(t, "Hello World", out.Value.Value)
}
}
out, err := yaml.Marshal(&myw)
assert.NoError(t, err)
assert.NotEmpty(t, out)
}

View File

@@ -8,6 +8,7 @@ import (
"crypto/tls"
"net/http"
"strings"
"time"
"code.gitea.io/actions-proto-go/ping/v1/pingv1connect"
"code.gitea.io/actions-proto-go/runner/v1/runnerv1connect"
@@ -15,16 +16,17 @@ import (
)
func getHTTPClient(endpoint string, insecure bool) *http.Client {
transport := &http.Transport{
MaxIdleConns: 10,
MaxIdleConnsPerHost: 10, // All requests go to one host; default is 2 which causes frequent reconnects.
IdleConnTimeout: 90 * time.Second,
}
if strings.HasPrefix(endpoint, "https://") && insecure {
return &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
transport.TLSClientConfig = &tls.Config{
InsecureSkipVerify: true,
},
},
}
}
return http.DefaultClient
return &http.Client{Transport: transport}
}
// New returns a new runner client.
@@ -47,14 +49,15 @@ func New(endpoint string, insecure bool, uuid, token, version string, opts ...co
}
})))
httpClient := getHTTPClient(endpoint, insecure)
return &HTTPClient{
PingServiceClient: pingv1connect.NewPingServiceClient(
getHTTPClient(endpoint, insecure),
httpClient,
baseURL,
opts...,
),
RunnerServiceClient: runnerv1connect.NewRunnerServiceClient(
getHTTPClient(endpoint, insecure),
httpClient,
baseURL,
opts...,
),

View File

@@ -32,6 +32,24 @@ runner:
fetch_timeout: 5s
# The interval for fetching the job from the Gitea instance.
fetch_interval: 2s
# The maximum interval for fetching the job from the Gitea instance.
# The runner uses exponential backoff when idle, increasing the interval up to this maximum.
# Set to 0 or same as fetch_interval to disable backoff.
fetch_interval_max: 60s
# The base interval for periodic log flush to the Gitea instance.
# Logs may be sent earlier if the buffer reaches log_report_batch_size
# or if log_report_max_latency expires after the first buffered row.
log_report_interval: 5s
# The maximum time a log row can wait before being sent.
# This ensures even a single log line appears on the frontend within this duration.
# Must be less than log_report_interval to have any effect.
log_report_max_latency: 3s
# Flush logs immediately when the buffer reaches this many rows.
# This ensures bursty output (e.g., npm install) is delivered promptly.
log_report_batch_size: 100
# The interval for reporting task state (step status, timing) to the Gitea instance.
# State is also reported immediately on step transitions (start/stop).
state_report_interval: 5s
# The github_mirror of a runner is used to specify the mirror address of the github that pulls the action repository.
# It works when something like `uses: actions/checkout@v4` is used and DEFAULT_ACTIONS_URL is set to github,
# and github_mirror is not empty. In this case,
@@ -103,8 +121,23 @@ container:
require_docker: false
# Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or act_runner
docker_timeout: 0s
# Bind the workspace to the host filesystem instead of using Docker volumes.
# This is required for Docker-in-Docker (DinD) setups when jobs use docker compose
# with bind mounts (e.g., ".:/app"), as volume-based workspaces are not accessible
# from the DinD daemon's filesystem. When enabled, ensure the workspace parent
# directory is also mounted into the runner container and listed in valid_volumes.
bind_workdir: false
host:
# The parent directory of a job's working directory.
# If it's empty, $HOME/.cache/act/ will be used.
workdir_parent:
metrics:
# Enable the Prometheus metrics endpoint.
# When enabled, metrics are served at http://<addr>/metrics and a liveness check at /healthz.
enabled: false
# The address for the metrics HTTP server to listen on.
# Defaults to localhost only. Set to ":9101" to allow external access,
# but ensure the port is firewall-protected as there is no authentication.
addr: "127.0.0.1:9101"

View File

@@ -31,6 +31,11 @@ type Runner struct {
Insecure bool `yaml:"insecure"` // Insecure indicates whether the runner operates in an insecure mode.
FetchTimeout time.Duration `yaml:"fetch_timeout"` // FetchTimeout specifies the timeout duration for fetching resources.
FetchInterval time.Duration `yaml:"fetch_interval"` // FetchInterval specifies the interval duration for fetching resources.
FetchIntervalMax time.Duration `yaml:"fetch_interval_max"` // FetchIntervalMax specifies the maximum backoff interval when idle.
LogReportInterval time.Duration `yaml:"log_report_interval"` // LogReportInterval specifies the base interval for periodic log flush.
LogReportMaxLatency time.Duration `yaml:"log_report_max_latency"` // LogReportMaxLatency specifies the max time a log row can wait before being sent.
LogReportBatchSize int `yaml:"log_report_batch_size"` // LogReportBatchSize triggers immediate log flush when buffer reaches this size.
StateReportInterval time.Duration `yaml:"state_report_interval"` // StateReportInterval specifies the interval for state reporting.
Labels []string `yaml:"labels"` // Labels specify the labels of the runner. Labels are declared on each startup
GithubMirror string `yaml:"github_mirror"` // GithubMirror defines what mirrors should be used when using github
}
@@ -57,6 +62,7 @@ type Container struct {
ForceRebuild bool `yaml:"force_rebuild"` // Rebuild docker image(s) even if already present
RequireDocker bool `yaml:"require_docker"` // Always require a reachable docker daemon, even if not required by act_runner
DockerTimeout time.Duration `yaml:"docker_timeout"` // Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or act_runner
BindWorkdir bool `yaml:"bind_workdir"` // BindWorkdir binds the workspace to the host filesystem instead of using Docker volumes. Required for DinD when jobs use docker compose with bind mounts.
}
// Host represents the configuration for the host.
@@ -64,6 +70,12 @@ type Host struct {
WorkdirParent string `yaml:"workdir_parent"` // WorkdirParent specifies the parent directory for the host's working directory.
}
// Metrics represents the configuration for the Prometheus metrics endpoint.
type Metrics struct {
Enabled bool `yaml:"enabled"` // Enabled indicates whether the metrics endpoint is exposed.
Addr string `yaml:"addr"` // Addr specifies the listen address for the metrics HTTP server (e.g., ":9101").
}
// Config represents the overall configuration.
type Config struct {
Log Log `yaml:"log"` // Log represents the configuration for logging.
@@ -71,6 +83,7 @@ type Config struct {
Cache Cache `yaml:"cache"` // Cache represents the configuration for caching.
Container Container `yaml:"container"` // Container represents the configuration for the container.
Host Host `yaml:"host"` // Host represents the configuration for the host.
Metrics Metrics `yaml:"metrics"` // Metrics represents the configuration for the Prometheus metrics endpoint.
}
// LoadDefault returns the default configuration.
@@ -136,6 +149,35 @@ func LoadDefault(file string) (*Config, error) {
if cfg.Runner.FetchInterval <= 0 {
cfg.Runner.FetchInterval = 2 * time.Second
}
if cfg.Runner.FetchIntervalMax <= 0 {
cfg.Runner.FetchIntervalMax = 60 * time.Second
}
if cfg.Runner.LogReportInterval <= 0 {
cfg.Runner.LogReportInterval = 5 * time.Second
}
if cfg.Runner.LogReportMaxLatency <= 0 {
cfg.Runner.LogReportMaxLatency = 3 * time.Second
}
if cfg.Runner.LogReportBatchSize <= 0 {
cfg.Runner.LogReportBatchSize = 100
}
if cfg.Runner.StateReportInterval <= 0 {
cfg.Runner.StateReportInterval = 5 * time.Second
}
if cfg.Metrics.Addr == "" {
cfg.Metrics.Addr = "127.0.0.1:9101"
}
// Validate and fix invalid config combinations to prevent confusing behavior.
if cfg.Runner.FetchIntervalMax < cfg.Runner.FetchInterval {
log.Warnf("fetch_interval_max (%v) is less than fetch_interval (%v), setting fetch_interval_max to fetch_interval",
cfg.Runner.FetchIntervalMax, cfg.Runner.FetchInterval)
cfg.Runner.FetchIntervalMax = cfg.Runner.FetchInterval
}
if cfg.Runner.LogReportMaxLatency >= cfg.Runner.LogReportInterval {
log.Warnf("log_report_max_latency (%v) >= log_report_interval (%v), the max-latency timer will never fire before the periodic ticker; consider lowering log_report_max_latency",
cfg.Runner.LogReportMaxLatency, cfg.Runner.LogReportInterval)
}
// 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

@@ -0,0 +1,216 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package metrics
import (
"sync"
"time"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
)
// Namespace is the Prometheus namespace for all act_runner metrics.
const Namespace = "act_runner"
// Label value constants for Prometheus metrics.
// Using constants prevents typos from silently creating new time-series.
//
// LabelResult* values are used on metrics with label key "result" (RPC outcomes).
// LabelStatus* values are used on metrics with label key "status" (job outcomes).
const (
LabelResultTask = "task"
LabelResultEmpty = "empty"
LabelResultError = "error"
LabelResultSuccess = "success"
LabelMethodFetchTask = "FetchTask"
LabelMethodUpdateLog = "UpdateLog"
LabelMethodUpdateTask = "UpdateTask"
LabelStatusSuccess = "success"
LabelStatusFailure = "failure"
LabelStatusCancelled = "cancelled"
LabelStatusSkipped = "skipped"
LabelStatusUnknown = "unknown"
)
// rpcDurationBuckets covers the expected latency range for short-running
// UpdateLog / UpdateTask RPCs. FetchTask uses its own buckets (it has a 10s tail).
var rpcDurationBuckets = []float64{0.01, 0.05, 0.1, 0.25, 0.5, 1, 2, 5}
// ResultToStatusLabel maps a runnerv1.Result to the "status" label value used on job metrics.
func ResultToStatusLabel(r runnerv1.Result) string {
switch r {
case runnerv1.Result_RESULT_SUCCESS:
return LabelStatusSuccess
case runnerv1.Result_RESULT_FAILURE:
return LabelStatusFailure
case runnerv1.Result_RESULT_CANCELLED:
return LabelStatusCancelled
case runnerv1.Result_RESULT_SKIPPED:
return LabelStatusSkipped
default:
return LabelStatusUnknown
}
}
var (
RunnerInfo = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: Namespace,
Name: "info",
Help: "Runner metadata. Always 1. Labels carry version and name.",
}, []string{"version", "name"})
RunnerCapacity = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: Namespace,
Name: "capacity",
Help: "Configured maximum concurrent jobs.",
})
PollFetchTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: Namespace,
Subsystem: "poll",
Name: "fetch_total",
Help: "Total number of FetchTask RPCs by result (task, empty, error).",
}, []string{"result"})
PollFetchDuration = prometheus.NewHistogram(prometheus.HistogramOpts{
Namespace: Namespace,
Subsystem: "poll",
Name: "fetch_duration_seconds",
Help: "Latency of FetchTask RPCs, excluding expected long-poll timeouts.",
Buckets: []float64{0.01, 0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10},
})
PollBackoffSeconds = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: Namespace,
Subsystem: "poll",
Name: "backoff_seconds",
Help: "Last observed polling backoff interval in seconds.",
})
JobsTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: Namespace,
Subsystem: "job",
Name: "total",
Help: "Total jobs processed by status (success, failure, cancelled, skipped, unknown).",
}, []string{"status"})
JobDuration = prometheus.NewHistogram(prometheus.HistogramOpts{
Namespace: Namespace,
Subsystem: "job",
Name: "duration_seconds",
Help: "Duration of job execution from start to finish.",
Buckets: prometheus.ExponentialBuckets(1, 2, 14), // 1s to ~4.5h
})
ReportLogTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: Namespace,
Subsystem: "report",
Name: "log_total",
Help: "Total UpdateLog RPCs by result (success, error).",
}, []string{"result"})
ReportLogDuration = prometheus.NewHistogram(prometheus.HistogramOpts{
Namespace: Namespace,
Subsystem: "report",
Name: "log_duration_seconds",
Help: "Latency of UpdateLog RPCs.",
Buckets: rpcDurationBuckets,
})
ReportStateTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: Namespace,
Subsystem: "report",
Name: "state_total",
Help: "Total UpdateTask (state) RPCs by result (success, error).",
}, []string{"result"})
ReportStateDuration = prometheus.NewHistogram(prometheus.HistogramOpts{
Namespace: Namespace,
Subsystem: "report",
Name: "state_duration_seconds",
Help: "Latency of UpdateTask RPCs.",
Buckets: rpcDurationBuckets,
})
ReportLogBufferRows = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: Namespace,
Subsystem: "report",
Name: "log_buffer_rows",
Help: "Current number of buffered log rows awaiting send.",
})
ClientErrors = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: Namespace,
Subsystem: "client",
Name: "errors_total",
Help: "Total client RPC errors by method.",
}, []string{"method"})
)
// Registry is the custom Prometheus registry used by the runner.
var Registry = prometheus.NewRegistry()
var initOnce sync.Once
// Init registers all static metrics and the standard Go/process collectors.
// Safe to call multiple times; only the first call has effect.
func Init() {
initOnce.Do(func() {
Registry.MustRegister(
collectors.NewGoCollector(),
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
RunnerInfo, RunnerCapacity,
PollFetchTotal, PollFetchDuration, PollBackoffSeconds,
JobsTotal, JobDuration,
ReportLogTotal, ReportLogDuration,
ReportStateTotal, ReportStateDuration, ReportLogBufferRows,
ClientErrors,
)
})
}
// RegisterUptimeFunc registers a GaugeFunc that reports seconds since startTime.
func RegisterUptimeFunc(startTime time.Time) {
Registry.MustRegister(prometheus.NewGaugeFunc(
prometheus.GaugeOpts{
Namespace: Namespace,
Name: "uptime_seconds",
Help: "Seconds since the runner daemon started.",
},
func() float64 { return time.Since(startTime).Seconds() },
))
}
// RegisterRunningJobsFunc registers GaugeFuncs for the running job count and
// capacity utilisation ratio, evaluated lazily at Prometheus scrape time.
func RegisterRunningJobsFunc(countFn func() int64, capacity int) {
capF := float64(capacity)
Registry.MustRegister(prometheus.NewGaugeFunc(
prometheus.GaugeOpts{
Namespace: Namespace,
Subsystem: "job",
Name: "running",
Help: "Number of jobs currently executing.",
},
func() float64 { return float64(countFn()) },
))
Registry.MustRegister(prometheus.NewGaugeFunc(
prometheus.GaugeOpts{
Namespace: Namespace,
Subsystem: "job",
Name: "capacity_utilization_ratio",
Help: "Ratio of running jobs to configured capacity (0.0-1.0).",
},
func() float64 {
if capF <= 0 {
return 0
}
return float64(countFn()) / capF
},
))
}

View File

@@ -0,0 +1,50 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package metrics
import (
"context"
"net/http"
"time"
"github.com/prometheus/client_golang/prometheus/promhttp"
log "github.com/sirupsen/logrus"
)
// StartServer starts an HTTP server that serves Prometheus metrics on /metrics
// and a liveness check on /healthz. The server shuts down when ctx is cancelled.
// Call Init() before StartServer to register metrics with the Registry.
func StartServer(ctx context.Context, addr string) {
mux := http.NewServeMux()
mux.Handle("/metrics", promhttp.HandlerFor(Registry, promhttp.HandlerOpts{}))
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
srv := &http.Server{
Addr: addr,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
log.Infof("metrics server listening on %s", addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.WithError(err).Error("metrics server failed")
}
}()
go func() {
<-ctx.Done()
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(shutCtx); err != nil {
log.WithError(err).Warn("metrics server shutdown error")
}
}()
}

View File

@@ -8,19 +8,20 @@ import (
"errors"
"fmt"
"regexp"
"slices"
"strings"
"sync"
"time"
"gitea.com/gitea/act_runner/internal/pkg/client"
"gitea.com/gitea/act_runner/internal/pkg/config"
"gitea.com/gitea/act_runner/internal/pkg/metrics"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"connectrpc.com/connect"
"github.com/avast/retry-go/v4"
log "github.com/sirupsen/logrus"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
"gitea.com/gitea/act_runner/internal/pkg/client"
)
type Reporter struct {
@@ -36,18 +37,32 @@ type Reporter struct {
logReplacer *strings.Replacer
oldnew []string
// lastLogBufferRows is the last value written to the ReportLogBufferRows
// gauge; guarded by clientM (the same lock held around each ReportLog call)
// so the gauge skips no-op Set calls when the buffer size is unchanged.
lastLogBufferRows int
state *runnerv1.TaskState
stateChanged bool
stateMu sync.RWMutex
outputs sync.Map
daemon chan struct{}
// Adaptive batching control
logReportInterval time.Duration
logReportMaxLatency time.Duration
logBatchSize int
stateReportInterval time.Duration
// Event notification channels (non-blocking, buffered 1)
logNotify chan struct{} // signal: new log rows arrived
stateNotify chan struct{} // signal: step transition (start/stop)
debugOutputEnabled bool
stopCommandEndToken string
stepIds []string
}
func NewReporter(ctx context.Context, cancel context.CancelFunc, client client.Client, task *runnerv1.Task) *Reporter {
func NewReporter(ctx context.Context, cancel context.CancelFunc, client client.Client, task *runnerv1.Task, cfg *config.Config) *Reporter {
var oldnew []string
if v := task.Context.Fields["token"].GetStringValue(); v != "" {
oldnew = append(oldnew, v, "***")
@@ -65,6 +80,12 @@ func NewReporter(ctx context.Context, cancel context.CancelFunc, client client.C
client: client,
oldnew: oldnew,
logReplacer: strings.NewReplacer(oldnew...),
logReportInterval: cfg.Runner.LogReportInterval,
logReportMaxLatency: cfg.Runner.LogReportMaxLatency,
logBatchSize: cfg.Runner.LogReportBatchSize,
stateReportInterval: cfg.Runner.StateReportInterval,
logNotify: make(chan struct{}, 1),
stateNotify: make(chan struct{}, 1),
state: &runnerv1.TaskState{
Id: task.Id,
},
@@ -78,6 +99,13 @@ func NewReporter(ctx context.Context, cancel context.CancelFunc, client client.C
return rv
}
// Result returns the final job result. Safe to call after Close() returns.
func (r *Reporter) Result() runnerv1.Result {
r.stateMu.RLock()
defer r.stateMu.RUnlock()
return r.state.Result
}
func (r *Reporter) ResetSteps(l int) {
r.stateMu.Lock()
defer r.stateMu.Unlock()
@@ -88,13 +116,6 @@ func (r *Reporter) ResetSteps(l int) {
}
}
func (r *Reporter) SetStepIdMapping(stepIDs ...string) {
r.ResetSteps(len(stepIDs))
r.stateMu.Lock()
defer r.stateMu.Unlock()
r.stepIds = stepIDs
}
func (r *Reporter) Levels() []log.Level {
return log.AllLevels
}
@@ -118,11 +139,42 @@ func isJobStepEntry(entry *log.Entry) bool {
return true
}
func (r *Reporter) Fire(entry *log.Entry) error {
r.stateMu.Lock()
defer r.stateMu.Unlock()
// notifyLog sends a non-blocking signal that new log rows are available.
func (r *Reporter) notifyLog() {
select {
case r.logNotify <- struct{}{}:
default:
}
}
// notifyState sends a non-blocking signal that a UX-critical state change occurred (step start/stop, job result).
func (r *Reporter) notifyState() {
select {
case r.stateNotify <- struct{}{}:
default:
}
}
// unlockAndNotify releases stateMu and sends channel notifications.
// Must be called with stateMu held.
func (r *Reporter) unlockAndNotify(urgentState bool) {
r.stateMu.Unlock()
r.notifyLog()
if urgentState {
r.notifyState()
}
}
func (r *Reporter) Fire(entry *log.Entry) error {
urgentState := false
r.stateMu.Lock()
r.stateChanged = true
if log.IsLevelEnabled(log.TraceLevel) {
log.WithFields(entry.Data).Trace(entry.Message)
}
timestamp := entry.Time
if r.state.StartedAt == nil {
@@ -145,11 +197,13 @@ func (r *Reporter) Fire(entry *log.Entry) error {
}
}
}
urgentState = true
}
}
if !r.duringSteps() {
r.logRows = appendIfNotNil(r.logRows, r.parseLogRow(entry))
}
r.unlockAndNotify(urgentState)
return nil
}
@@ -158,22 +212,18 @@ func (r *Reporter) Fire(entry *log.Entry) error {
if v, ok := v.(int); ok && len(r.state.Steps) > v {
step = r.state.Steps[v]
}
} else if v, ok := entry.Data["stepID"]; ok {
if v, ok := v.([]string); ok && len(v) >= 1 {
if no := slices.Index(r.stepIds, v[0]); no >= 0 && len(r.state.Steps) > no {
step = r.state.Steps[no]
}
}
}
if step == nil {
if !r.duringSteps() {
r.logRows = appendIfNotNil(r.logRows, r.parseLogRow(entry))
}
r.unlockAndNotify(false)
return nil
}
if step.StartedAt == nil {
step.StartedAt = timestamppb.New(timestamp)
urgentState = true
}
// Force reporting log errors as raw output to prevent silent failures
@@ -201,26 +251,91 @@ func (r *Reporter) Fire(entry *log.Entry) error {
}
step.Result = stepResult
step.StoppedAt = timestamppb.New(timestamp)
urgentState = true
}
}
r.unlockAndNotify(urgentState)
return nil
}
func (r *Reporter) RunDaemon() {
go r.runDaemonLoop()
}
func (r *Reporter) stopLatencyTimer(active *bool, timer *time.Timer) {
if *active {
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
*active = false
}
}
func (r *Reporter) runDaemonLoop() {
logTicker := time.NewTicker(r.logReportInterval)
stateTicker := time.NewTicker(r.stateReportInterval)
// maxLatencyTimer ensures the first buffered log row is sent within logReportMaxLatency.
// Start inactive — it is armed when the first log row arrives in an empty buffer.
maxLatencyTimer := time.NewTimer(0)
if !maxLatencyTimer.Stop() {
<-maxLatencyTimer.C
}
maxLatencyActive := false
defer logTicker.Stop()
defer stateTicker.Stop()
defer maxLatencyTimer.Stop()
for {
select {
case <-logTicker.C:
_ = r.ReportLog(false)
r.stopLatencyTimer(&maxLatencyActive, maxLatencyTimer)
case <-stateTicker.C:
_ = r.ReportState(false)
case <-r.logNotify:
r.stateMu.RLock()
closed := r.closed
n := len(r.logRows)
r.stateMu.RUnlock()
if closed || r.ctx.Err() != nil {
// Acknowledge close
if n >= r.logBatchSize {
_ = r.ReportLog(false)
r.stopLatencyTimer(&maxLatencyActive, maxLatencyTimer)
} else if !maxLatencyActive && n > 0 {
maxLatencyTimer.Reset(r.logReportMaxLatency)
maxLatencyActive = true
}
case <-r.stateNotify:
// Step transition or job result — flush both immediately for frontend UX.
_ = r.ReportLog(false)
_ = r.ReportState(false)
r.stopLatencyTimer(&maxLatencyActive, maxLatencyTimer)
case <-maxLatencyTimer.C:
maxLatencyActive = false
_ = r.ReportLog(false)
case <-r.ctx.Done():
close(r.daemon)
return
}
_ = r.ReportLog(false)
_ = r.ReportState(false)
time.AfterFunc(time.Second, r.RunDaemon)
r.stateMu.RLock()
closed := r.closed
r.stateMu.RUnlock()
if closed {
close(r.daemon)
return
}
}
}
func (r *Reporter) Logf(format string, a ...any) {
@@ -284,6 +399,10 @@ func (r *Reporter) Close(lastWords string) error {
})
}
r.stateMu.Unlock()
// Wake up the daemon loop so it detects closed promptly.
r.notifyLog()
// Wait for Acknowledge
select {
case <-r.daemon:
@@ -311,15 +430,24 @@ func (r *Reporter) ReportLog(noMore bool) error {
rows := r.logRows
r.stateMu.RUnlock()
if !noMore && len(rows) == 0 {
return nil
}
start := time.Now()
resp, err := r.client.UpdateLog(r.ctx, connect.NewRequest(&runnerv1.UpdateLogRequest{
TaskId: r.state.Id,
Index: int64(r.logOffset),
Rows: rows,
NoMore: noMore,
}))
metrics.ReportLogDuration.Observe(time.Since(start).Seconds())
if err != nil {
metrics.ReportLogTotal.WithLabelValues(metrics.LabelResultError).Inc()
metrics.ClientErrors.WithLabelValues(metrics.LabelMethodUpdateLog).Inc()
return err
}
metrics.ReportLogTotal.WithLabelValues(metrics.LabelResultSuccess).Inc()
ack := int(resp.Msg.AckIndex)
if ack < r.logOffset {
@@ -330,7 +458,12 @@ func (r *Reporter) ReportLog(noMore bool) error {
r.logRows = r.logRows[ack-r.logOffset:]
submitted := r.logOffset + len(rows)
r.logOffset = ack
remaining := len(r.logRows)
r.stateMu.Unlock()
if remaining != r.lastLogBufferRows {
metrics.ReportLogBufferRows.Set(float64(remaining))
r.lastLogBufferRows = remaining
}
if noMore && ack < submitted {
return errors.New("not all logs are submitted")
@@ -345,15 +478,7 @@ func (r *Reporter) ReportState(reportResult bool) error {
r.clientM.Lock()
defer r.clientM.Unlock()
r.stateMu.RLock()
state := proto.Clone(r.state).(*runnerv1.TaskState)
r.stateMu.RUnlock()
// Only report result from Close to reliable sent logs
if !reportResult {
state.Result = runnerv1.Result_RESULT_UNSPECIFIED
}
// Build the outputs map first (single Range pass instead of two).
outputs := make(map[string]string)
r.outputs.Range(func(k, v any) bool {
if val, ok := v.(string); ok {
@@ -362,13 +487,36 @@ func (r *Reporter) ReportState(reportResult bool) error {
return true
})
// Consume stateChanged atomically with the snapshot; restored on error
// below so a concurrent Fire() during UpdateTask isn't silently lost.
r.stateMu.Lock()
if !reportResult && !r.stateChanged && len(outputs) == 0 {
r.stateMu.Unlock()
return nil
}
state := proto.Clone(r.state).(*runnerv1.TaskState)
r.stateChanged = false
r.stateMu.Unlock()
if !reportResult {
state.Result = runnerv1.Result_RESULT_UNSPECIFIED
}
start := time.Now()
resp, err := r.client.UpdateTask(r.ctx, connect.NewRequest(&runnerv1.UpdateTaskRequest{
State: state,
Outputs: outputs,
}))
metrics.ReportStateDuration.Observe(time.Since(start).Seconds())
if err != nil {
metrics.ReportStateTotal.WithLabelValues(metrics.LabelResultError).Inc()
metrics.ClientErrors.WithLabelValues(metrics.LabelMethodUpdateTask).Inc()
r.stateMu.Lock()
r.stateChanged = true
r.stateMu.Unlock()
return err
}
metrics.ReportStateTotal.WithLabelValues(metrics.LabelResultSuccess).Inc()
for _, k := range resp.Msg.SentOutputs {
r.outputs.Store(k, struct{}{})

View File

@@ -6,11 +6,15 @@ package report
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"gitea.com/gitea/act_runner/internal/pkg/client/mocks"
"gitea.com/gitea/act_runner/internal/pkg/config"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
connect_go "connectrpc.com/connect"
log "github.com/sirupsen/logrus"
@@ -19,8 +23,6 @@ import (
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/structpb"
"google.golang.org/protobuf/types/known/timestamppb"
"gitea.com/gitea/act_runner/internal/pkg/client/mocks"
)
func TestReporter_parseLogRow(t *testing.T) {
@@ -175,9 +177,10 @@ func TestReporter_Fire(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
taskCtx, err := structpb.NewStruct(map[string]any{})
require.NoError(t, err)
cfg, _ := config.LoadDefault("")
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{
Context: taskCtx,
})
}, cfg)
reporter.RunDaemon()
defer func() {
require.NoError(t, reporter.Close(""))
@@ -252,7 +255,8 @@ func TestReporter_EphemeralRunnerDeletion(t *testing.T) {
defer cancel()
taskCtx, err := structpb.NewStruct(map[string]any{})
require.NoError(t, err)
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx})
cfg, _ := config.LoadDefault("")
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
reporter.ResetSteps(1)
// Fire a log entry to create pending data
@@ -315,23 +319,281 @@ func TestReporter_RunDaemonClose_Race(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
taskCtx, err := structpb.NewStruct(map[string]any{})
require.NoError(t, err)
cfg, _ := config.LoadDefault("")
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{
Context: taskCtx,
})
}, cfg)
reporter.ResetSteps(1)
// Start the daemon loop in a separate goroutine.
// RunDaemon reads r.closed and reschedules itself via time.AfterFunc.
var wg sync.WaitGroup
wg.Go(func() {
// Start the daemon loop — RunDaemon spawns a goroutine internally.
reporter.RunDaemon()
})
// Close concurrently — this races with RunDaemon on r.closed.
// Close concurrently — this races with the daemon goroutine on r.closed.
require.NoError(t, reporter.Close(""))
// Cancel context so pending AfterFunc callbacks exit quickly.
// Cancel context so the daemon goroutine exits cleanly.
cancel()
wg.Wait()
time.Sleep(2 * time.Second)
}
// TestReporter_MaxLatencyTimer verifies that the maxLatencyTimer flushes a
// single buffered log row before the periodic logTicker fires.
//
// Setup: logReportInterval=10s (effectively never), maxLatency=100ms.
// Fire one log line, then assert UpdateLog is called within 500ms.
func TestReporter_MaxLatencyTimer(t *testing.T) {
var updateLogCalls atomic.Int64
client := mocks.NewClient(t)
client.On("UpdateLog", mock.Anything, mock.Anything).Return(
func(_ context.Context, req *connect_go.Request[runnerv1.UpdateLogRequest]) (*connect_go.Response[runnerv1.UpdateLogResponse], error) {
updateLogCalls.Add(1)
return connect_go.NewResponse(&runnerv1.UpdateLogResponse{
AckIndex: req.Msg.Index + int64(len(req.Msg.Rows)),
}), nil
},
)
client.On("UpdateTask", mock.Anything, mock.Anything).Maybe().Return(
func(_ context.Context, _ *connect_go.Request[runnerv1.UpdateTaskRequest]) (*connect_go.Response[runnerv1.UpdateTaskResponse], error) {
return connect_go.NewResponse(&runnerv1.UpdateTaskResponse{}), nil
},
)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
taskCtx, err := structpb.NewStruct(map[string]any{})
require.NoError(t, err)
// Custom config: logTicker=10s (won't fire during test), maxLatency=100ms
cfg, _ := config.LoadDefault("")
cfg.Runner.LogReportInterval = 10 * time.Second
cfg.Runner.LogReportMaxLatency = 100 * time.Millisecond
cfg.Runner.LogReportBatchSize = 1000 // won't trigger batch flush
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
reporter.ResetSteps(1)
reporter.RunDaemon()
defer func() {
_ = reporter.Close("")
}()
// Fire a single log line — not enough to trigger batch flush
require.NoError(t, reporter.Fire(&log.Entry{
Message: "single log line",
Data: log.Fields{"stage": "Main", "stepNumber": 0, "raw_output": true},
}))
// maxLatencyTimer should flush within ~100ms. Wait up to 500ms.
assert.Eventually(t, func() bool {
return updateLogCalls.Load() > 0
}, 500*time.Millisecond, 10*time.Millisecond,
"maxLatencyTimer should have flushed the log before logTicker (10s)")
}
// TestReporter_BatchSizeFlush verifies that reaching logBatchSize triggers
// an immediate log flush without waiting for any timer.
func TestReporter_BatchSizeFlush(t *testing.T) {
var updateLogCalls atomic.Int64
client := mocks.NewClient(t)
client.On("UpdateLog", mock.Anything, mock.Anything).Return(
func(_ context.Context, req *connect_go.Request[runnerv1.UpdateLogRequest]) (*connect_go.Response[runnerv1.UpdateLogResponse], error) {
updateLogCalls.Add(1)
return connect_go.NewResponse(&runnerv1.UpdateLogResponse{
AckIndex: req.Msg.Index + int64(len(req.Msg.Rows)),
}), nil
},
)
client.On("UpdateTask", mock.Anything, mock.Anything).Maybe().Return(
func(_ context.Context, _ *connect_go.Request[runnerv1.UpdateTaskRequest]) (*connect_go.Response[runnerv1.UpdateTaskResponse], error) {
return connect_go.NewResponse(&runnerv1.UpdateTaskResponse{}), nil
},
)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
taskCtx, err := structpb.NewStruct(map[string]any{})
require.NoError(t, err)
// Custom config: large timers, small batch size
cfg, _ := config.LoadDefault("")
cfg.Runner.LogReportInterval = 10 * time.Second
cfg.Runner.LogReportMaxLatency = 10 * time.Second
cfg.Runner.LogReportBatchSize = 5
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
reporter.ResetSteps(1)
reporter.RunDaemon()
defer func() {
_ = reporter.Close("")
}()
// Fire exactly batchSize log lines
for i := range 5 {
require.NoError(t, reporter.Fire(&log.Entry{
Message: fmt.Sprintf("log line %d", i),
Data: log.Fields{"stage": "Main", "stepNumber": 0, "raw_output": true},
}))
}
// Batch threshold should trigger immediate flush
assert.Eventually(t, func() bool {
return updateLogCalls.Load() > 0
}, 500*time.Millisecond, 10*time.Millisecond,
"batch size threshold should have triggered immediate flush")
}
// TestReporter_StateChangedNotLostDuringReport asserts that a Fire() arriving
// mid-UpdateTask re-dirties the flag so the change is picked up by the next report.
func TestReporter_StateChangedNotLostDuringReport(t *testing.T) {
var updateTaskCalls atomic.Int64
inFlight := make(chan struct{})
release := make(chan struct{})
client := mocks.NewClient(t)
client.On("UpdateTask", mock.Anything, mock.Anything).Return(
func(_ context.Context, _ *connect_go.Request[runnerv1.UpdateTaskRequest]) (*connect_go.Response[runnerv1.UpdateTaskResponse], error) {
n := updateTaskCalls.Add(1)
if n == 1 {
// Signal that the first UpdateTask is in flight, then block until released.
close(inFlight)
<-release
}
return connect_go.NewResponse(&runnerv1.UpdateTaskResponse{}), nil
},
)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
taskCtx, err := structpb.NewStruct(map[string]any{})
require.NoError(t, err)
cfg, _ := config.LoadDefault("")
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
reporter.ResetSteps(2)
// Mark stateChanged=true so the first ReportState proceeds to UpdateTask.
reporter.stateMu.Lock()
reporter.stateChanged = true
reporter.stateMu.Unlock()
// Kick off the first ReportState in a goroutine — it will block in UpdateTask.
done := make(chan error, 1)
go func() {
done <- reporter.ReportState(false)
}()
// Wait until UpdateTask is in flight (snapshot taken, flag consumed).
<-inFlight
// Concurrent Fire() modifies state — must re-flip stateChanged so the
// change is not lost when the in-flight ReportState finishes.
require.NoError(t, reporter.Fire(&log.Entry{
Message: "step starts",
Data: log.Fields{"stage": "Main", "stepNumber": 1, "raw_output": true},
}))
// Release the in-flight UpdateTask and wait for it to return.
close(release)
require.NoError(t, <-done)
// stateChanged must still be true so the next ReportState picks up the
// concurrent Fire()'s change instead of skipping via the early-return path.
reporter.stateMu.RLock()
changed := reporter.stateChanged
reporter.stateMu.RUnlock()
assert.True(t, changed, "stateChanged must remain true after a concurrent Fire() during in-flight ReportState")
// And the next ReportState must actually send a second UpdateTask.
require.NoError(t, reporter.ReportState(false))
assert.Equal(t, int64(2), updateTaskCalls.Load(), "concurrent Fire() change must trigger a second UpdateTask, not be silently lost")
}
// TestReporter_StateChangedRestoredOnError verifies that when UpdateTask fails,
// the dirty flag is restored so the snapshotted change isn't silently lost.
func TestReporter_StateChangedRestoredOnError(t *testing.T) {
var updateTaskCalls atomic.Int64
client := mocks.NewClient(t)
client.On("UpdateTask", mock.Anything, mock.Anything).Return(
func(_ context.Context, _ *connect_go.Request[runnerv1.UpdateTaskRequest]) (*connect_go.Response[runnerv1.UpdateTaskResponse], error) {
n := updateTaskCalls.Add(1)
if n == 1 {
return nil, errors.New("transient network error")
}
return connect_go.NewResponse(&runnerv1.UpdateTaskResponse{}), nil
},
)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
taskCtx, err := structpb.NewStruct(map[string]any{})
require.NoError(t, err)
cfg, _ := config.LoadDefault("")
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
reporter.ResetSteps(1)
reporter.stateMu.Lock()
reporter.stateChanged = true
reporter.stateMu.Unlock()
// First ReportState fails — flag must be restored to true.
require.Error(t, reporter.ReportState(false))
reporter.stateMu.RLock()
changed := reporter.stateChanged
reporter.stateMu.RUnlock()
assert.True(t, changed, "stateChanged must be restored to true after UpdateTask error so the change is retried")
// The next ReportState should still issue a request because the flag was restored.
require.NoError(t, reporter.ReportState(false))
assert.Equal(t, int64(2), updateTaskCalls.Load())
}
// TestReporter_StateNotifyFlush verifies that step transitions trigger
// an immediate state flush via the stateNotify channel.
func TestReporter_StateNotifyFlush(t *testing.T) {
var updateTaskCalls atomic.Int64
client := mocks.NewClient(t)
client.On("UpdateLog", mock.Anything, mock.Anything).Maybe().Return(
func(_ context.Context, req *connect_go.Request[runnerv1.UpdateLogRequest]) (*connect_go.Response[runnerv1.UpdateLogResponse], error) {
return connect_go.NewResponse(&runnerv1.UpdateLogResponse{
AckIndex: req.Msg.Index + int64(len(req.Msg.Rows)),
}), nil
},
)
client.On("UpdateTask", mock.Anything, mock.Anything).Return(
func(_ context.Context, _ *connect_go.Request[runnerv1.UpdateTaskRequest]) (*connect_go.Response[runnerv1.UpdateTaskResponse], error) {
updateTaskCalls.Add(1)
return connect_go.NewResponse(&runnerv1.UpdateTaskResponse{}), nil
},
)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
taskCtx, err := structpb.NewStruct(map[string]any{})
require.NoError(t, err)
// Custom config: large state interval so only stateNotify can trigger
cfg, _ := config.LoadDefault("")
cfg.Runner.StateReportInterval = 10 * time.Second
cfg.Runner.LogReportInterval = 10 * time.Second
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
reporter.ResetSteps(1)
reporter.RunDaemon()
defer func() {
_ = reporter.Close("")
}()
// Fire a log entry that starts a step — this triggers stateNotify
require.NoError(t, reporter.Fire(&log.Entry{
Message: "step starting",
Data: log.Fields{"stage": "Main", "stepNumber": 0, "raw_output": true},
}))
// stateNotify should trigger immediate UpdateTask call
assert.Eventually(t, func() bool {
return updateTaskCalls.Load() > 0
}, 500*time.Millisecond, 10*time.Millisecond,
"step transition should have triggered immediate state flush via stateNotify")
}

View File

@@ -1,195 +0,0 @@
package templateeval
import (
"context"
"fmt"
"regexp"
v2 "github.com/actions-oss/act-cli/internal/eval/v2"
exprparser "github.com/actions-oss/act-cli/internal/expr"
"github.com/actions-oss/act-cli/pkg/schema"
"gopkg.in/yaml.v3"
)
type ExpressionEvaluator struct {
RestrictEval bool
EvaluationContext v2.EvaluationContext
}
func isImplExpr(snode *schema.Node) bool {
def := snode.Schema.GetDefinition(snode.Definition)
return def.String != nil && def.String.IsExpression
}
func (ee ExpressionEvaluator) evaluateScalarYamlNode(_ context.Context, node *yaml.Node, snode *schema.Node) (*yaml.Node, error) {
var in string
if err := node.Decode(&in); err != nil {
return nil, err
}
expr, isExpr, err := rewriteSubExpression(in, false)
if err != nil {
return nil, err
}
if snode == nil || !isExpr && !isImplExpr(snode) || snode.Schema.GetDefinition(snode.Definition).String.IsExpression || ee.RestrictEval && node.Tag != "!!expr" {
return node, nil
}
parsed, err := exprparser.Parse(expr)
if err != nil {
return nil, err
}
canEvaluate := ee.canEvaluate(parsed, snode)
if !canEvaluate {
node.Tag = "!!expr"
return node, nil
}
eval := v2.NewEvaluator(&ee.EvaluationContext)
res, err := eval.EvaluateRaw(expr)
if err != nil {
return nil, err
}
ret := &yaml.Node{}
if err := ret.Encode(res); err != nil {
return nil, err
}
ret.Line = node.Line
ret.Column = node.Column
// Finally check if we found a schema validation error
return ret, snode.UnmarshalYAML(ret)
}
func (ee ExpressionEvaluator) canEvaluate(parsed exprparser.Node, snode *schema.Node) bool {
canEvaluate := true
for _, v := range snode.GetVariables() {
canEvaluate = canEvaluate && ee.EvaluationContext.Variables.Get(v) != nil
}
for _, v := range snode.GetFunctions() {
canEvaluate = canEvaluate && ee.EvaluationContext.Functions.Get(v.GetName()) != nil
}
exprparser.VisitNode(parsed, func(node exprparser.Node) {
switch el := node.(type) {
case *exprparser.FunctionNode:
canEvaluate = canEvaluate && ee.EvaluationContext.Functions.Get(el.Name) != nil
case *exprparser.ValueNode:
canEvaluate = canEvaluate && (el.Kind != exprparser.TokenKindNamedValue || ee.EvaluationContext.Variables.Get(el.Value.(string)) != nil)
}
})
return canEvaluate
}
func (ee ExpressionEvaluator) evaluateMappingYamlNode(ctx context.Context, node *yaml.Node, snode *schema.Node) (*yaml.Node, error) {
var ret *yaml.Node
// GitHub has this undocumented feature to merge maps, called insert directive
insertDirective := regexp.MustCompile(`\${{\s*insert\s*}}`)
for i := 0; i < len(node.Content)/2; i++ {
k := node.Content[i*2]
var sk string
shouldInsert := k.Decode(&sk) == nil && insertDirective.MatchString(sk)
changed := func() error {
if ret == nil {
ret = &yaml.Node{}
if err := ret.Encode(node); err != nil {
return err
}
ret.Content = ret.Content[:i*2]
}
return nil
}
var ek *yaml.Node
if !shouldInsert {
var err error
ek, err = ee.evaluateYamlNodeInternal(ctx, k, snode)
if err != nil {
return nil, err
}
if ek != nil {
if err := changed(); err != nil {
return nil, err
}
} else {
ek = k
}
}
v := node.Content[i*2+1]
ev, err := ee.evaluateYamlNodeInternal(ctx, v, snode.GetNestedNode(ek.Value))
if err != nil {
return nil, err
}
if ev != nil {
if err := changed(); err != nil {
return nil, err
}
} else {
ev = v
}
// Merge the nested map of the insert directive
if shouldInsert {
if ev.Kind != yaml.MappingNode {
return nil, fmt.Errorf("failed to insert node %v into mapping %v unexpected type %v expected MappingNode", ev, node, ev.Kind)
}
if err := changed(); err != nil {
return nil, err
}
ret.Content = append(ret.Content, ev.Content...)
} else if ret != nil {
ret.Content = append(ret.Content, ek, ev)
}
}
return ret, nil
}
func (ee ExpressionEvaluator) evaluateSequenceYamlNode(ctx context.Context, node *yaml.Node, snode *schema.Node) (*yaml.Node, error) {
var ret *yaml.Node
for i := 0; i < len(node.Content); i++ {
v := node.Content[i]
// Preserve nested sequences
wasseq := v.Kind == yaml.SequenceNode
ev, err := ee.evaluateYamlNodeInternal(ctx, v, snode.GetNestedNode("*"))
if err != nil {
return nil, err
}
if ev != nil {
if ret == nil {
ret = &yaml.Node{}
if err := ret.Encode(node); err != nil {
return nil, err
}
ret.Content = ret.Content[:i]
}
// GitHub has this undocumented feature to merge sequences / arrays
// We have a nested sequence via evaluation, merge the arrays
if ev.Kind == yaml.SequenceNode && !wasseq {
ret.Content = append(ret.Content, ev.Content...)
} else {
ret.Content = append(ret.Content, ev)
}
} else if ret != nil {
ret.Content = append(ret.Content, v)
}
}
return ret, nil
}
func (ee ExpressionEvaluator) evaluateYamlNodeInternal(ctx context.Context, node *yaml.Node, snode *schema.Node) (*yaml.Node, error) {
switch node.Kind {
case yaml.ScalarNode:
return ee.evaluateScalarYamlNode(ctx, node, snode)
case yaml.MappingNode:
return ee.evaluateMappingYamlNode(ctx, node, snode)
case yaml.SequenceNode:
return ee.evaluateSequenceYamlNode(ctx, node, snode)
default:
return nil, nil
}
}
func (ee ExpressionEvaluator) EvaluateYamlNode(ctx context.Context, node *yaml.Node, snode *schema.Node) error {
ret, err := ee.evaluateYamlNodeInternal(ctx, node, snode)
if err != nil {
return err
}
if ret != nil {
return ret.Decode(node)
}
return nil
}

View File

@@ -1,94 +0,0 @@
package templateeval
import (
"context"
"testing"
v2 "github.com/actions-oss/act-cli/internal/eval/v2"
"github.com/actions-oss/act-cli/pkg/schema"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
func TestEval(t *testing.T) {
cases := []struct {
name string
yamlInput string
restrict bool
variables v2.CaseInsensitiveObject[any]
expectErr bool
}{
{
name: "NoError",
yamlInput: `on: push
run-name: ${{ github.ref_name }}
jobs:
_:
name: ${{ github.ref_name }}
steps:
- run: echo Hello World
env:
TAG: ${{ env.global }}`,
restrict: false,
expectErr: false,
},
{
name: "Error",
yamlInput: `on: push
run-name: ${{ fromjson('{}') }}
jobs:
_:
name: ${{ github.ref_name }}
steps:
- run: echo Hello World
env:
TAG: ${{ env.global }}`,
restrict: true,
variables: v2.CaseInsensitiveObject[any]{
"github": v2.CaseInsensitiveObject[any]{
"ref_name": "self",
},
"vars": v2.CaseInsensitiveObject[any]{},
"inputs": v2.CaseInsensitiveObject[any]{},
},
expectErr: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ee := &ExpressionEvaluator{
EvaluationContext: v2.EvaluationContext{
Variables: v2.CaseInsensitiveObject[any]{},
Functions: v2.GetFunctions(),
},
}
var node yaml.Node
err := yaml.Unmarshal([]byte(tc.yamlInput), &node)
require.NoError(t, err)
err = ee.EvaluateYamlNode(context.Background(), node.Content[0], &schema.Node{
Definition: "workflow-root",
Schema: schema.GetWorkflowSchema(),
})
require.NoError(t, err)
if tc.restrict {
ee.RestrictEval = true
}
if tc.variables != nil {
ee.EvaluationContext.Variables = tc.variables
}
err = ee.EvaluateYamlNode(context.Background(), node.Content[0], &schema.Node{
Definition: "workflow-root",
Schema: schema.GetWorkflowSchema(),
})
if tc.expectErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}

View File

@@ -1,75 +0,0 @@
package templateeval
import (
"fmt"
"regexp"
"strings"
)
func escapeFormatString(in string) string {
return strings.ReplaceAll(strings.ReplaceAll(in, "{", "{{"), "}", "}}")
}
func rewriteSubExpression(in string, forceFormat bool) (result string, isExpr bool, err error) {
// missing closing pair is an error
if !strings.Contains(in, "${{") {
return in, false, nil
}
strPattern := regexp.MustCompile("(?:''|[^'])*'")
pos := 0
exprStart := -1
strStart := -1
var results []string
formatOut := ""
for pos < len(in) {
if strStart > -1 {
matches := strPattern.FindStringIndex(in[pos:])
if matches == nil {
return "", false, fmt.Errorf("unclosed string at position %d in %s", pos, in)
}
strStart = -1
pos += matches[1]
} else if exprStart > -1 {
exprEnd := strings.Index(in[pos:], "}}")
strStart = strings.Index(in[pos:], "'")
if exprEnd > -1 && strStart > -1 {
if exprEnd < strStart {
strStart = -1
} else {
exprEnd = -1
}
}
if exprEnd > -1 {
formatOut += fmt.Sprintf("{%d}", len(results))
results = append(results, strings.TrimSpace(in[exprStart:pos+exprEnd]))
pos += exprEnd + 2
exprStart = -1
} else if strStart > -1 {
pos += strStart + 1
} else {
return "", false, fmt.Errorf("unclosed expression at position %d in %s", pos, in)
}
} else {
exprStart = strings.Index(in[pos:], "${{")
if exprStart != -1 {
formatOut += escapeFormatString(in[pos : pos+exprStart])
exprStart = pos + exprStart + 3
pos = exprStart
} else {
formatOut += escapeFormatString(in[pos:])
pos = len(in)
}
}
}
if len(results) == 1 && formatOut == "{0}" && !forceFormat {
return results[0], true, nil
}
out := fmt.Sprintf("format('%s', %s)", strings.ReplaceAll(formatOut, "'", "''"), strings.Join(results, ", "))
return out, true, nil
}

View File

@@ -1,115 +0,0 @@
package templateeval
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestRewriteSubExpression_NoExpression(t *testing.T) {
in := "Hello world"
out, ok, err := rewriteSubExpression(in, false)
assert.NoError(t, err)
if ok {
t.Fatalf("expected ok=false for no expression, got true with output %q", out)
}
if out != in {
t.Fatalf("expected output %q, got %q", in, out)
}
}
func TestRewriteSubExpression_SingleExpression(t *testing.T) {
in := "Hello ${{ 'world' }}"
out, ok, err := rewriteSubExpression(in, false)
assert.NoError(t, err)
if !ok {
t.Fatalf("expected ok=true for single expression, got false")
}
expected := "format('Hello {0}', 'world')"
if out != expected {
t.Fatalf("expected %q, got %q", expected, out)
}
}
func TestRewriteSubExpression_MultipleExpressions(t *testing.T) {
in := "Hello ${{ 'world' }}, you are ${{ 'awesome' }}"
out, ok, err := rewriteSubExpression(in, false)
assert.NoError(t, err)
if !ok {
t.Fatalf("expected ok=true for multiple expressions, got false")
}
expected := "format('Hello {0}, you are {1}', 'world', 'awesome')"
if out != expected {
t.Fatalf("expected %q, got %q", expected, out)
}
}
func TestRewriteSubExpression_ForceFormatSingle(t *testing.T) {
in := "Hello ${{ 'world' }}"
out, ok, err := rewriteSubExpression(in, true)
assert.NoError(t, err)
if !ok {
t.Fatalf("expected ok=true when forceFormat, got false")
}
expected := "format('Hello {0}', 'world')"
if out != expected {
t.Fatalf("expected %q, got %q", expected, out)
}
}
func TestRewriteSubExpression_ForceFormatMultiple(t *testing.T) {
in := "Hello ${{ 'world' }}, you are ${{ 'awesome' }}"
out, ok, err := rewriteSubExpression(in, true)
assert.NoError(t, err)
if !ok {
t.Fatalf("expected ok=true when forceFormat, got false")
}
expected := "format('Hello {0}, you are {1}', 'world', 'awesome')"
if out != expected {
t.Fatalf("expected %q, got %q", expected, out)
}
}
func TestRewriteSubExpression_UnclosedExpression(t *testing.T) {
in := "Hello ${{ 'world' " // missing closing }}
_, _, err := rewriteSubExpression(in, false)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unclosed expression")
}
func TestRewriteSubExpression_UnclosedString(t *testing.T) {
in := "Hello ${{ 'world }}, you are ${{ 'awesome' }}"
_, _, err := rewriteSubExpression(in, false)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unclosed string")
}
func TestRewriteSubExpression_EscapedStringLiteral(t *testing.T) {
// Two single quotes represent an escaped quote inside a string
in := "Hello ${{ 'It''s a test' }}"
out, ok, err := rewriteSubExpression(in, false)
assert.NoError(t, err)
assert.True(t, ok)
expected := "format('Hello {0}', 'It''s a test')"
assert.Equal(t, expected, out)
}
func TestRewriteSubExpression_ExpressionAtEnd(t *testing.T) {
// Expression ends exactly at the string end should be valid
in := "Hello ${{ 'world' }}"
out, ok, err := rewriteSubExpression(in, false)
assert.NoError(t, err)
assert.True(t, ok)
expected := "format('Hello {0}', 'world')"
assert.Equal(t, expected, out)
}
func TestRewriteSubExpression_ExpressionNotAtEnd(t *testing.T) {
// Expression followed by additional text should still be valid
in := "Hello ${{ 'world' }}, how are you?"
out, ok, err := rewriteSubExpression(in, false)
assert.NoError(t, err)
assert.True(t, ok)
expected := "format('Hello {0}, how are you?', 'world')"
assert.Equal(t, expected, out)
}

View File

@@ -1,11 +0,0 @@
package main
import (
"os"
"testing"
)
func TestMain(_ *testing.T) {
os.Args = []string{"act", "--help"}
main()
}

View File

@@ -1,8 +0,0 @@
// Package artifactcache provides a cache handler for the runner.
//
// Inspired by https://github.com/sp-ricard-valverde/github-act-cache-server
//
// TODO: Authorization
// TODO: Restrictions for accessing a cache, see https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#restrictions-for-accessing-a-cache
// TODO: Force deleting cache entries, see https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#force-deleting-cache-entries
package artifactcache

View File

@@ -1,613 +0,0 @@
package artifactcache
import (
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/julienschmidt/httprouter"
"github.com/sirupsen/logrus"
"github.com/timshannon/bolthold"
"go.etcd.io/bbolt"
"github.com/actions-oss/act-cli/pkg/common"
)
const (
urlBase = "/_apis/artifactcache"
)
type Handler struct {
dir string
storage *Storage
router *httprouter.Router
listener net.Listener
server *http.Server
logger logrus.FieldLogger
gcing atomic.Bool
gcAt time.Time
outboundIP string
externalAddress string
}
func StartHandler(dir, outboundIP string, port uint16, logger logrus.FieldLogger) (*Handler, error) {
h := &Handler{}
if logger == nil {
discard := logrus.New()
discard.Out = io.Discard
logger = discard
}
logger = logger.WithField("module", "artifactcache")
h.logger = logger
if dir == "" {
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
dir = filepath.Join(home, ".cache", "actcache")
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
h.dir = dir
storage, err := NewStorage(filepath.Join(dir, "cache"))
if err != nil {
return nil, err
}
h.storage = storage
if outboundIP != "" {
h.outboundIP = outboundIP
} else if ip := common.GetOutboundIP(); ip == nil {
return nil, fmt.Errorf("unable to determine outbound IP address")
} else {
h.outboundIP = ip.String()
}
router := httprouter.New()
router.GET(urlBase+"/cache", h.middleware(h.find))
router.POST(urlBase+"/caches", h.middleware(h.reserve))
router.PATCH(urlBase+"/caches/:id", h.middleware(h.upload))
router.POST(urlBase+"/caches/:id", h.middleware(h.commit))
router.GET(urlBase+"/artifacts/:id", h.middleware(h.get))
router.POST(urlBase+"/clean", h.middleware(h.clean))
h.router = router
h.gcCache()
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) // listen on all interfaces
if err != nil {
return nil, err
}
server := &http.Server{
ReadHeaderTimeout: 2 * time.Second,
Handler: router,
}
go func() {
if err := server.Serve(listener); err != nil && errors.Is(err, net.ErrClosed) {
logger.Errorf("http serve: %v", err)
}
}()
h.listener = listener
h.server = server
return h, nil
}
func CreateHandler(dir, externalAddress string, logger logrus.FieldLogger) (*Handler, http.Handler, error) {
h := &Handler{}
if logger == nil {
discard := logrus.New()
discard.Out = io.Discard
logger = discard
}
logger = logger.WithField("module", "artifactcache")
h.logger = logger
if dir == "" {
home, err := os.UserHomeDir()
if err != nil {
return nil, nil, err
}
dir = filepath.Join(home, ".cache", "actcache")
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, nil, err
}
h.dir = dir
storage, err := NewStorage(filepath.Join(dir, "cache"))
if err != nil {
return nil, nil, err
}
h.storage = storage
if externalAddress != "" {
h.externalAddress = externalAddress
} else if ip := common.GetOutboundIP(); ip == nil {
return nil, nil, fmt.Errorf("unable to determine outbound IP address")
} else {
h.outboundIP = ip.String()
}
router := httprouter.New()
router.GET(urlBase+"/cache", h.middleware(h.find))
router.POST(urlBase+"/caches", h.middleware(h.reserve))
router.PATCH(urlBase+"/caches/:id", h.middleware(h.upload))
router.POST(urlBase+"/caches/:id", h.middleware(h.commit))
router.GET(urlBase+"/artifacts/:id", h.middleware(h.get))
router.POST(urlBase+"/clean", h.middleware(h.clean))
h.router = router
h.gcCache()
return h, router, nil
}
func (h *Handler) ExternalURL() string {
if h.externalAddress != "" {
return h.externalAddress
}
// TODO: make the external url configurable if necessary
return fmt.Sprintf("http://%s:%d",
h.outboundIP,
h.listener.Addr().(*net.TCPAddr).Port)
}
func (h *Handler) Close() error {
if h == nil {
return nil
}
var retErr error
if h.server != nil {
err := h.server.Close()
if err != nil {
retErr = err
}
h.server = nil
}
if h.listener != nil {
err := h.listener.Close()
if errors.Is(err, net.ErrClosed) {
err = nil
}
if err != nil {
retErr = err
}
h.listener = nil
}
return retErr
}
func (h *Handler) openDB() (*bolthold.Store, error) {
return bolthold.Open(filepath.Join(h.dir, "bolt.db"), 0o644, &bolthold.Options{
Encoder: json.Marshal,
Decoder: json.Unmarshal,
Options: &bbolt.Options{
Timeout: 5 * time.Second,
NoGrowSync: bbolt.DefaultOptions.NoGrowSync,
FreelistType: bbolt.DefaultOptions.FreelistType,
},
})
}
// GET /_apis/artifactcache/cache
func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
keys := strings.Split(r.URL.Query().Get("keys"), ",")
// cache keys are case insensitive
for i, key := range keys {
keys[i] = strings.ToLower(key)
}
version := r.URL.Query().Get("version")
db, err := h.openDB()
if err != nil {
h.responseJSON(w, r, 500, err)
return
}
defer db.Close()
cache, err := findCache(db, keys, version)
if err != nil {
h.responseJSON(w, r, 500, err)
return
}
if cache == nil {
h.responseJSON(w, r, 204)
return
}
if ok, err := h.storage.Exist(cache.ID); err != nil {
h.responseJSON(w, r, 500, err)
return
} else if !ok {
_ = db.Delete(cache.ID, cache)
h.responseJSON(w, r, 204)
return
}
h.responseJSON(w, r, 200, map[string]any{
"result": "hit",
"archiveLocation": fmt.Sprintf("%s%s/artifacts/%d", h.ExternalURL(), urlBase, cache.ID),
"cacheKey": cache.Key,
})
}
// POST /_apis/artifactcache/caches
func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
api := &Request{}
if err := json.NewDecoder(r.Body).Decode(api); err != nil {
h.responseJSON(w, r, 400, err)
return
}
// cache keys are case insensitive
api.Key = strings.ToLower(api.Key)
cache := api.ToCache()
db, err := h.openDB()
if err != nil {
h.responseJSON(w, r, 500, err)
return
}
defer db.Close()
now := time.Now().Unix()
cache.CreatedAt = now
cache.UsedAt = now
if err := insertCache(db, cache); err != nil {
h.responseJSON(w, r, 500, err)
return
}
h.responseJSON(w, r, 200, map[string]any{
"cacheId": cache.ID,
})
}
// PATCH /_apis/artifactcache/caches/:id
func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
id, err := strconv.ParseUint(params.ByName("id"), 10, 64)
if err != nil {
h.responseJSON(w, r, 400, err)
return
}
cache := &Cache{}
db, err := h.openDB()
if err != nil {
h.responseJSON(w, r, 500, err)
return
}
defer db.Close()
if err := db.Get(id, cache); err != nil {
if errors.Is(err, bolthold.ErrNotFound) {
h.responseJSON(w, r, 400, fmt.Errorf("cache %d: not reserved", id))
return
}
h.responseJSON(w, r, 500, err)
return
}
if cache.Complete {
h.responseJSON(w, r, 400, fmt.Errorf("cache %v %q: already complete", cache.ID, cache.Key))
return
}
db.Close()
start, _, err := parseContentRange(r.Header.Get("Content-Range"))
if err != nil {
h.responseJSON(w, r, 400, err)
return
}
if err := h.storage.Write(cache.ID, start, r.Body); err != nil {
h.responseJSON(w, r, 500, err)
}
h.useCache(id)
h.responseJSON(w, r, 200)
}
// POST /_apis/artifactcache/caches/:id
func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
if err != nil {
h.responseJSON(w, r, 400, err)
return
}
cache := &Cache{}
db, err := h.openDB()
if err != nil {
h.responseJSON(w, r, 500, err)
return
}
defer db.Close()
if err := db.Get(id, cache); err != nil {
if errors.Is(err, bolthold.ErrNotFound) {
h.responseJSON(w, r, 400, fmt.Errorf("cache %d: not reserved", id))
return
}
h.responseJSON(w, r, 500, err)
return
}
if cache.Complete {
h.responseJSON(w, r, 400, fmt.Errorf("cache %v %q: already complete", cache.ID, cache.Key))
return
}
db.Close()
size, err := h.storage.Commit(cache.ID, cache.Size)
if err != nil {
h.responseJSON(w, r, 500, err)
return
}
// write real size back to cache, it may be different from the current value when the request doesn't specify it.
cache.Size = size
db, err = h.openDB()
if err != nil {
h.responseJSON(w, r, 500, err)
return
}
defer db.Close()
cache.Complete = true
if err := db.Update(cache.ID, cache); err != nil {
h.responseJSON(w, r, 500, err)
return
}
h.responseJSON(w, r, 200)
}
// GET /_apis/artifactcache/artifacts/:id
func (h *Handler) get(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
id, err := strconv.ParseUint(params.ByName("id"), 10, 64)
if err != nil {
h.responseJSON(w, r, 400, err)
return
}
h.useCache(id)
h.storage.Serve(w, r, id)
}
// POST /_apis/artifactcache/clean
func (h *Handler) clean(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
// TODO: don't support force deleting cache entries
// see: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#force-deleting-cache-entries
h.responseJSON(w, r, 200)
}
func (h *Handler) middleware(handler httprouter.Handle) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
h.logger.Debugf("%s %s", r.Method, r.RequestURI)
handler(w, r, params)
go h.gcCache()
}
}
// if not found, return (nil, nil) instead of an error.
func findCache(db *bolthold.Store, keys []string, version string) (*Cache, error) {
cache := &Cache{}
for _, prefix := range keys {
// if a key in the list matches exactly, don't return partial matches
if err := db.FindOne(cache,
bolthold.Where("Key").Eq(prefix).
And("Version").Eq(version).
And("Complete").Eq(true).
SortBy("CreatedAt").Reverse()); err == nil || !errors.Is(err, bolthold.ErrNotFound) {
if err != nil {
return nil, fmt.Errorf("find cache: %w", err)
}
return cache, nil
}
prefixPattern := fmt.Sprintf("^%s", regexp.QuoteMeta(prefix))
re, err := regexp.Compile(prefixPattern)
if err != nil {
continue
}
if err := db.FindOne(cache,
bolthold.Where("Key").RegExp(re).
And("Version").Eq(version).
And("Complete").Eq(true).
SortBy("CreatedAt").Reverse()); err != nil {
if errors.Is(err, bolthold.ErrNotFound) {
continue
}
return nil, fmt.Errorf("find cache: %w", err)
}
return cache, nil
}
return nil, nil
}
func insertCache(db *bolthold.Store, cache *Cache) error {
if err := db.Insert(bolthold.NextSequence(), cache); err != nil {
return fmt.Errorf("insert cache: %w", err)
}
// write back id to db
if err := db.Update(cache.ID, cache); err != nil {
return fmt.Errorf("write back id to db: %w", err)
}
return nil
}
func (h *Handler) useCache(id uint64) {
db, err := h.openDB()
if err != nil {
return
}
defer db.Close()
cache := &Cache{}
if err := db.Get(id, cache); err != nil {
return
}
cache.UsedAt = time.Now().Unix()
_ = db.Update(cache.ID, cache)
}
const (
keepUsed = 30 * 24 * time.Hour
keepUnused = 7 * 24 * time.Hour
keepTemp = 5 * time.Minute
keepOld = 5 * time.Minute
)
func (h *Handler) gcCache() {
if h.gcing.Load() {
return
}
if !h.gcing.CompareAndSwap(false, true) {
return
}
defer h.gcing.Store(false)
if time.Since(h.gcAt) < time.Hour {
h.logger.Debugf("skip gc: %v", h.gcAt.String())
return
}
h.gcAt = time.Now()
h.logger.Debugf("gc: %v", h.gcAt.String())
db, err := h.openDB()
if err != nil {
return
}
defer db.Close()
// Remove the caches which are not completed for a while, they are most likely to be broken.
var caches []*Cache
if err := db.Find(&caches, bolthold.
Where("UsedAt").Lt(time.Now().Add(-keepTemp).Unix()).
And("Complete").Eq(false),
); err != nil {
h.logger.Warnf("find caches: %v", err)
} else {
for _, cache := range caches {
h.storage.Remove(cache.ID)
if err := db.Delete(cache.ID, cache); err != nil {
h.logger.Warnf("delete cache: %v", err)
continue
}
h.logger.Infof("deleted cache: %+v", cache)
}
}
// Remove the old caches which have not been used recently.
caches = caches[:0]
if err := db.Find(&caches, bolthold.
Where("UsedAt").Lt(time.Now().Add(-keepUnused).Unix()),
); err != nil {
h.logger.Warnf("find caches: %v", err)
} else {
for _, cache := range caches {
h.storage.Remove(cache.ID)
if err := db.Delete(cache.ID, cache); err != nil {
h.logger.Warnf("delete cache: %v", err)
continue
}
h.logger.Infof("deleted cache: %+v", cache)
}
}
// Remove the old caches which are too old.
caches = caches[:0]
if err := db.Find(&caches, bolthold.
Where("CreatedAt").Lt(time.Now().Add(-keepUsed).Unix()),
); err != nil {
h.logger.Warnf("find caches: %v", err)
} else {
for _, cache := range caches {
h.storage.Remove(cache.ID)
if err := db.Delete(cache.ID, cache); err != nil {
h.logger.Warnf("delete cache: %v", err)
continue
}
h.logger.Infof("deleted cache: %+v", cache)
}
}
// Remove the old caches with the same key and version, keep the latest one.
// Also keep the olds which have been used recently for a while in case of the cache is still in use.
if results, err := db.FindAggregate(
&Cache{},
bolthold.Where("Complete").Eq(true),
"Key", "Version",
); err != nil {
h.logger.Warnf("find aggregate caches: %v", err)
} else {
for _, result := range results {
if result.Count() <= 1 {
continue
}
result.Sort("CreatedAt")
caches = caches[:0]
result.Reduction(&caches)
for _, cache := range caches[:len(caches)-1] {
if time.Since(time.Unix(cache.UsedAt, 0)) < keepOld {
// Keep it since it has been used recently, even if it's old.
// Or it could break downloading in process.
continue
}
h.storage.Remove(cache.ID)
if err := db.Delete(cache.ID, cache); err != nil {
h.logger.Warnf("delete cache: %v", err)
continue
}
h.logger.Infof("deleted cache: %+v", cache)
}
}
}
}
func (h *Handler) responseJSON(w http.ResponseWriter, r *http.Request, code int, v ...any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
var data []byte
if len(v) == 0 || v[0] == nil {
data, _ = json.Marshal(struct{}{})
} else if err, ok := v[0].(error); ok {
h.logger.Errorf("%v %v: %v", r.Method, r.RequestURI, err)
data, _ = json.Marshal(map[string]any{
"error": err.Error(),
})
} else {
data, _ = json.Marshal(v[0])
}
w.WriteHeader(code)
_, _ = w.Write(data)
}
func parseContentRange(s string) (int64, int64, error) {
// support the format like "bytes 11-22/*" only
s, _, _ = strings.Cut(strings.TrimPrefix(s, "bytes "), "/")
s1, s2, _ := strings.Cut(s, "-")
start, err := strconv.ParseInt(s1, 10, 64)
if err != nil {
return 0, 0, fmt.Errorf("parse %q: %w", s, err)
}
stop, err := strconv.ParseInt(s2, 10, 64)
if err != nil {
return 0, 0, fmt.Errorf("parse %q: %w", s, err)
}
return start, stop, nil
}

View File

@@ -1,707 +0,0 @@
package artifactcache
import (
"bytes"
"crypto/rand"
"encoding/json"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/timshannon/bolthold"
"go.etcd.io/bbolt"
)
func TestHandler(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, nil)
require.NoError(t, err)
base := fmt.Sprintf("%s%s", handler.ExternalURL(), urlBase)
defer func() {
t.Run("inpect db", func(t *testing.T) {
db, err := handler.openDB()
require.NoError(t, err)
defer db.Close()
require.NoError(t, db.Bolt().View(func(tx *bbolt.Tx) error {
return tx.Bucket([]byte("Cache")).ForEach(func(k, v []byte) error {
t.Logf("%s: %s", k, v)
return nil
})
}))
})
t.Run("close", func(t *testing.T) {
require.NoError(t, handler.Close())
assert.Nil(t, handler.server)
assert.Nil(t, handler.listener)
_, err := http.Post(fmt.Sprintf("%s/caches/%d", base, 1), "", nil)
assert.Error(t, err)
})
}()
t.Run("get not exist", func(t *testing.T) {
key := strings.ToLower(t.Name())
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
require.NoError(t, err)
require.Equal(t, 204, resp.StatusCode)
})
t.Run("reserve and upload", func(t *testing.T) {
key := strings.ToLower(t.Name())
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
content := make([]byte, 100)
_, err := rand.Read(content)
require.NoError(t, err)
uploadCacheNormally(t, base, key, version, content)
})
t.Run("clean", func(t *testing.T) {
resp, err := http.Post(fmt.Sprintf("%s/clean", base), "", nil)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
})
t.Run("reserve with bad request", func(t *testing.T) {
body := []byte(`invalid json`)
require.NoError(t, err)
resp, err := http.Post(fmt.Sprintf("%s/caches", base), "application/json", bytes.NewReader(body))
require.NoError(t, err)
assert.Equal(t, 400, resp.StatusCode)
})
t.Run("duplicate reserve", func(t *testing.T) {
key := strings.ToLower(t.Name())
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
var first, second struct {
CacheID uint64 `json:"cacheId"`
}
{
body, err := json.Marshal(&Request{
Key: key,
Version: version,
Size: 100,
})
require.NoError(t, err)
resp, err := http.Post(fmt.Sprintf("%s/caches", base), "application/json", bytes.NewReader(body))
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
require.NoError(t, json.NewDecoder(resp.Body).Decode(&first))
assert.NotZero(t, first.CacheID)
}
{
body, err := json.Marshal(&Request{
Key: key,
Version: version,
Size: 100,
})
require.NoError(t, err)
resp, err := http.Post(fmt.Sprintf("%s/caches", base), "application/json", bytes.NewReader(body))
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
require.NoError(t, json.NewDecoder(resp.Body).Decode(&second))
assert.NotZero(t, second.CacheID)
}
assert.NotEqual(t, first.CacheID, second.CacheID)
})
t.Run("upload with bad id", func(t *testing.T) {
req, err := http.NewRequest(http.MethodPatch,
fmt.Sprintf("%s/caches/invalid_id", base), bytes.NewReader(nil))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Range", "bytes 0-99/*")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
assert.Equal(t, 400, resp.StatusCode)
})
t.Run("upload without reserve", func(t *testing.T) {
req, err := http.NewRequest(http.MethodPatch,
fmt.Sprintf("%s/caches/%d", base, 1000), bytes.NewReader(nil))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Range", "bytes 0-99/*")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
assert.Equal(t, 400, resp.StatusCode)
})
t.Run("upload with complete", func(t *testing.T) {
key := strings.ToLower(t.Name())
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
var id uint64
content := make([]byte, 100)
_, err := rand.Read(content)
require.NoError(t, err)
{
body, err := json.Marshal(&Request{
Key: key,
Version: version,
Size: 100,
})
require.NoError(t, err)
resp, err := http.Post(fmt.Sprintf("%s/caches", base), "application/json", bytes.NewReader(body))
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
got := struct {
CacheID uint64 `json:"cacheId"`
}{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
id = got.CacheID
}
{
req, err := http.NewRequest(http.MethodPatch,
fmt.Sprintf("%s/caches/%d", base, id), bytes.NewReader(content))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Range", "bytes 0-99/*")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
}
{
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
}
{
req, err := http.NewRequest(http.MethodPatch,
fmt.Sprintf("%s/caches/%d", base, id), bytes.NewReader(content))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Range", "bytes 0-99/*")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
assert.Equal(t, 400, resp.StatusCode)
}
})
t.Run("upload with invalid range", func(t *testing.T) {
key := strings.ToLower(t.Name())
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
var id uint64
content := make([]byte, 100)
_, err := rand.Read(content)
require.NoError(t, err)
{
body, err := json.Marshal(&Request{
Key: key,
Version: version,
Size: 100,
})
require.NoError(t, err)
resp, err := http.Post(fmt.Sprintf("%s/caches", base), "application/json", bytes.NewReader(body))
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
got := struct {
CacheID uint64 `json:"cacheId"`
}{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
id = got.CacheID
}
{
req, err := http.NewRequest(http.MethodPatch,
fmt.Sprintf("%s/caches/%d", base, id), bytes.NewReader(content))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Range", "bytes xx-99/*")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
assert.Equal(t, 400, resp.StatusCode)
}
})
t.Run("commit with bad id", func(t *testing.T) {
{
resp, err := http.Post(fmt.Sprintf("%s/caches/invalid_id", base), "", nil)
require.NoError(t, err)
assert.Equal(t, 400, resp.StatusCode)
}
})
t.Run("commit with not exist id", func(t *testing.T) {
{
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, 100), "", nil)
require.NoError(t, err)
assert.Equal(t, 400, resp.StatusCode)
}
})
t.Run("duplicate commit", func(t *testing.T) {
key := strings.ToLower(t.Name())
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
var id uint64
content := make([]byte, 100)
_, err := rand.Read(content)
require.NoError(t, err)
{
body, err := json.Marshal(&Request{
Key: key,
Version: version,
Size: 100,
})
require.NoError(t, err)
resp, err := http.Post(fmt.Sprintf("%s/caches", base), "application/json", bytes.NewReader(body))
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
got := struct {
CacheID uint64 `json:"cacheId"`
}{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
id = got.CacheID
}
{
req, err := http.NewRequest(http.MethodPatch,
fmt.Sprintf("%s/caches/%d", base, id), bytes.NewReader(content))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Range", "bytes 0-99/*")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
}
{
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
}
{
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil)
require.NoError(t, err)
assert.Equal(t, 400, resp.StatusCode)
}
})
t.Run("commit early", func(t *testing.T) {
key := strings.ToLower(t.Name())
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
var id uint64
content := make([]byte, 100)
_, err := rand.Read(content)
require.NoError(t, err)
{
body, err := json.Marshal(&Request{
Key: key,
Version: version,
Size: 100,
})
require.NoError(t, err)
resp, err := http.Post(fmt.Sprintf("%s/caches", base), "application/json", bytes.NewReader(body))
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
got := struct {
CacheID uint64 `json:"cacheId"`
}{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
id = got.CacheID
}
{
req, err := http.NewRequest(http.MethodPatch,
fmt.Sprintf("%s/caches/%d", base, id), bytes.NewReader(content[:50]))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Range", "bytes 0-59/*")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
}
{
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil)
require.NoError(t, err)
assert.Equal(t, 500, resp.StatusCode)
}
})
t.Run("get with bad id", func(t *testing.T) {
resp, err := http.Get(fmt.Sprintf("%s/artifacts/invalid_id", base))
require.NoError(t, err)
require.Equal(t, 400, resp.StatusCode)
})
t.Run("get with not exist id", func(t *testing.T) {
resp, err := http.Get(fmt.Sprintf("%s/artifacts/%d", base, 100))
require.NoError(t, err)
require.Equal(t, 404, resp.StatusCode)
})
t.Run("get with not exist id", func(t *testing.T) {
resp, err := http.Get(fmt.Sprintf("%s/artifacts/%d", base, 100))
require.NoError(t, err)
require.Equal(t, 404, resp.StatusCode)
})
t.Run("get with multiple keys", func(t *testing.T) {
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
key := strings.ToLower(t.Name())
keys := [3]string{
key + "_a_b_c",
key + "_a_b",
key + "_a",
}
contents := [3][]byte{
make([]byte, 100),
make([]byte, 200),
make([]byte, 300),
}
for i := range contents {
_, err := rand.Read(contents[i])
require.NoError(t, err)
uploadCacheNormally(t, base, keys[i], version, contents[i])
time.Sleep(time.Second) // ensure CreatedAt of caches are different
}
reqKeys := strings.Join([]string{
key + "_a_b_x",
key + "_a_b",
key + "_a",
}, ",")
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version))
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode)
/*
Expect `key_a_b` because:
- `key_a_b_x" doesn't match any caches.
- `key_a_b" matches `key_a_b` and `key_a_b_c`, but `key_a_b` is newer.
*/
except := 1
got := struct {
Result string `json:"result"`
ArchiveLocation string `json:"archiveLocation"`
CacheKey string `json:"cacheKey"`
}{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
assert.Equal(t, "hit", got.Result)
assert.Equal(t, keys[except], got.CacheKey)
contentResp, err := http.Get(got.ArchiveLocation)
require.NoError(t, err)
require.Equal(t, 200, contentResp.StatusCode)
content, err := io.ReadAll(contentResp.Body)
require.NoError(t, err)
assert.Equal(t, contents[except], content)
})
t.Run("case insensitive", func(t *testing.T) {
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
key := strings.ToLower(t.Name())
content := make([]byte, 100)
_, err := rand.Read(content)
require.NoError(t, err)
uploadCacheNormally(t, base, key+"_ABC", version, content)
{
reqKey := key + "_aBc"
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKey, version))
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode)
got := struct {
Result string `json:"result"`
ArchiveLocation string `json:"archiveLocation"`
CacheKey string `json:"cacheKey"`
}{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
assert.Equal(t, "hit", got.Result)
assert.Equal(t, key+"_abc", got.CacheKey)
}
})
t.Run("exact keys are preferred (key 0)", func(t *testing.T) {
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
key := strings.ToLower(t.Name())
keys := [3]string{
key + "_a",
key + "_a_b_c",
key + "_a_b",
}
contents := [3][]byte{
make([]byte, 100),
make([]byte, 200),
make([]byte, 300),
}
for i := range contents {
_, err := rand.Read(contents[i])
require.NoError(t, err)
uploadCacheNormally(t, base, keys[i], version, contents[i])
time.Sleep(time.Second) // ensure CreatedAt of caches are different
}
reqKeys := strings.Join([]string{
key + "_a",
key + "_a_b",
}, ",")
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version))
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode)
/*
Expect `key_a` because:
- `key_a` matches `key_a`, `key_a_b` and `key_a_b_c`, but `key_a` is an exact match.
- `key_a_b` matches `key_a_b` and `key_a_b_c`, but previous key had a match
*/
expect := 0
got := struct {
ArchiveLocation string `json:"archiveLocation"`
CacheKey string `json:"cacheKey"`
}{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
assert.Equal(t, keys[expect], got.CacheKey)
contentResp, err := http.Get(got.ArchiveLocation)
require.NoError(t, err)
require.Equal(t, 200, contentResp.StatusCode)
content, err := io.ReadAll(contentResp.Body)
require.NoError(t, err)
assert.Equal(t, contents[expect], content)
})
t.Run("exact keys are preferred (key 1)", func(t *testing.T) {
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
key := strings.ToLower(t.Name())
keys := [3]string{
key + "_a",
key + "_a_b_c",
key + "_a_b",
}
contents := [3][]byte{
make([]byte, 100),
make([]byte, 200),
make([]byte, 300),
}
for i := range contents {
_, err := rand.Read(contents[i])
require.NoError(t, err)
uploadCacheNormally(t, base, keys[i], version, contents[i])
time.Sleep(time.Second) // ensure CreatedAt of caches are different
}
reqKeys := strings.Join([]string{
"------------------------------------------------------",
key + "_a",
key + "_a_b",
}, ",")
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version))
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode)
/*
Expect `key_a` because:
- `------------------------------------------------------` doesn't match any caches.
- `key_a` matches `key_a`, `key_a_b` and `key_a_b_c`, but `key_a` is an exact match.
- `key_a_b` matches `key_a_b` and `key_a_b_c`, but previous key had a match
*/
expect := 0
got := struct {
ArchiveLocation string `json:"archiveLocation"`
CacheKey string `json:"cacheKey"`
}{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
assert.Equal(t, keys[expect], got.CacheKey)
contentResp, err := http.Get(got.ArchiveLocation)
require.NoError(t, err)
require.Equal(t, 200, contentResp.StatusCode)
content, err := io.ReadAll(contentResp.Body)
require.NoError(t, err)
assert.Equal(t, contents[expect], content)
})
}
func uploadCacheNormally(t *testing.T, base, key, version string, content []byte) {
var id uint64
{
body, err := json.Marshal(&Request{
Key: key,
Version: version,
Size: int64(len(content)),
})
require.NoError(t, err)
resp, err := http.Post(fmt.Sprintf("%s/caches", base), "application/json", bytes.NewReader(body))
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
got := struct {
CacheID uint64 `json:"cacheId"`
}{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
id = got.CacheID
}
{
req, err := http.NewRequest(http.MethodPatch,
fmt.Sprintf("%s/caches/%d", base, id), bytes.NewReader(content))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Range", "bytes 0-99/*")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
}
{
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
}
var archiveLocation string
{
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode)
got := struct {
Result string `json:"result"`
ArchiveLocation string `json:"archiveLocation"`
CacheKey string `json:"cacheKey"`
}{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
assert.Equal(t, "hit", got.Result)
assert.Equal(t, strings.ToLower(key), got.CacheKey)
archiveLocation = got.ArchiveLocation
}
{
resp, err := http.Get(archiveLocation) //nolint:gosec
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode)
got, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Equal(t, content, got)
}
}
func TestHandler_gcCache(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, nil)
require.NoError(t, err)
defer func() {
require.NoError(t, handler.Close())
}()
now := time.Now()
cases := []struct {
Cache *Cache
Kept bool
}{
{
// should be kept, since it's used recently and not too old.
Cache: &Cache{
Key: "test_key_1",
Version: "test_version",
Complete: true,
UsedAt: now.Unix(),
CreatedAt: now.Add(-time.Hour).Unix(),
},
Kept: true,
},
{
// should be removed, since it's not complete and not used for a while.
Cache: &Cache{
Key: "test_key_2",
Version: "test_version",
Complete: false,
UsedAt: now.Add(-(keepTemp + time.Second)).Unix(),
CreatedAt: now.Add(-(keepTemp + time.Hour)).Unix(),
},
Kept: false,
},
{
// should be removed, since it's not used for a while.
Cache: &Cache{
Key: "test_key_3",
Version: "test_version",
Complete: true,
UsedAt: now.Add(-(keepUnused + time.Second)).Unix(),
CreatedAt: now.Add(-(keepUnused + time.Hour)).Unix(),
},
Kept: false,
},
{
// should be removed, since it's used but too old.
Cache: &Cache{
Key: "test_key_3",
Version: "test_version",
Complete: true,
UsedAt: now.Unix(),
CreatedAt: now.Add(-(keepUsed + time.Second)).Unix(),
},
Kept: false,
},
{
// should be kept, since it has a newer edition but be used recently.
Cache: &Cache{
Key: "test_key_1",
Version: "test_version",
Complete: true,
UsedAt: now.Add(-(keepOld - time.Minute)).Unix(),
CreatedAt: now.Add(-(time.Hour + time.Second)).Unix(),
},
Kept: true,
},
{
// should be removed, since it has a newer edition and not be used recently.
Cache: &Cache{
Key: "test_key_1",
Version: "test_version",
Complete: true,
UsedAt: now.Add(-(keepOld + time.Second)).Unix(),
CreatedAt: now.Add(-(time.Hour + time.Second)).Unix(),
},
Kept: false,
},
}
db, err := handler.openDB()
require.NoError(t, err)
for _, c := range cases {
require.NoError(t, insertCache(db, c.Cache))
}
require.NoError(t, db.Close())
handler.gcAt = time.Time{} // ensure gcCache will not skip
handler.gcCache()
db, err = handler.openDB()
require.NoError(t, err)
for i, v := range cases {
t.Run(fmt.Sprintf("%d_%s", i, v.Cache.Key), func(t *testing.T) {
cache := &Cache{}
err = db.Get(v.Cache.ID, cache)
if v.Kept {
assert.NoError(t, err)
} else {
assert.ErrorIs(t, err, bolthold.ErrNotFound)
}
})
}
require.NoError(t, db.Close())
}
func TestCreateHandler(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, router, err := CreateHandler(dir, "http://localhost:8080", nil)
require.NoError(t, err)
require.NotNil(t, handler)
require.NotNil(t, router)
require.Equal(t, "http://localhost:8080", handler.ExternalURL())
}

View File

@@ -1,34 +0,0 @@
package artifactcache
type Request struct {
Key string `json:"key" `
Version string `json:"version"`
Size int64 `json:"cacheSize"`
}
func (c *Request) ToCache() *Cache {
if c == nil {
return nil
}
ret := &Cache{
Key: c.Key,
Version: c.Version,
Size: c.Size,
}
if c.Size == 0 {
// So the request comes from old versions of actions, like `actions/cache@v2`.
// It doesn't send cache size. Set it to -1 to indicate that.
ret.Size = -1
}
return ret
}
type Cache struct {
ID uint64 `json:"id" boltholdKey:"ID"`
Key string `json:"key" boltholdIndex:"Key"`
Version string `json:"version" boltholdIndex:"Version"`
Size int64 `json:"cacheSize"`
Complete bool `json:"complete" boltholdIndex:"Complete"`
UsedAt int64 `json:"usedAt" boltholdIndex:"UsedAt"`
CreatedAt int64 `json:"createdAt" boltholdIndex:"CreatedAt"`
}

View File

@@ -1,130 +0,0 @@
package artifactcache
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
)
type Storage struct {
rootDir string
}
func NewStorage(rootDir string) (*Storage, error) {
if err := os.MkdirAll(rootDir, 0o755); err != nil {
return nil, err
}
return &Storage{
rootDir: rootDir,
}, nil
}
func (s *Storage) Exist(id uint64) (bool, error) {
name := s.filename(id)
if _, err := os.Stat(name); os.IsNotExist(err) {
return false, nil
} else if err != nil {
return false, err
}
return true, nil
}
func (s *Storage) Write(id uint64, offset int64, reader io.Reader) error {
name := s.tempName(id, offset)
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
return err
}
file, err := os.Create(name)
if err != nil {
return err
}
defer file.Close()
_, err = io.Copy(file, reader)
return err
}
func (s *Storage) Commit(id uint64, size int64) (int64, error) {
defer func() {
_ = os.RemoveAll(s.tempDir(id))
}()
name := s.filename(id)
tempNames, err := s.tempNames(id)
if err != nil {
return 0, err
}
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
return 0, err
}
file, err := os.Create(name)
if err != nil {
return 0, err
}
defer file.Close()
var written int64
for _, v := range tempNames {
f, err := os.Open(v)
if err != nil {
return 0, err
}
n, err := io.Copy(file, f)
_ = f.Close()
if err != nil {
return 0, err
}
written += n
}
// If size is less than 0, it means the size is unknown.
// We can't check the size of the file, just skip the check.
// It happens when the request comes from old versions of actions, like `actions/cache@v2`.
if size >= 0 && written != size {
_ = file.Close()
_ = os.Remove(name)
return 0, fmt.Errorf("broken file: %v != %v", written, size)
}
return written, nil
}
func (s *Storage) Serve(w http.ResponseWriter, r *http.Request, id uint64) {
name := s.filename(id)
http.ServeFile(w, r, name)
}
func (s *Storage) Remove(id uint64) {
_ = os.Remove(s.filename(id))
_ = os.RemoveAll(s.tempDir(id))
}
func (s *Storage) filename(id uint64) string {
return filepath.Join(s.rootDir, fmt.Sprintf("%02x", id%0xff), fmt.Sprint(id))
}
func (s *Storage) tempDir(id uint64) string {
return filepath.Join(s.rootDir, "tmp", fmt.Sprint(id))
}
func (s *Storage) tempName(id uint64, offset int64) string {
return filepath.Join(s.tempDir(id), fmt.Sprintf("%016x", offset))
}
func (s *Storage) tempNames(id uint64) ([]string, error) {
dir := s.tempDir(id)
files, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var names []string
for _, v := range files {
if !v.IsDir() {
names = append(names, filepath.Join(dir, v.Name()))
}
}
return names, nil
}

View File

@@ -1,30 +0,0 @@
# Copied from https://github.com/actions/cache#example-cache-workflow
name: Caching Primes
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: env
- uses: actions/checkout@v3
- name: Cache Primes
id: cache-primes
uses: actions/cache@v3
with:
path: prime-numbers
key: ${{ runner.os }}-primes-${{ github.run_id }}
restore-keys: |
${{ runner.os }}-primes
${{ runner.os }}
- name: Generate Prime Numbers
if: steps.cache-primes.outputs.cache-hit != 'true'
run: cat /proc/sys/kernel/random/uuid > prime-numbers
- name: Use Prime Numbers
run: cat prime-numbers

File diff suppressed because it is too large Load Diff

View File

@@ -1,456 +0,0 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package artifacts
// GitHub Actions Artifacts V4 API Simple Description
//
// 1. Upload artifact
// 1.1. CreateArtifact
// Post: /twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact
// Request:
// {
// "workflow_run_backend_id": "21",
// "workflow_job_run_backend_id": "49",
// "name": "test",
// "version": 4
// }
// Response:
// {
// "ok": true,
// "signedUploadUrl": "http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=mO7y35r4GyjN7fwg0DTv3-Fv1NDXD84KLEgLpoPOtDI=&expires=2024-01-23+21%3A48%3A37.20833956+%2B0100+CET&artifactName=test&taskID=75"
// }
// 1.2. Upload Zip Content to Blobstorage (unauthenticated request)
// PUT: http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=mO7y35r4GyjN7fwg0DTv3-Fv1NDXD84KLEgLpoPOtDI=&expires=2024-01-23+21%3A48%3A37.20833956+%2B0100+CET&artifactName=test&taskID=75&comp=block
// 1.3. Continue Upload Zip Content to Blobstorage (unauthenticated request), repeat until everything is uploaded
// PUT: http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=mO7y35r4GyjN7fwg0DTv3-Fv1NDXD84KLEgLpoPOtDI=&expires=2024-01-23+21%3A48%3A37.20833956+%2B0100+CET&artifactName=test&taskID=75&comp=appendBlock
// 1.4. Unknown xml payload to Blobstorage (unauthenticated request), ignored for now
// PUT: http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=mO7y35r4GyjN7fwg0DTv3-Fv1NDXD84KLEgLpoPOtDI=&expires=2024-01-23+21%3A48%3A37.20833956+%2B0100+CET&artifactName=test&taskID=75&comp=blockList
// 1.5. FinalizeArtifact
// Post: /twirp/github.actions.results.api.v1.ArtifactService/FinalizeArtifact
// Request
// {
// "workflow_run_backend_id": "21",
// "workflow_job_run_backend_id": "49",
// "name": "test",
// "size": "2097",
// "hash": "sha256:b6325614d5649338b87215d9536b3c0477729b8638994c74cdefacb020a2cad4"
// }
// Response
// {
// "ok": true,
// "artifactId": "4"
// }
// 2. Download artifact
// 2.1. ListArtifacts and optionally filter by artifact exact name or id
// Post: /twirp/github.actions.results.api.v1.ArtifactService/ListArtifacts
// Request
// {
// "workflow_run_backend_id": "21",
// "workflow_job_run_backend_id": "49",
// "name_filter": "test"
// }
// Response
// {
// "artifacts": [
// {
// "workflowRunBackendId": "21",
// "workflowJobRunBackendId": "49",
// "databaseId": "4",
// "name": "test",
// "size": "2093",
// "createdAt": "2024-01-23T00:13:28Z"
// }
// ]
// }
// 2.2. GetSignedArtifactURL get the URL to download the artifact zip file of a specific artifact
// Post: /twirp/github.actions.results.api.v1.ArtifactService/GetSignedArtifactURL
// Request
// {
// "workflow_run_backend_id": "21",
// "workflow_job_run_backend_id": "49",
// "name": "test"
// }
// Response
// {
// "signedUrl": "http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/DownloadArtifact?sig=wHzFOwpF-6220-5CA0CIRmAX9VbiTC2Mji89UOqo1E8=&expires=2024-01-23+21%3A51%3A56.872846295+%2B0100+CET&artifactName=test&taskID=76"
// }
// 2.3. Download Zip from Blobstorage (unauthenticated request)
// GET: http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/DownloadArtifact?sig=wHzFOwpF-6220-5CA0CIRmAX9VbiTC2Mji89UOqo1E8=&expires=2024-01-23+21%3A51%3A56.872846295+%2B0100+CET&artifactName=test&taskID=76
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"hash/fnv"
"io"
"io/fs"
"net/http"
"net/url"
"os"
"path"
"strconv"
"strings"
"time"
"github.com/julienschmidt/httprouter"
log "github.com/sirupsen/logrus"
"google.golang.org/protobuf/encoding/protojson"
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/types/known/timestamppb"
)
const (
ArtifactV4RouteBase = "/twirp/github.actions.results.api.v1.ArtifactService"
ArtifactV4ContentEncoding = "application/zip"
)
type artifactV4Routes struct {
prefix string
fs WriteFS
rfs fs.FS
AppURL string
baseDir string
}
type ArtifactContext struct {
Req *http.Request
Resp http.ResponseWriter
}
func artifactNameToID(s string) int64 {
h := fnv.New32a()
h.Write([]byte(s))
return int64(h.Sum32())
}
func (c ArtifactContext) Error(status int, _ ...interface{}) {
c.Resp.WriteHeader(status)
}
func (c ArtifactContext) JSON(status int, _ ...interface{}) {
c.Resp.WriteHeader(status)
}
func validateRunIDV4(ctx *ArtifactContext, rawRunID string) (interface{}, int64, bool) {
runID, err := strconv.ParseInt(rawRunID, 10, 64)
if err != nil /* || task.Job.RunID != runID*/ {
log.Error("Error runID not match")
ctx.Error(http.StatusBadRequest, "run-id does not match")
return nil, 0, false
}
return nil, runID, true
}
func RoutesV4(router *httprouter.Router, baseDir string, fsys WriteFS, rfs fs.FS) {
route := &artifactV4Routes{
fs: fsys,
rfs: rfs,
baseDir: baseDir,
prefix: ArtifactV4RouteBase,
}
router.POST(path.Join(ArtifactV4RouteBase, "CreateArtifact"), func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
route.AppURL = r.Host
route.createArtifact(&ArtifactContext{
Req: r,
Resp: w,
})
})
router.POST(path.Join(ArtifactV4RouteBase, "FinalizeArtifact"), func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
route.finalizeArtifact(&ArtifactContext{
Req: r,
Resp: w,
})
})
router.POST(path.Join(ArtifactV4RouteBase, "ListArtifacts"), func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
route.listArtifacts(&ArtifactContext{
Req: r,
Resp: w,
})
})
router.POST(path.Join(ArtifactV4RouteBase, "GetSignedArtifactURL"), func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
route.AppURL = r.Host
route.getSignedArtifactURL(&ArtifactContext{
Req: r,
Resp: w,
})
})
router.POST(path.Join(ArtifactV4RouteBase, "DeleteArtifact"), func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
route.AppURL = r.Host
route.deleteArtifact(&ArtifactContext{
Req: r,
Resp: w,
})
})
router.PUT(path.Join(ArtifactV4RouteBase, "UploadArtifact"), func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
route.uploadArtifact(&ArtifactContext{
Req: r,
Resp: w,
})
})
router.GET(path.Join(ArtifactV4RouteBase, "DownloadArtifact"), func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
route.downloadArtifact(&ArtifactContext{
Req: r,
Resp: w,
})
})
}
func (r artifactV4Routes) buildSignature(endp, expires, artifactName string, taskID int64) []byte {
mac := hmac.New(sha256.New, []byte{0xba, 0xdb, 0xee, 0xf0})
mac.Write([]byte(endp))
mac.Write([]byte(expires))
mac.Write([]byte(artifactName))
fmt.Fprint(mac, taskID)
return mac.Sum(nil)
}
func (r artifactV4Routes) buildArtifactURL(endp, artifactName string, taskID int64) string {
expires := time.Now().Add(60 * time.Minute).Format("2006-01-02 15:04:05.999999999 -0700 MST")
uploadURL := "http://" + strings.TrimSuffix(r.AppURL, "/") + strings.TrimSuffix(r.prefix, "/") +
"/" + endp + "?sig=" + base64.URLEncoding.EncodeToString(r.buildSignature(endp, expires, artifactName, taskID)) + "&expires=" + url.QueryEscape(expires) + "&artifactName=" + url.QueryEscape(artifactName) + "&taskID=" + fmt.Sprint(taskID)
return uploadURL
}
func (r artifactV4Routes) verifySignature(ctx *ArtifactContext, endp string) (int64, string, bool) {
rawTaskID := ctx.Req.URL.Query().Get("taskID")
sig := ctx.Req.URL.Query().Get("sig")
expires := ctx.Req.URL.Query().Get("expires")
artifactName := ctx.Req.URL.Query().Get("artifactName")
dsig, _ := base64.URLEncoding.DecodeString(sig)
taskID, _ := strconv.ParseInt(rawTaskID, 10, 64)
expecedsig := r.buildSignature(endp, expires, artifactName, taskID)
if !hmac.Equal(dsig, expecedsig) {
log.Error("Error unauthorized")
ctx.Error(http.StatusUnauthorized, "Error unauthorized")
return -1, "", false
}
t, err := time.Parse("2006-01-02 15:04:05.999999999 -0700 MST", expires)
if err != nil || t.Before(time.Now()) {
log.Error("Error link expired")
ctx.Error(http.StatusUnauthorized, "Error link expired")
return -1, "", false
}
return taskID, artifactName, true
}
func (r *artifactV4Routes) parseProtbufBody(ctx *ArtifactContext, req protoreflect.ProtoMessage) bool {
body, err := io.ReadAll(ctx.Req.Body)
if err != nil {
log.Errorf("error decode request body: %v", err)
ctx.Error(http.StatusInternalServerError, "Error decode request body")
return false
}
err = protojson.Unmarshal(body, req)
if err != nil {
log.Errorf("error decode request body: %v", err)
ctx.Error(http.StatusInternalServerError, "Error decode request body")
return false
}
return true
}
func (r *artifactV4Routes) sendProtbufBody(ctx *ArtifactContext, req protoreflect.ProtoMessage) {
resp, err := protojson.Marshal(req)
if err != nil {
log.Errorf("error encode response body: %v", err)
ctx.Error(http.StatusInternalServerError, "Error encode response body")
return
}
ctx.Resp.Header().Set("Content-Type", "application/json;charset=utf-8")
ctx.Resp.WriteHeader(http.StatusOK)
_, _ = ctx.Resp.Write(resp)
}
func (r *artifactV4Routes) createArtifact(ctx *ArtifactContext) {
var req CreateArtifactRequest
if ok := r.parseProtbufBody(ctx, &req); !ok {
return
}
_, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
if !ok {
return
}
artifactName := req.Name
safeRunPath := safeResolve(r.baseDir, fmt.Sprint(runID))
safePath := safeResolve(safeRunPath, artifactName)
safePath = safeResolve(safePath, artifactName+".zip")
file, err := r.fs.OpenWritable(safePath)
if err != nil {
panic(err)
}
file.Close()
respData := CreateArtifactResponse{
Ok: true,
SignedUploadUrl: r.buildArtifactURL("UploadArtifact", artifactName, runID),
}
r.sendProtbufBody(ctx, &respData)
}
func (r *artifactV4Routes) uploadArtifact(ctx *ArtifactContext) {
task, artifactName, ok := r.verifySignature(ctx, "UploadArtifact")
if !ok {
return
}
comp := ctx.Req.URL.Query().Get("comp")
switch comp {
case "block", "appendBlock":
safeRunPath := safeResolve(r.baseDir, fmt.Sprint(task))
safePath := safeResolve(safeRunPath, artifactName)
safePath = safeResolve(safePath, artifactName+".zip")
file, err := r.fs.OpenAppendable(safePath)
if err != nil {
panic(err)
}
defer file.Close()
writer, ok := file.(io.Writer)
if !ok {
panic(errors.New("file is not writable"))
}
if ctx.Req.Body == nil {
panic(errors.New("no body given"))
}
_, err = io.Copy(writer, ctx.Req.Body)
if err != nil {
panic(err)
}
file.Close()
ctx.JSON(http.StatusCreated, "appended")
case "blocklist":
ctx.JSON(http.StatusCreated, "created")
}
}
func (r *artifactV4Routes) finalizeArtifact(ctx *ArtifactContext) {
var req FinalizeArtifactRequest
if ok := r.parseProtbufBody(ctx, &req); !ok {
return
}
_, _, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
if !ok {
return
}
respData := FinalizeArtifactResponse{
Ok: true,
ArtifactId: artifactNameToID(req.Name),
}
r.sendProtbufBody(ctx, &respData)
}
func (r *artifactV4Routes) listArtifacts(ctx *ArtifactContext) {
var req ListArtifactsRequest
if ok := r.parseProtbufBody(ctx, &req); !ok {
return
}
_, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
if !ok {
return
}
safePath := safeResolve(r.baseDir, fmt.Sprint(runID))
entries, err := fs.ReadDir(r.rfs, safePath)
if err != nil {
panic(err)
}
list := []*ListArtifactsResponse_MonolithArtifact{}
for _, entry := range entries {
id := artifactNameToID(entry.Name())
if (req.NameFilter == nil || req.NameFilter.Value == entry.Name()) && (req.IdFilter == nil || req.IdFilter.Value == id) {
data := &ListArtifactsResponse_MonolithArtifact{
Name: entry.Name(),
CreatedAt: timestamppb.Now(),
DatabaseId: id,
WorkflowRunBackendId: req.WorkflowRunBackendId,
WorkflowJobRunBackendId: req.WorkflowJobRunBackendId,
Size: 0,
}
if info, err := entry.Info(); err == nil {
data.Size = info.Size()
data.CreatedAt = timestamppb.New(info.ModTime())
}
list = append(list, data)
}
}
respData := ListArtifactsResponse{
Artifacts: list,
}
r.sendProtbufBody(ctx, &respData)
}
func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) {
var req GetSignedArtifactURLRequest
if ok := r.parseProtbufBody(ctx, &req); !ok {
return
}
_, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
if !ok {
return
}
artifactName := req.Name
respData := GetSignedArtifactURLResponse{}
respData.SignedUrl = r.buildArtifactURL("DownloadArtifact", artifactName, runID)
r.sendProtbufBody(ctx, &respData)
}
func (r *artifactV4Routes) downloadArtifact(ctx *ArtifactContext) {
task, artifactName, ok := r.verifySignature(ctx, "DownloadArtifact")
if !ok {
return
}
safeRunPath := safeResolve(r.baseDir, fmt.Sprint(task))
safePath := safeResolve(safeRunPath, artifactName)
safePath = safeResolve(safePath, artifactName+".zip")
file, _ := r.rfs.Open(safePath)
_, _ = io.Copy(ctx.Resp, file)
}
func (r *artifactV4Routes) deleteArtifact(ctx *ArtifactContext) {
var req DeleteArtifactRequest
if ok := r.parseProtbufBody(ctx, &req); !ok {
return
}
_, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
if !ok {
return
}
safeRunPath := safeResolve(r.baseDir, fmt.Sprint(runID))
safePath := safeResolve(safeRunPath, req.Name)
_ = os.RemoveAll(safePath)
respData := DeleteArtifactResponse{
Ok: true,
ArtifactId: artifactNameToID(req.Name),
}
r.sendProtbufBody(ctx, &respData)
}

View File

@@ -1,319 +0,0 @@
package artifacts
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/julienschmidt/httprouter"
"github.com/actions-oss/act-cli/pkg/common"
)
type FileContainerResourceURL struct {
FileContainerResourceURL string `json:"fileContainerResourceUrl"`
}
type NamedFileContainerResourceURL struct {
Name string `json:"name"`
FileContainerResourceURL string `json:"fileContainerResourceUrl"`
}
type NamedFileContainerResourceURLResponse struct {
Count int `json:"count"`
Value []NamedFileContainerResourceURL `json:"value"`
}
type ContainerItem struct {
Path string `json:"path"`
ItemType string `json:"itemType"`
ContentLocation string `json:"contentLocation"`
}
type ContainerItemResponse struct {
Value []ContainerItem `json:"value"`
}
type ResponseMessage struct {
Message string `json:"message"`
}
type WritableFile interface {
io.WriteCloser
}
type WriteFS interface {
OpenWritable(name string) (WritableFile, error)
OpenAppendable(name string) (WritableFile, error)
}
type readWriteFSImpl struct {
}
func (fwfs readWriteFSImpl) Open(name string) (fs.File, error) {
return os.Open(name)
}
func (fwfs readWriteFSImpl) OpenWritable(name string) (WritableFile, error) {
if err := os.MkdirAll(filepath.Dir(name), os.ModePerm); err != nil {
return nil, err
}
return os.OpenFile(name, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0o644)
}
func (fwfs readWriteFSImpl) OpenAppendable(name string) (WritableFile, error) {
if err := os.MkdirAll(filepath.Dir(name), os.ModePerm); err != nil {
return nil, err
}
file, err := os.OpenFile(name, os.O_CREATE|os.O_RDWR, 0o644)
if err != nil {
return nil, err
}
_, err = file.Seek(0, io.SeekEnd)
if err != nil {
return nil, err
}
return file, nil
}
var gzipExtension = ".gz__"
func safeResolve(baseDir string, relPath string) string {
return filepath.Join(baseDir, filepath.Clean(filepath.Join(string(os.PathSeparator), relPath)))
}
func uploads(router *httprouter.Router, baseDir string, fsys WriteFS) {
router.POST("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
runID := params.ByName("runId")
json, err := json.Marshal(FileContainerResourceURL{
FileContainerResourceURL: fmt.Sprintf("http://%s/upload/%s", req.Host, runID),
})
if err != nil {
panic(err)
}
_, err = w.Write(json)
if err != nil {
panic(err)
}
})
router.PUT("/upload/:runId", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
itemPath := req.URL.Query().Get("itemPath")
runID := params.ByName("runId")
if req.Header.Get("Content-Encoding") == "gzip" {
itemPath += gzipExtension
}
safeRunPath := safeResolve(baseDir, runID)
safePath := safeResolve(safeRunPath, itemPath)
file, err := func() (WritableFile, error) {
contentRange := req.Header.Get("Content-Range")
if contentRange != "" && !strings.HasPrefix(contentRange, "bytes 0-") {
return fsys.OpenAppendable(safePath)
}
return fsys.OpenWritable(safePath)
}()
if err != nil {
panic(err)
}
defer file.Close()
writer, ok := file.(io.Writer)
if !ok {
panic(errors.New("file is not writable"))
}
if req.Body == nil {
panic(errors.New("no body given"))
}
_, err = io.Copy(writer, req.Body)
if err != nil {
panic(err)
}
json, err := json.Marshal(ResponseMessage{
Message: "success",
})
if err != nil {
panic(err)
}
_, err = w.Write(json)
if err != nil {
panic(err)
}
})
router.PATCH("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, _ *http.Request, _ httprouter.Params) {
json, err := json.Marshal(ResponseMessage{
Message: "success",
})
if err != nil {
panic(err)
}
_, err = w.Write(json)
if err != nil {
panic(err)
}
})
}
func downloads(router *httprouter.Router, baseDir string, fsys fs.FS) {
router.GET("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
runID := params.ByName("runId")
safePath := safeResolve(baseDir, runID)
entries, err := fs.ReadDir(fsys, safePath)
if err != nil {
panic(err)
}
var list []NamedFileContainerResourceURL
for _, entry := range entries {
list = append(list, NamedFileContainerResourceURL{
Name: entry.Name(),
FileContainerResourceURL: fmt.Sprintf("http://%s/download/%s", req.Host, runID),
})
}
json, err := json.Marshal(NamedFileContainerResourceURLResponse{
Count: len(list),
Value: list,
})
if err != nil {
panic(err)
}
_, err = w.Write(json)
if err != nil {
panic(err)
}
})
router.GET("/download/:container", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
container := params.ByName("container")
itemPath := req.URL.Query().Get("itemPath")
safePath := safeResolve(baseDir, filepath.Join(container, itemPath))
var files []ContainerItem
err := fs.WalkDir(fsys, safePath, func(path string, entry fs.DirEntry, _ error) error {
if !entry.IsDir() {
rel, err := filepath.Rel(safePath, path)
if err != nil {
panic(err)
}
// if it was upload as gzip
rel = strings.TrimSuffix(rel, gzipExtension)
path := filepath.Join(itemPath, rel)
rel = filepath.ToSlash(rel)
path = filepath.ToSlash(path)
files = append(files, ContainerItem{
Path: path,
ItemType: "file",
ContentLocation: fmt.Sprintf("http://%s/artifact/%s/%s/%s", req.Host, container, itemPath, rel),
})
}
return nil
})
if err != nil {
panic(err)
}
json, err := json.Marshal(ContainerItemResponse{
Value: files,
})
if err != nil {
panic(err)
}
_, err = w.Write(json)
if err != nil {
panic(err)
}
})
router.GET("/artifact/*path", func(w http.ResponseWriter, _ *http.Request, params httprouter.Params) {
path := params.ByName("path")[1:]
safePath := safeResolve(baseDir, path)
file, err := fsys.Open(safePath)
if err != nil {
// try gzip file
file, err = fsys.Open(safePath + gzipExtension)
if err != nil {
panic(err)
}
w.Header().Add("Content-Encoding", "gzip")
}
_, err = io.Copy(w, file)
if err != nil {
panic(err)
}
})
}
func Serve(ctx context.Context, artifactPath string, addr string, port string) context.CancelFunc {
serverContext, cancel := context.WithCancel(ctx)
logger := common.Logger(serverContext)
if artifactPath == "" {
return cancel
}
router := httprouter.New()
logger.Debugf("Artifacts base path '%s'", artifactPath)
fsys := readWriteFSImpl{}
uploads(router, artifactPath, fsys)
downloads(router, artifactPath, fsys)
RoutesV4(router, artifactPath, fsys, fsys)
server := &http.Server{
Addr: fmt.Sprintf("%s:%s", addr, port),
ReadHeaderTimeout: 2 * time.Second,
Handler: router,
}
// run server
go func() {
logger.Infof("Start server on http://%s:%s", addr, port)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Fatal(err)
}
}()
// wait for cancel to gracefully shutdown server
go func() {
<-serverContext.Done()
if err := server.Shutdown(ctx); err != nil {
logger.Errorf("failed shutdown gracefully - force shutdown: %v", err)
server.Close()
}
}()
return cancel
}

View File

@@ -1,398 +0,0 @@
package artifacts
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"github.com/julienschmidt/httprouter"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/actions-oss/act-cli/pkg/model"
"github.com/actions-oss/act-cli/pkg/runner"
)
type writableMapFile struct {
fstest.MapFile
}
func (f *writableMapFile) Write(data []byte) (int, error) {
f.Data = data
return len(data), nil
}
func (f *writableMapFile) Close() error {
return nil
}
type writeMapFS struct {
fstest.MapFS
}
func (fsys writeMapFS) OpenWritable(name string) (WritableFile, error) {
var file = &writableMapFile{
MapFile: fstest.MapFile{
Data: []byte("content2"),
},
}
fsys.MapFS[name] = &file.MapFile
return file, nil
}
func (fsys writeMapFS) OpenAppendable(name string) (WritableFile, error) {
var file = &writableMapFile{
MapFile: fstest.MapFile{
Data: []byte("content2"),
},
}
fsys.MapFS[name] = &file.MapFile
return file, nil
}
func TestNewArtifactUploadPrepare(t *testing.T) {
assert := assert.New(t)
var memfs = fstest.MapFS(map[string]*fstest.MapFile{})
router := httprouter.New()
uploads(router, "artifact/server/path", writeMapFS{memfs})
req, _ := http.NewRequest("POST", "http://localhost/_apis/pipelines/workflows/1/artifacts", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
assert.Fail("Wrong status")
}
response := FileContainerResourceURL{}
err := json.Unmarshal(rr.Body.Bytes(), &response)
if err != nil {
panic(err)
}
assert.Equal("http://localhost/upload/1", response.FileContainerResourceURL)
}
func TestArtifactUploadBlob(t *testing.T) {
assert := assert.New(t)
var memfs = fstest.MapFS(map[string]*fstest.MapFile{})
router := httprouter.New()
uploads(router, "artifact/server/path", writeMapFS{memfs})
req, _ := http.NewRequest("PUT", "http://localhost/upload/1?itemPath=some/file", strings.NewReader("content"))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
assert.Fail("Wrong status")
}
response := ResponseMessage{}
err := json.Unmarshal(rr.Body.Bytes(), &response)
if err != nil {
panic(err)
}
assert.Equal("success", response.Message)
assert.Equal("content", string(memfs["artifact/server/path/1/some/file"].Data))
}
func TestFinalizeArtifactUpload(t *testing.T) {
assert := assert.New(t)
var memfs = fstest.MapFS(map[string]*fstest.MapFile{})
router := httprouter.New()
uploads(router, "artifact/server/path", writeMapFS{memfs})
req, _ := http.NewRequest("PATCH", "http://localhost/_apis/pipelines/workflows/1/artifacts", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
assert.Fail("Wrong status")
}
response := ResponseMessage{}
err := json.Unmarshal(rr.Body.Bytes(), &response)
if err != nil {
panic(err)
}
assert.Equal("success", response.Message)
}
func TestListArtifacts(t *testing.T) {
assert := assert.New(t)
var memfs = fstest.MapFS(map[string]*fstest.MapFile{
"artifact/server/path/1/file.txt": {
Data: []byte(""),
},
})
router := httprouter.New()
downloads(router, "artifact/server/path", memfs)
req, _ := http.NewRequest("GET", "http://localhost/_apis/pipelines/workflows/1/artifacts", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
assert.FailNow(fmt.Sprintf("Wrong status: %d", status))
}
response := NamedFileContainerResourceURLResponse{}
err := json.Unmarshal(rr.Body.Bytes(), &response)
if err != nil {
panic(err)
}
assert.Equal(1, response.Count)
assert.Equal("file.txt", response.Value[0].Name)
assert.Equal("http://localhost/download/1", response.Value[0].FileContainerResourceURL)
}
func TestListArtifactContainer(t *testing.T) {
assert := assert.New(t)
var memfs = fstest.MapFS(map[string]*fstest.MapFile{
"artifact/server/path/1/some/file": {
Data: []byte(""),
},
})
router := httprouter.New()
downloads(router, "artifact/server/path", memfs)
req, _ := http.NewRequest("GET", "http://localhost/download/1?itemPath=some/file", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
assert.FailNow(fmt.Sprintf("Wrong status: %d", status))
}
response := ContainerItemResponse{}
err := json.Unmarshal(rr.Body.Bytes(), &response)
if err != nil {
panic(err)
}
assert.Equal(1, len(response.Value))
assert.Equal("some/file", response.Value[0].Path)
assert.Equal("file", response.Value[0].ItemType)
assert.Equal("http://localhost/artifact/1/some/file/.", response.Value[0].ContentLocation)
}
func TestDownloadArtifactFile(t *testing.T) {
assert := assert.New(t)
var memfs = fstest.MapFS(map[string]*fstest.MapFile{
"artifact/server/path/1/some/file": {
Data: []byte("content"),
},
})
router := httprouter.New()
downloads(router, "artifact/server/path", memfs)
req, _ := http.NewRequest("GET", "http://localhost/artifact/1/some/file", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
assert.FailNow(fmt.Sprintf("Wrong status: %d", status))
}
data := rr.Body.Bytes()
assert.Equal("content", string(data))
}
type TestJobFileInfo struct {
workdir string
workflowPath string
eventName string
errorMessage string
platforms map[string]string
containerArchitecture string
}
var (
artifactsPath = path.Join(os.TempDir(), "test-artifacts")
artifactsAddr = "127.0.0.1"
artifactsPort = "12345"
)
func TestArtifactFlow(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
if _, ok := os.LookupEnv("NO_EXTERNAL_IP"); ok {
t.Skip("skipping test because QEMU is disabled")
}
ctx := context.Background()
cancel := Serve(ctx, artifactsPath, artifactsAddr, artifactsPort)
defer cancel()
platforms := map[string]string{
"ubuntu-latest": "node:16-buster", // Don't use node:16-buster-slim because it doesn't have curl command, which is used in the tests
}
tables := []TestJobFileInfo{
{"testdata", "upload-and-download", "push", "", platforms, ""},
{"testdata", "GHSL-2023-004", "push", "", platforms, ""},
{"testdata", "v4", "push", "", platforms, ""},
}
log.SetLevel(log.DebugLevel)
for _, table := range tables {
runTestJobFile(ctx, t, table)
}
}
func runTestJobFile(ctx context.Context, t *testing.T, tjfi TestJobFileInfo) {
t.Run(tjfi.workflowPath, func(t *testing.T) {
fmt.Printf("::group::%s\n", tjfi.workflowPath)
if err := os.RemoveAll(artifactsPath); err != nil {
panic(err)
}
workdir, err := filepath.Abs(tjfi.workdir)
assert.Nil(t, err, workdir)
fullWorkflowPath := filepath.Join(workdir, tjfi.workflowPath)
runnerConfig := &runner.Config{
Workdir: workdir,
BindWorkdir: false,
EventName: tjfi.eventName,
Platforms: tjfi.platforms,
ReuseContainers: false,
ContainerArchitecture: tjfi.containerArchitecture,
GitHubInstance: "github.com",
ArtifactServerPath: artifactsPath,
ArtifactServerAddr: artifactsAddr,
ArtifactServerPort: artifactsPort,
}
runner, err := runner.New(runnerConfig)
assert.Nil(t, err, tjfi.workflowPath)
planner, err := model.NewWorkflowPlanner(fullWorkflowPath, model.PlannerConfig{})
assert.Nil(t, err, fullWorkflowPath)
plan, err := planner.PlanEvent(tjfi.eventName)
if err == nil {
err = runner.NewPlanExecutor(plan)(ctx)
if tjfi.errorMessage == "" {
assert.Nil(t, err, fullWorkflowPath)
} else {
assert.Error(t, err, tjfi.errorMessage)
}
} else {
assert.Nil(t, plan)
}
fmt.Println("::endgroup::")
})
}
func TestMkdirFsImplSafeResolve(t *testing.T) {
baseDir := "/foo/bar"
tests := map[string]struct {
input string
want string
}{
"simple": {input: "baz", want: "/foo/bar/baz"},
"nested": {input: "baz/blue", want: "/foo/bar/baz/blue"},
"dots in middle": {input: "baz/../../blue", want: "/foo/bar/blue"},
"leading dots": {input: "../../parent", want: "/foo/bar/parent"},
"root path": {input: "/root", want: "/foo/bar/root"},
"root": {input: "/", want: "/foo/bar"},
"empty": {input: "", want: "/foo/bar"},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
assert.Equal(tc.want, safeResolve(baseDir, tc.input))
})
}
}
func TestDownloadArtifactFileUnsafePath(t *testing.T) {
assert := assert.New(t)
var memfs = fstest.MapFS(map[string]*fstest.MapFile{
"artifact/server/path/some/file": {
Data: []byte("content"),
},
})
router := httprouter.New()
downloads(router, "artifact/server/path", memfs)
req, _ := http.NewRequest("GET", "http://localhost/artifact/2/../../some/file", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
assert.FailNow(fmt.Sprintf("Wrong status: %d", status))
}
data := rr.Body.Bytes()
assert.Equal("content", string(data))
}
func TestArtifactUploadBlobUnsafePath(t *testing.T) {
assert := assert.New(t)
var memfs = fstest.MapFS(map[string]*fstest.MapFile{})
router := httprouter.New()
uploads(router, "artifact/server/path", writeMapFS{memfs})
req, _ := http.NewRequest("PUT", "http://localhost/upload/1?itemPath=../../some/file", strings.NewReader("content"))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
assert.Fail("Wrong status")
}
response := ResponseMessage{}
err := json.Unmarshal(rr.Body.Bytes(), &response)
if err != nil {
panic(err)
}
assert.Equal("success", response.Message)
assert.Equal("content", string(memfs["artifact/server/path/1/some/file"].Data))
}

Some files were not shown because too many files have changed in this diff Show More