mirror of https://github.com/go-gitea/gitea
Support configuration variables on Gitea Actions (#24724)
Co-Author: @silverwind @wxiaoguang Replace: #24404 See: - [defining configuration variables for multiple workflows](https://docs.github.com/en/actions/learn-github-actions/variables#defining-configuration-variables-for-multiple-workflows) - [vars context](https://docs.github.com/en/actions/learn-github-actions/contexts#vars-context) Related to: - [x] protocol: https://gitea.com/gitea/actions-proto-def/pulls/7 - [x] act_runner: https://gitea.com/gitea/act_runner/pulls/157 - [x] act: https://gitea.com/gitea/act/pulls/43 #### Screenshoot Create Variable: ![image](https://user-images.githubusercontent.com/33891828/236758288-032b7f64-44e7-48ea-b07d-de8b8b0e3729.png) ![image](https://user-images.githubusercontent.com/33891828/236758174-5203f64c-1d0e-4737-a5b0-62061dee86f8.png) Workflow: ```yaml test_vars: runs-on: ubuntu-latest steps: - name: Print Custom Variables run: echo "${{ vars.test_key }}" - name: Try to print a non-exist var run: echo "${{ vars.NON_EXIST_VAR }}" ``` Actions Log: ![image](https://user-images.githubusercontent.com/33891828/236759075-af0c5950-368d-4758-a8ac-47a96e43b6e2.png) --- This PR just implement the org / user (depends on the owner of the current repository) and repo level variables, The Environment level variables have not been implemented. Because [Environment](https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#about-environments) is a module separate from `Actions`. Maybe it would be better to create a new PR to do it. --------- Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: Giteabot <teabot@gitea.io>pull/25397/head^2
parent
8220e50b56
commit
35a653d7ed
@ -0,0 +1,97 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"context" |
||||
"errors" |
||||
"fmt" |
||||
"strings" |
||||
|
||||
"code.gitea.io/gitea/models/db" |
||||
"code.gitea.io/gitea/modules/timeutil" |
||||
"code.gitea.io/gitea/modules/util" |
||||
|
||||
"xorm.io/builder" |
||||
) |
||||
|
||||
type ActionVariable struct { |
||||
ID int64 `xorm:"pk autoincr"` |
||||
OwnerID int64 `xorm:"UNIQUE(owner_repo_name)"` |
||||
RepoID int64 `xorm:"INDEX UNIQUE(owner_repo_name)"` |
||||
Name string `xorm:"UNIQUE(owner_repo_name) NOT NULL"` |
||||
Data string `xorm:"LONGTEXT NOT NULL"` |
||||
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"` |
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"` |
||||
} |
||||
|
||||
func init() { |
||||
db.RegisterModel(new(ActionVariable)) |
||||
} |
||||
|
||||
func (v *ActionVariable) Validate() error { |
||||
if v.OwnerID == 0 && v.RepoID == 0 { |
||||
return errors.New("the variable is not bound to any scope") |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func InsertVariable(ctx context.Context, ownerID, repoID int64, name, data string) (*ActionVariable, error) { |
||||
variable := &ActionVariable{ |
||||
OwnerID: ownerID, |
||||
RepoID: repoID, |
||||
Name: strings.ToUpper(name), |
||||
Data: data, |
||||
} |
||||
if err := variable.Validate(); err != nil { |
||||
return variable, err |
||||
} |
||||
return variable, db.Insert(ctx, variable) |
||||
} |
||||
|
||||
type FindVariablesOpts struct { |
||||
db.ListOptions |
||||
OwnerID int64 |
||||
RepoID int64 |
||||
} |
||||
|
||||
func (opts *FindVariablesOpts) toConds() builder.Cond { |
||||
cond := builder.NewCond() |
||||
if opts.OwnerID > 0 { |
||||
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID}) |
||||
} |
||||
if opts.RepoID > 0 { |
||||
cond = cond.And(builder.Eq{"repo_id": opts.RepoID}) |
||||
} |
||||
return cond |
||||
} |
||||
|
||||
func FindVariables(ctx context.Context, opts FindVariablesOpts) ([]*ActionVariable, error) { |
||||
var variables []*ActionVariable |
||||
sess := db.GetEngine(ctx) |
||||
if opts.PageSize != 0 { |
||||
sess = db.SetSessionPagination(sess, &opts.ListOptions) |
||||
} |
||||
return variables, sess.Where(opts.toConds()).Find(&variables) |
||||
} |
||||
|
||||
func GetVariableByID(ctx context.Context, variableID int64) (*ActionVariable, error) { |
||||
var variable ActionVariable |
||||
has, err := db.GetEngine(ctx).Where("id=?", variableID).Get(&variable) |
||||
if err != nil { |
||||
return nil, err |
||||
} else if !has { |
||||
return nil, fmt.Errorf("variable with id %d: %w", variableID, util.ErrNotExist) |
||||
} |
||||
return &variable, nil |
||||
} |
||||
|
||||
func UpdateVariable(ctx context.Context, variable *ActionVariable) (bool, error) { |
||||
count, err := db.GetEngine(ctx).ID(variable.ID).Cols("name", "data"). |
||||
Update(&ActionVariable{ |
||||
Name: variable.Name, |
||||
Data: variable.Data, |
||||
}) |
||||
return count != 0, err |
||||
} |
@ -0,0 +1,24 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_21 //nolint
|
||||
|
||||
import ( |
||||
"code.gitea.io/gitea/modules/timeutil" |
||||
|
||||
"xorm.io/xorm" |
||||
) |
||||
|
||||
func CreateVariableTable(x *xorm.Engine) error { |
||||
type ActionVariable struct { |
||||
ID int64 `xorm:"pk autoincr"` |
||||
OwnerID int64 `xorm:"UNIQUE(owner_repo_name)"` |
||||
RepoID int64 `xorm:"INDEX UNIQUE(owner_repo_name)"` |
||||
Name string `xorm:"UNIQUE(owner_repo_name) NOT NULL"` |
||||
Data string `xorm:"LONGTEXT NOT NULL"` |
||||
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"` |
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"` |
||||
} |
||||
|
||||
return x.Sync(new(ActionVariable)) |
||||
} |
@ -0,0 +1,119 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package setting |
||||
|
||||
import ( |
||||
"errors" |
||||
"net/http" |
||||
|
||||
"code.gitea.io/gitea/modules/base" |
||||
"code.gitea.io/gitea/modules/context" |
||||
"code.gitea.io/gitea/modules/setting" |
||||
shared "code.gitea.io/gitea/routers/web/shared/actions" |
||||
) |
||||
|
||||
const ( |
||||
tplRepoVariables base.TplName = "repo/settings/actions" |
||||
tplOrgVariables base.TplName = "org/settings/actions" |
||||
tplUserVariables base.TplName = "user/settings/actions" |
||||
) |
||||
|
||||
type variablesCtx struct { |
||||
OwnerID int64 |
||||
RepoID int64 |
||||
IsRepo bool |
||||
IsOrg bool |
||||
IsUser bool |
||||
VariablesTemplate base.TplName |
||||
RedirectLink string |
||||
} |
||||
|
||||
func getVariablesCtx(ctx *context.Context) (*variablesCtx, error) { |
||||
if ctx.Data["PageIsRepoSettings"] == true { |
||||
return &variablesCtx{ |
||||
RepoID: ctx.Repo.Repository.ID, |
||||
IsRepo: true, |
||||
VariablesTemplate: tplRepoVariables, |
||||
RedirectLink: ctx.Repo.RepoLink + "/settings/actions/variables", |
||||
}, nil |
||||
} |
||||
|
||||
if ctx.Data["PageIsOrgSettings"] == true { |
||||
return &variablesCtx{ |
||||
OwnerID: ctx.ContextUser.ID, |
||||
IsOrg: true, |
||||
VariablesTemplate: tplOrgVariables, |
||||
RedirectLink: ctx.Org.OrgLink + "/settings/actions/variables", |
||||
}, nil |
||||
} |
||||
|
||||
if ctx.Data["PageIsUserSettings"] == true { |
||||
return &variablesCtx{ |
||||
OwnerID: ctx.Doer.ID, |
||||
IsUser: true, |
||||
VariablesTemplate: tplUserVariables, |
||||
RedirectLink: setting.AppSubURL + "/user/settings/actions/variables", |
||||
}, nil |
||||
} |
||||
|
||||
return nil, errors.New("unable to set Variables context") |
||||
} |
||||
|
||||
func Variables(ctx *context.Context) { |
||||
ctx.Data["Title"] = ctx.Tr("actions.variables") |
||||
ctx.Data["PageType"] = "variables" |
||||
ctx.Data["PageIsSharedSettingsVariables"] = true |
||||
|
||||
vCtx, err := getVariablesCtx(ctx) |
||||
if err != nil { |
||||
ctx.ServerError("getVariablesCtx", err) |
||||
return |
||||
} |
||||
|
||||
shared.SetVariablesContext(ctx, vCtx.OwnerID, vCtx.RepoID) |
||||
if ctx.Written() { |
||||
return |
||||
} |
||||
|
||||
ctx.HTML(http.StatusOK, vCtx.VariablesTemplate) |
||||
} |
||||
|
||||
func VariableCreate(ctx *context.Context) { |
||||
vCtx, err := getVariablesCtx(ctx) |
||||
if err != nil { |
||||
ctx.ServerError("getVariablesCtx", err) |
||||
return |
||||
} |
||||
|
||||
if ctx.HasError() { // form binding validation error
|
||||
ctx.JSONError(ctx.GetErrMsg()) |
||||
return |
||||
} |
||||
|
||||
shared.CreateVariable(ctx, vCtx.OwnerID, vCtx.RepoID, vCtx.RedirectLink) |
||||
} |
||||
|
||||
func VariableUpdate(ctx *context.Context) { |
||||
vCtx, err := getVariablesCtx(ctx) |
||||
if err != nil { |
||||
ctx.ServerError("getVariablesCtx", err) |
||||
return |
||||
} |
||||
|
||||
if ctx.HasError() { // form binding validation error
|
||||
ctx.JSONError(ctx.GetErrMsg()) |
||||
return |
||||
} |
||||
|
||||
shared.UpdateVariable(ctx, vCtx.RedirectLink) |
||||
} |
||||
|
||||
func VariableDelete(ctx *context.Context) { |
||||
vCtx, err := getVariablesCtx(ctx) |
||||
if err != nil { |
||||
ctx.ServerError("getVariablesCtx", err) |
||||
return |
||||
} |
||||
shared.DeleteVariable(ctx, vCtx.RedirectLink) |
||||
} |
@ -0,0 +1,128 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"errors" |
||||
"regexp" |
||||
"strings" |
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions" |
||||
"code.gitea.io/gitea/models/db" |
||||
"code.gitea.io/gitea/modules/context" |
||||
"code.gitea.io/gitea/modules/log" |
||||
"code.gitea.io/gitea/modules/web" |
||||
"code.gitea.io/gitea/services/forms" |
||||
) |
||||
|
||||
func SetVariablesContext(ctx *context.Context, ownerID, repoID int64) { |
||||
variables, err := actions_model.FindVariables(ctx, actions_model.FindVariablesOpts{ |
||||
OwnerID: ownerID, |
||||
RepoID: repoID, |
||||
}) |
||||
if err != nil { |
||||
ctx.ServerError("FindVariables", err) |
||||
return |
||||
} |
||||
ctx.Data["Variables"] = variables |
||||
} |
||||
|
||||
// some regular expression of `variables` and `secrets`
|
||||
// reference to:
|
||||
// https://docs.github.com/en/actions/learn-github-actions/variables#naming-conventions-for-configuration-variables
|
||||
// https://docs.github.com/en/actions/security-guides/encrypted-secrets#naming-your-secrets
|
||||
var ( |
||||
nameRx = regexp.MustCompile("(?i)^[A-Z_][A-Z0-9_]*$") |
||||
forbiddenPrefixRx = regexp.MustCompile("(?i)^GIT(EA|HUB)_") |
||||
|
||||
forbiddenEnvNameCIRx = regexp.MustCompile("(?i)^CI") |
||||
) |
||||
|
||||
func NameRegexMatch(name string) error { |
||||
if !nameRx.MatchString(name) || forbiddenPrefixRx.MatchString(name) { |
||||
log.Error("Name %s, regex match error", name) |
||||
return errors.New("name has invalid character") |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func envNameCIRegexMatch(name string) error { |
||||
if forbiddenEnvNameCIRx.MatchString(name) { |
||||
log.Error("Env Name cannot be ci") |
||||
return errors.New("env name cannot be ci") |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func CreateVariable(ctx *context.Context, ownerID, repoID int64, redirectURL string) { |
||||
form := web.GetForm(ctx).(*forms.EditVariableForm) |
||||
|
||||
if err := NameRegexMatch(form.Name); err != nil { |
||||
ctx.JSONError(err.Error()) |
||||
return |
||||
} |
||||
|
||||
if err := envNameCIRegexMatch(form.Name); err != nil { |
||||
ctx.JSONError(err.Error()) |
||||
return |
||||
} |
||||
|
||||
v, err := actions_model.InsertVariable(ctx, ownerID, repoID, form.Name, ReserveLineBreakForTextarea(form.Data)) |
||||
if err != nil { |
||||
log.Error("InsertVariable error: %v", err) |
||||
ctx.JSONError(ctx.Tr("actions.variables.creation.failed")) |
||||
return |
||||
} |
||||
ctx.Flash.Success(ctx.Tr("actions.variables.creation.success", v.Name)) |
||||
ctx.JSONRedirect(redirectURL) |
||||
} |
||||
|
||||
func UpdateVariable(ctx *context.Context, redirectURL string) { |
||||
id := ctx.ParamsInt64(":variable_id") |
||||
form := web.GetForm(ctx).(*forms.EditVariableForm) |
||||
|
||||
if err := NameRegexMatch(form.Name); err != nil { |
||||
ctx.JSONError(err.Error()) |
||||
return |
||||
} |
||||
|
||||
if err := envNameCIRegexMatch(form.Name); err != nil { |
||||
ctx.JSONError(err.Error()) |
||||
return |
||||
} |
||||
|
||||
ok, err := actions_model.UpdateVariable(ctx, &actions_model.ActionVariable{ |
||||
ID: id, |
||||
Name: strings.ToUpper(form.Name), |
||||
Data: ReserveLineBreakForTextarea(form.Data), |
||||
}) |
||||
if err != nil || !ok { |
||||
log.Error("UpdateVariable error: %v", err) |
||||
ctx.JSONError(ctx.Tr("actions.variables.update.failed")) |
||||
return |
||||
} |
||||
ctx.Flash.Success(ctx.Tr("actions.variables.update.success")) |
||||
ctx.JSONRedirect(redirectURL) |
||||
} |
||||
|
||||
func DeleteVariable(ctx *context.Context, redirectURL string) { |
||||
id := ctx.ParamsInt64(":variable_id") |
||||
|
||||
if _, err := db.DeleteByBean(ctx, &actions_model.ActionVariable{ID: id}); err != nil { |
||||
log.Error("Delete variable [%d] failed: %v", id, err) |
||||
ctx.JSONError(ctx.Tr("actions.variables.deletion.failed")) |
||||
return |
||||
} |
||||
ctx.Flash.Success(ctx.Tr("actions.variables.deletion.success")) |
||||
ctx.JSONRedirect(redirectURL) |
||||
} |
||||
|
||||
func ReserveLineBreakForTextarea(input string) string { |
||||
// Since the content is from a form which is a textarea, the line endings are \r\n.
|
||||
// It's a standard behavior of HTML.
|
||||
// But we want to store them as \n like what GitHub does.
|
||||
// And users are unlikely to really need to keep the \r.
|
||||
// Other than this, we should respect the original content, even leading or trailing spaces.
|
||||
return strings.ReplaceAll(input, "\r\n", "\n") |
||||
} |
@ -0,0 +1,85 @@ |
||||
<h4 class="ui top attached header"> |
||||
{{.locale.Tr "actions.variables.management"}} |
||||
<div class="ui right"> |
||||
<button class="ui primary tiny button show-modal" |
||||
data-modal="#edit-variable-modal" |
||||
data-modal-form.action="{{.Link}}/new" |
||||
data-modal-header="{{.locale.Tr "actions.variables.creation"}}" |
||||
data-modal-dialog-variable-name="" |
||||
data-modal-dialog-variable-data="" |
||||
> |
||||
{{.locale.Tr "actions.variables.creation"}} |
||||
</button> |
||||
</div> |
||||
</h4> |
||||
<div class="ui attached segment"> |
||||
{{if .Variables}} |
||||
<div class="ui list"> |
||||
{{range $i, $v := .Variables}} |
||||
<div class="item gt-df gt-ac gt-fw {{if gt $i 0}} gt-py-4{{end}}"> |
||||
<div class="content gt-f1 gt-ellipsis"> |
||||
<strong>{{$v.Name}}</strong> |
||||
<div class="print meta gt-ellipsis">{{$v.Data}}</div> |
||||
</div> |
||||
<div class="content"> |
||||
<span class="color-text-light-2 gt-mr-5"> |
||||
{{$.locale.Tr "settings.added_on" (DateTime "short" $v.CreatedUnix) | Safe}} |
||||
</span> |
||||
<button class="btn interact-bg gt-p-3 show-modal" |
||||
data-tooltip-content="{{$.locale.Tr "variables.edit"}}" |
||||
data-modal="#edit-variable-modal" |
||||
data-modal-form.action="{{$.Link}}/{{$v.ID}}/edit" |
||||
data-modal-header="{{$.locale.Tr "actions.variables.edit"}}" |
||||
data-modal-dialog-variable-name="{{$v.Name}}" |
||||
data-modal-dialog-variable-data="{{$v.Data}}" |
||||
> |
||||
{{svg "octicon-pencil"}} |
||||
</button> |
||||
<button class="btn interact-bg gt-p-3 link-action" |
||||
data-tooltip-content="{{$.locale.Tr "actions.variables.deletion"}}" |
||||
data-url="{{$.Link}}/{{$v.ID}}/delete" |
||||
data-modal-confirm="{{$.locale.Tr "actions.variables.deletion.description"}}" |
||||
> |
||||
{{svg "octicon-trash"}} |
||||
</button> |
||||
</div> |
||||
</div> |
||||
{{end}} |
||||
</div> |
||||
{{else}} |
||||
{{.locale.Tr "actions.variables.none"}} |
||||
{{end}} |
||||
</div> |
||||
|
||||
{{/** Edit variable dialog */}} |
||||
<div class="ui small modal" id="edit-variable-modal"> |
||||
<div class="header"></div> |
||||
<form class="ui form form-fetch-action" method="post"> |
||||
<div class="content"> |
||||
{{.CsrfTokenHtml}} |
||||
<div class="field"> |
||||
{{.locale.Tr "actions.variables.description"}} |
||||
</div> |
||||
<div class="field"> |
||||
<label for="dialog-variable-name">{{.locale.Tr "name"}}</label> |
||||
<input autofocus required |
||||
name="name" |
||||
id="dialog-variable-name" |
||||
value="{{.name}}" |
||||
pattern="^[a-zA-Z_][a-zA-Z0-9_]*$" |
||||
placeholder="{{.locale.Tr "secrets.creation.name_placeholder"}}" |
||||
> |
||||
</div> |
||||
<div class="field"> |
||||
<label for="dialog-variable-data">{{.locale.Tr "value"}}</label> |
||||
<textarea required |
||||
name="data" |
||||
id="dialog-variable-data" |
||||
placeholder="{{.locale.Tr "secrets.creation.value_placeholder"}}" |
||||
></textarea> |
||||
</div> |
||||
</div> |
||||
{{template "base/modal_actions_confirm" (dict "locale" $.locale "ModalButtonTypes" "confirm")}} |
||||
</form> |
||||
</div> |
||||
|
Loading…
Reference in new issue