feat(actions)!: add RUN_RETENTION_DAYS to delete old action runs (#38855)

Gitea keeps completed Actions runs forever. Artifacts and logs expire on
their own schedule, but the run rows never go away, so `action_run` and
its child tables grow without bound.

Adds `RUN_RETENTION_DAYS` to delete completed runs along with their
jobs, tasks and anything the earlier expiries left behind. It defaults
to 400 days, matching how long GitHub keeps run history browsable. A
dedicated `cleanup_action_runs` cron task performs the cleanup, so
admins can schedule it separately from the nightly artifact and log
sweep.

`0` now means "keep forever" for all three retention settings, where
`LOG_RETENTION_DAYS` and `ARTIFACT_RETENTION_DAYS` previously took it
literally and deleted everything at the next sweep.

Docs: https://gitea.com/gitea/docs/pulls/502

----

## ⚠️ BREAKING ⚠️

`RUN_RETENTION_DAYS` defaults to 400, so completed runs older than that
are deleted when the cron task next runs at midnight. Set
`RUN_RETENTION_DAYS = 0` before upgrading to keep all runs.

---------

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
Federico A. Corazza
2026-08-22 23:32:59 +02:00
committed by GitHub
parent e6af4c341c
commit 1fa6465efd
14 changed files with 399 additions and 145 deletions
+77 -22
View File
@@ -5,7 +5,6 @@ package actions
import (
"context"
"errors"
"fmt"
"time"
@@ -54,7 +53,7 @@ func cleanExpiredArtifacts(taskCtx context.Context) error {
if err != nil {
return err
}
log.Info("Found %d expired artifacts", len(artifacts))
log.Info("Found %d expired Actions artifacts", len(artifacts))
for _, artifact := range artifacts {
if err := actions_model.SetArtifactExpired(taskCtx, artifact.ID); err != nil {
log.Error("Cannot set artifact %d expired: %v", artifact.ID, err)
@@ -64,7 +63,7 @@ func cleanExpiredArtifacts(taskCtx context.Context) error {
log.Error("Cannot delete artifact %d: %v", artifact.ID, err)
// go on
}
log.Info("Artifact %d is deleted (due to expiration)", artifact.ID)
log.Info("Actions artifact %d is deleted (due to expiration)", artifact.ID)
}
return nil
}
@@ -78,7 +77,7 @@ func cleanNeedDeleteArtifacts(taskCtx context.Context) error {
if err != nil {
return err
}
log.Info("Found %d artifacts pending deletion", len(artifacts))
log.Info("Found %d Actions artifacts pending deletion", len(artifacts))
for _, artifact := range artifacts {
if err := actions_model.SetArtifactDeleted(taskCtx, artifact.ID); err != nil {
log.Error("Cannot set artifact %d deleted: %v", artifact.ID, err)
@@ -88,10 +87,10 @@ func cleanNeedDeleteArtifacts(taskCtx context.Context) error {
log.Error("Cannot delete artifact %d: %v", artifact.ID, err)
// go on
}
log.Info("Artifact %d is deleted (due to pending deletion)", artifact.ID)
log.Info("Actions artifact %d is deleted (due to pending deletion)", artifact.ID)
}
if len(artifacts) < deleteArtifactBatchSize {
log.Debug("No more artifacts pending deletion")
log.Debug("No more Actions artifacts pending deletion")
break
}
}
@@ -109,6 +108,10 @@ func removeTaskLog(ctx context.Context, task *actions_model.ActionTask) {
// CleanupExpiredLogs removes logs which are older than the configured retention time
func CleanupExpiredLogs(ctx context.Context) error {
if setting.Actions.LogRetentionDays <= 0 {
return nil
}
olderThan := timeutil.TimeStampNow().AddDuration(-time.Duration(setting.Actions.LogRetentionDays) * 24 * time.Hour)
count := 0
@@ -134,7 +137,7 @@ func CleanupExpiredLogs(ctx context.Context) error {
}
}
log.Info("Removed %d logs", count)
log.Info("Removed %d expired Actions logs", count)
return nil
}
@@ -146,13 +149,8 @@ func CleanupEphemeralRunners(ctx context.Context) error {
Where(builder.Eq{"`action_runner`.`ephemeral`": true}).
And(builder.NotIn("`action_task`.`status`", actions_model.StatusWaiting, actions_model.StatusRunning, actions_model.StatusBlocked, actions_model.StatusCancelling))
b := builder.Delete(builder.In("id", subQuery)).From("`action_runner`")
res, err := db.GetEngine(ctx).Exec(b)
if err != nil {
return fmt.Errorf("find runners: %w", err)
}
affected, _ := res.RowsAffected()
log.Info("Removed %d runners", affected)
return nil
_, err := db.GetEngine(ctx).Exec(b)
return err
}
// CleanupEphemeralRunnersByPickedTaskOfRepo removes all ephemeral runners that have active/finished tasks on the given repository
@@ -162,19 +160,15 @@ func CleanupEphemeralRunnersByPickedTaskOfRepo(ctx context.Context, repoID int64
Join("INNER", "`action_task`", "`action_task`.`runner_id` = `action_runner`.`id`").
Where(builder.And(builder.Eq{"`action_runner`.`ephemeral`": true}, builder.Eq{"`action_task`.`repo_id`": repoID}))
b := builder.Delete(builder.In("id", subQuery)).From("`action_runner`")
res, err := db.GetEngine(ctx).Exec(b)
if err != nil {
return fmt.Errorf("find runners: %w", err)
}
affected, _ := res.RowsAffected()
log.Info("Removed %d runners", affected)
return nil
_, err := db.GetEngine(ctx).Exec(b)
return err
}
// DeleteRun deletes workflow run, including all logs and artifacts.
func DeleteRun(ctx context.Context, run *actions_model.ActionRun) error {
if !run.Status.IsDone() {
return errors.New("run is not done")
// callers guarantee a terminal status, but in production delete it anyway
setting.PanicInDevOrTesting("DeleteRun called on non-terminal run %d with status %s", run.ID, run.Status)
}
repoID := run.RepoID
@@ -262,9 +256,70 @@ func DeleteRun(ctx context.Context, run *actions_model.ActionRun) error {
}
for _, art := range artifacts {
if err := storage.ActionsArtifacts.Delete(art.StoragePath); err != nil {
// don't return any error since the database records have been deleted
log.Error("remove artifact file %q: %v", art.StoragePath, err)
}
}
return nil
}
var cleanupOldRunsBatchSize = 50
// CleanupOldRuns deletes completed runs older than RUN_RETENTION_DAYS, along with everything under them.
func CleanupOldRuns(ctx context.Context) error {
if setting.Actions.RunRetentionDays <= 0 {
return nil
}
olderThan := timeutil.TimeStampNow().AddDuration(-time.Duration(setting.Actions.RunRetentionDays) * 24 * time.Hour)
doneStatuses := []actions_model.Status{
actions_model.StatusSuccess,
actions_model.StatusFailure,
actions_model.StatusCancelled,
actions_model.StatusSkipped,
}
total, err := cleanupOldRuns(ctx, olderThan, doneStatuses, DeleteRun)
if err != nil {
return err
}
log.Info("Deleted %d old Actions runs before %s", total, olderThan.Format(time.RFC3339))
return nil
}
func cleanupOldRuns(ctx context.Context, olderThan timeutil.TimeStamp, doneStatuses []actions_model.Status, deleteRun func(context.Context, *actions_model.ActionRun) error) (int, error) {
total := 0
failed := container.Set[int64]{} // skipping these stops the outer loop refetching them forever
for {
runs, err := actions_model.FindOldestRuns(ctx, doneStatuses, olderThan, cleanupOldRunsBatchSize)
if err != nil {
return total, fmt.Errorf("FindOldestRuns: %w", err)
}
realDeleted := 0
for _, run := range runs {
if failed.Contains(run.ID) {
continue
}
if err := deleteRun(ctx, run); err != nil {
setting.PanicInDevOrTesting("failed to delete old action run %d: %v", run.ID, err)
failed.Add(run.ID)
continue
}
total++
realDeleted++
log.Trace("Deleted old action run %d (created at %s)", run.ID, run.Created.AsTime())
}
if realDeleted == 0 {
if len(runs) != 0 {
log.Error("Too many actions runs are unable to delete, please figure out and fix the failures")
}
break
}
}
return total, nil
}