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>forgejo-federated-star
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