Skip to content

Commit 7164c50

Browse files
committed
feat(actions): add deployment environments for Actions
Implements deployment environments similar to GitHub Actions, resolves #32090. Environments allow scoping secrets and variables to a named deployment target (e.g. production, staging) with optional branch protection rules. - New DB tables: action_environment, action_environment_secret, action_environment_variable (migrations v340, v341 in v1_27) - ActionRunJob.environment_name populated from workflow YAML environment: key - Secret/variable resolution overlays env-scoped values over repo/org scope - GenerateGiteaContext exposes github.environment to runner steps - REST API: CRUD under /repos/{owner}/{repo}/environments - Web UI: Environments nested inside Actions settings dropdown - Generated swagger spec Closes #32090.
1 parent 9c82394 commit 7164c50

26 files changed

Lines changed: 3633 additions & 5 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,4 @@ prime/
120120

121121
# A Makefile for custom make targets
122122
Makefile.local
123+
gitea.dev

models/actions/environment.go

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
// Copyright 2026 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package actions
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"strings"
10+
11+
"gitea.dev/models/db"
12+
"gitea.dev/modules/glob"
13+
"gitea.dev/modules/timeutil"
14+
"gitea.dev/modules/util"
15+
16+
"xorm.io/builder"
17+
)
18+
19+
// ActionEnvironment represents a deployment environment for a repository.
20+
// Secrets and variables can be scoped to an environment and optionally protected by branch policies.
21+
type ActionEnvironment struct {
22+
ID int64 `xorm:"pk autoincr"`
23+
RepoID int64 `xorm:"UNIQUE(repo_name) NOT NULL"`
24+
Name string `xorm:"UNIQUE(repo_name) NOT NULL"`
25+
26+
// ProtectedBranches is a glob pattern list (comma-separated) that restricts
27+
// which branches can deploy to this environment. Empty means no restriction.
28+
ProtectedBranches string `xorm:"TEXT"`
29+
30+
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"`
31+
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
32+
}
33+
34+
func init() {
35+
db.RegisterModel(new(ActionEnvironment))
36+
db.RegisterModel(new(ActionEnvironmentSecret))
37+
db.RegisterModel(new(ActionEnvironmentVariable))
38+
}
39+
40+
// ErrEnvironmentNotFound is returned when an environment does not exist.
41+
type ErrEnvironmentNotFound struct {
42+
Name string
43+
}
44+
45+
func (err ErrEnvironmentNotFound) Error() string {
46+
return fmt.Sprintf("environment not found [name: %s]", err.Name)
47+
}
48+
49+
func (err ErrEnvironmentNotFound) Unwrap() error {
50+
return util.ErrNotExist
51+
}
52+
53+
// ErrEnvironmentAlreadyExists is returned when creating a duplicate environment.
54+
type ErrEnvironmentAlreadyExists struct {
55+
Name string
56+
}
57+
58+
func (err ErrEnvironmentAlreadyExists) Error() string {
59+
return fmt.Sprintf("environment already exists [name: %s]", err.Name)
60+
}
61+
62+
func (err ErrEnvironmentAlreadyExists) Unwrap() error {
63+
return util.ErrAlreadyExist
64+
}
65+
66+
// FindEnvironmentsOptions holds filter parameters for listing environments.
67+
type FindEnvironmentsOptions struct {
68+
db.ListOptions
69+
RepoID int64
70+
Name string
71+
}
72+
73+
func (opts FindEnvironmentsOptions) ToConds() builder.Cond {
74+
cond := builder.NewCond()
75+
if opts.RepoID != 0 {
76+
cond = cond.And(builder.Eq{"repo_id": opts.RepoID})
77+
}
78+
if opts.Name != "" {
79+
cond = cond.And(builder.Eq{"name": opts.Name})
80+
}
81+
return cond
82+
}
83+
84+
// GetEnvironmentByRepoAndName returns the environment matching the given repo and name.
85+
func GetEnvironmentByRepoAndName(ctx context.Context, repoID int64, name string) (*ActionEnvironment, error) {
86+
envs, err := db.Find[ActionEnvironment](ctx, FindEnvironmentsOptions{RepoID: repoID, Name: name})
87+
if err != nil {
88+
return nil, err
89+
}
90+
if len(envs) == 0 {
91+
return nil, ErrEnvironmentNotFound{Name: name}
92+
}
93+
return envs[0], nil
94+
}
95+
96+
// GetEnvironmentByID returns the environment with the given id.
97+
func GetEnvironmentByID(ctx context.Context, id int64) (*ActionEnvironment, error) {
98+
env := &ActionEnvironment{}
99+
has, err := db.GetEngine(ctx).ID(id).Get(env)
100+
if err != nil {
101+
return nil, err
102+
}
103+
if !has {
104+
return nil, ErrEnvironmentNotFound{}
105+
}
106+
return env, nil
107+
}
108+
109+
// InsertEnvironment creates a new environment for a repository.
110+
func InsertEnvironment(ctx context.Context, repoID int64, name, protectedBranches string) (*ActionEnvironment, error) {
111+
env := &ActionEnvironment{
112+
RepoID: repoID,
113+
Name: name,
114+
ProtectedBranches: protectedBranches,
115+
}
116+
return env, db.Insert(ctx, env)
117+
}
118+
119+
// UpdateEnvironment updates mutable fields of an environment.
120+
func UpdateEnvironment(ctx context.Context, env *ActionEnvironment) error {
121+
_, err := db.GetEngine(ctx).ID(env.ID).Cols("name", "protected_branches").Update(env)
122+
return err
123+
}
124+
125+
// DeleteEnvironment removes an environment and all its associated secrets and variables.
126+
func DeleteEnvironment(ctx context.Context, repoID, envID int64) error {
127+
return db.WithTx(ctx, func(ctx context.Context) error {
128+
if _, err := db.GetEngine(ctx).
129+
Where("repo_id = ? AND environment_id = ?", repoID, envID).
130+
Delete(new(ActionEnvironmentSecret)); err != nil {
131+
return err
132+
}
133+
if _, err := db.GetEngine(ctx).
134+
Where("repo_id = ? AND environment_id = ?", repoID, envID).
135+
Delete(new(ActionEnvironmentVariable)); err != nil {
136+
return err
137+
}
138+
_, err := db.GetEngine(ctx).Where("id = ? AND repo_id = ?", envID, repoID).Delete(new(ActionEnvironment))
139+
return err
140+
})
141+
}
142+
143+
// MatchesBranch reports whether ref (e.g. "refs/heads/main") is permitted by the environment's branch policy.
144+
// An empty policy allows everything.
145+
func (env *ActionEnvironment) MatchesBranch(ref string) bool {
146+
if env.ProtectedBranches == "" {
147+
return true
148+
}
149+
// Strip refs/heads/ prefix for comparison
150+
branch := strings.TrimPrefix(ref, "refs/heads/")
151+
for pattern := range strings.SplitSeq(env.ProtectedBranches, ",") {
152+
pattern = strings.TrimSpace(pattern)
153+
if pattern == "" {
154+
continue
155+
}
156+
g, err := glob.Compile(pattern)
157+
if err != nil {
158+
return false
159+
}
160+
ok := g.Match(branch)
161+
if ok {
162+
return true
163+
}
164+
}
165+
return false
166+
}
167+
168+
// ActionEnvironmentSecret is a secret scoped to an environment.
169+
type ActionEnvironmentSecret struct {
170+
ID int64 `xorm:"pk autoincr"`
171+
RepoID int64 `xorm:"UNIQUE(env_name) NOT NULL"`
172+
EnvironmentID int64 `xorm:"UNIQUE(env_name) NOT NULL"`
173+
Name string `xorm:"UNIQUE(env_name) NOT NULL"`
174+
Data string `xorm:"LONGTEXT"` // encrypted
175+
Description string `xorm:"TEXT"`
176+
177+
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"`
178+
}
179+
180+
// FindEnvSecretsOptions holds filter options for env secrets.
181+
type FindEnvSecretsOptions struct {
182+
db.ListOptions
183+
RepoID int64
184+
EnvironmentID int64
185+
Name string
186+
SecretID int64
187+
}
188+
189+
func (opts FindEnvSecretsOptions) ToConds() builder.Cond {
190+
cond := builder.NewCond()
191+
if opts.RepoID != 0 {
192+
cond = cond.And(builder.Eq{"repo_id": opts.RepoID})
193+
}
194+
if opts.EnvironmentID != 0 {
195+
cond = cond.And(builder.Eq{"environment_id": opts.EnvironmentID})
196+
}
197+
if opts.SecretID != 0 {
198+
cond = cond.And(builder.Eq{"id": opts.SecretID})
199+
}
200+
if opts.Name != "" {
201+
cond = cond.And(builder.Eq{"name": strings.ToUpper(opts.Name)})
202+
}
203+
return cond
204+
}
205+
206+
// ActionEnvironmentVariable is a plain-text variable scoped to an environment.
207+
type ActionEnvironmentVariable struct {
208+
ID int64 `xorm:"pk autoincr"`
209+
RepoID int64 `xorm:"UNIQUE(env_var_name) NOT NULL"`
210+
EnvironmentID int64 `xorm:"UNIQUE(env_var_name) NOT NULL"`
211+
Name string `xorm:"UNIQUE(env_var_name) NOT NULL"`
212+
Data string `xorm:"LONGTEXT NOT NULL"`
213+
Description string `xorm:"TEXT"`
214+
215+
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"`
216+
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
217+
}
218+
219+
// FindEnvVariablesOptions holds filter options for env variables.
220+
type FindEnvVariablesOptions struct {
221+
db.ListOptions
222+
RepoID int64
223+
EnvironmentID int64
224+
Name string
225+
VariableID int64
226+
}
227+
228+
func (opts FindEnvVariablesOptions) ToConds() builder.Cond {
229+
cond := builder.NewCond()
230+
if opts.RepoID != 0 {
231+
cond = cond.And(builder.Eq{"repo_id": opts.RepoID})
232+
}
233+
if opts.EnvironmentID != 0 {
234+
cond = cond.And(builder.Eq{"environment_id": opts.EnvironmentID})
235+
}
236+
if opts.VariableID != 0 {
237+
cond = cond.And(builder.Eq{"id": opts.VariableID})
238+
}
239+
if opts.Name != "" {
240+
cond = cond.And(builder.Eq{"name": strings.ToUpper(opts.Name)})
241+
}
242+
return cond
243+
}

models/actions/run_job.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@ type ActionRunJob struct {
105105
// Only set when IsReusableCaller is true.
106106
CallPayload string `xorm:"LONGTEXT"`
107107

108+
// EnvironmentName is the deployment environment name declared in the job's "environment:" key.
109+
// Empty if the job does not target a deployment environment.
110+
EnvironmentName string `xorm:"VARCHAR(255) NOT NULL DEFAULT ''"`
111+
108112
// ParentJobID scopes `Needs` resolution: name lookups happen only among rows sharing the same ParentJobID. 0 for top-level rows.
109113
ParentJobID int64 `xorm:"index NOT NULL DEFAULT 0"`
110114

models/actions/variable.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ package actions
55

66
import (
77
"context"
8+
"errors"
9+
"fmt"
810
"strings"
911
"unicode/utf8"
1012

@@ -169,6 +171,47 @@ func GetVariablesOfRun(ctx context.Context, run *ActionRun) (map[string]string,
169171
return variables, nil
170172
}
171173

174+
// GetVariablesOfJob returns variables for a job, overlaying environment-scoped variables when the job
175+
// targets a deployment environment whose branch policy matches the run's ref.
176+
// Precedence (high to low): environment > repo > org/user > global
177+
func GetVariablesOfJob(ctx context.Context, job *ActionRunJob) (map[string]string, error) {
178+
if err := job.LoadRun(ctx); err != nil {
179+
return nil, err
180+
}
181+
variables, err := GetVariablesOfRun(ctx, job.Run)
182+
if err != nil {
183+
return nil, err
184+
}
185+
186+
if job.EnvironmentName == "" {
187+
return variables, nil
188+
}
189+
190+
env, err := GetEnvironmentByRepoAndName(ctx, job.RepoID, job.EnvironmentName)
191+
if err != nil {
192+
if !errors.Is(err, util.ErrNotExist) {
193+
return nil, fmt.Errorf("get environment %q for job %d: %w", job.EnvironmentName, job.ID, err)
194+
}
195+
return variables, nil
196+
}
197+
if !env.MatchesBranch(job.Run.Ref) {
198+
return variables, nil
199+
}
200+
201+
envVars, err := db.Find[ActionEnvironmentVariable](ctx, FindEnvVariablesOptions{
202+
RepoID: job.RepoID,
203+
EnvironmentID: env.ID,
204+
})
205+
if err != nil {
206+
log.Error("find environment variables for env %d: %v", env.ID, err)
207+
return variables, nil
208+
}
209+
for _, v := range envVars {
210+
variables[v.Name] = v.Data
211+
}
212+
return variables, nil
213+
}
214+
172215
func CountWrongRepoLevelVariables(ctx context.Context) (int64, error) {
173216
var result int64
174217
_, err := db.GetEngine(ctx).SQL("SELECT count(`id`) FROM `action_variable` WHERE `repo_id` > 0 AND `owner_id` > 0").Get(&result)

models/migrations/migrations.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,8 @@ func prepareMigrationTasks() []*migration {
417417
newMigration(337, "Add visibility to team", v1_27.AddVisibilityToTeam),
418418
newMigration(338, "Expand legacy MSSQL issue/comment long-text columns", v1_27.ExpandIssueAndCommentLongTextFieldsForMSSQL),
419419
newMigration(339, "Extend action c_u index to include created_unix for faster dashboard feed queries", v1_27.AddCreatedUnixToActionUserIsDeletedIndex),
420+
newMigration(340, "Add action environment tables", v1_27.AddActionEnvironmentTables),
421+
newMigration(341, "Add environment_name to action_run_job", v1_27.AddEnvironmentNameToActionRunJob),
420422
}
421423
return preparedMigrations
422424
}

models/migrations/v1_27/v340.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Copyright 2026 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package v1_27
5+
6+
import (
7+
"gitea.dev/models/db"
8+
"gitea.dev/modules/timeutil"
9+
)
10+
11+
// AddActionEnvironmentTables creates the action_environment, action_environment_secret,
12+
// and action_environment_variable tables for the Environments feature.
13+
func AddActionEnvironmentTables(x db.EngineMigration) error {
14+
type ActionEnvironment struct {
15+
ID int64 `xorm:"pk autoincr"`
16+
RepoID int64 `xorm:"UNIQUE(repo_name) NOT NULL"`
17+
Name string `xorm:"UNIQUE(repo_name) NOT NULL"`
18+
ProtectedBranches string `xorm:"TEXT"`
19+
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"`
20+
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
21+
}
22+
23+
type ActionEnvironmentSecret struct {
24+
ID int64 `xorm:"pk autoincr"`
25+
RepoID int64 `xorm:"UNIQUE(env_name) NOT NULL"`
26+
EnvironmentID int64 `xorm:"UNIQUE(env_name) NOT NULL"`
27+
Name string `xorm:"UNIQUE(env_name) NOT NULL"`
28+
Data string `xorm:"LONGTEXT"`
29+
Description string `xorm:"TEXT"`
30+
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"`
31+
}
32+
33+
type ActionEnvironmentVariable struct {
34+
ID int64 `xorm:"pk autoincr"`
35+
RepoID int64 `xorm:"UNIQUE(env_var_name) NOT NULL"`
36+
EnvironmentID int64 `xorm:"UNIQUE(env_var_name) NOT NULL"`
37+
Name string `xorm:"UNIQUE(env_var_name) NOT NULL"`
38+
Data string `xorm:"LONGTEXT NOT NULL"`
39+
Description string `xorm:"TEXT"`
40+
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"`
41+
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
42+
}
43+
44+
return x.Sync(
45+
new(ActionEnvironment),
46+
new(ActionEnvironmentSecret),
47+
new(ActionEnvironmentVariable),
48+
)
49+
}

models/migrations/v1_27/v341.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Copyright 2026 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package v1_27
5+
6+
import "gitea.dev/models/db"
7+
8+
// AddEnvironmentNameToActionRunJob adds environment_name column to action_run_job
9+
// to track the deployment environment a job targets.
10+
func AddEnvironmentNameToActionRunJob(x db.EngineMigration) error {
11+
type ActionRunJob struct {
12+
EnvironmentName string `xorm:"VARCHAR(255) NOT NULL DEFAULT ''"`
13+
}
14+
return x.Sync(new(ActionRunJob))
15+
}

0 commit comments

Comments
 (0)