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