mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-01 20:59:45 +09:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f5cdbd5c1 | |||
| aae0a218c3 | |||
| 9b5c87a6b6 | |||
| 51b93d1d27 | |||
| f3aed8b81d | |||
| fc68608603 | |||
| fe31237fd8 | |||
| a71c5c94c5 | |||
| f81a2ab69a |
@@ -2050,7 +2050,8 @@ LEVEL = Info
|
||||
;; How Gitea deals with missing repository avatars
|
||||
;; none = no avatar will be displayed; random = random avatar will be displayed; image = default image will be used
|
||||
;REPOSITORY_AVATAR_FALLBACK = none
|
||||
;REPOSITORY_AVATAR_FALLBACK_IMAGE = /img/repo_default.png
|
||||
;; Image URL for the "image" fallback, used as-is, defaults to Gitea's builtin repository avatar
|
||||
;REPOSITORY_AVATAR_FALLBACK_IMAGE =
|
||||
;;
|
||||
;; Max Width and Height of uploaded avatars.
|
||||
;; This is to limit the amount of RAM used when resizing the image.
|
||||
|
||||
@@ -200,7 +200,7 @@ func (r *ActionRunner) GenerateAndFillToken() {
|
||||
// CanMatchLabels checks whether the runner's labels can match a job's "runs-on"
|
||||
// See https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idruns-on
|
||||
func (r *ActionRunner) CanMatchLabels(jobRunsOn []string) bool {
|
||||
return !slices.ContainsFunc(jobRunsOn, func(label string) bool { return !util.SliceContainsString(r.AgentLabels, label, true) })
|
||||
return len(jobRunsOn) > 0 && !slices.ContainsFunc(jobRunsOn, func(label string) bool { return !util.SliceContainsString(r.AgentLabels, label, true) })
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -86,4 +86,5 @@ func TestCanMatchLabelsCaseInsensitive(t *testing.T) {
|
||||
runner := &ActionRunner{AgentLabels: []string{"self-hosted", "Linux", "X64"}}
|
||||
assert.True(t, runner.CanMatchLabels([]string{"SELF-HOSTED", "linux"}))
|
||||
assert.False(t, runner.CanMatchLabels([]string{"linux", "arm64"}))
|
||||
assert.False(t, runner.CanMatchLabels(nil))
|
||||
}
|
||||
|
||||
@@ -564,6 +564,12 @@ func (grant *OAuth2Grant) SetNonce(ctx context.Context, nonce string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func UpdateGrantScope(ctx context.Context, grant *OAuth2Grant, newScope string) error {
|
||||
grant.Scope = newScope
|
||||
_, err := db.GetEngine(ctx).ID(grant.ID).Cols("scope").Update(grant)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetOAuth2GrantByID returns the grant with the given ID
|
||||
func GetOAuth2GrantByID(ctx context.Context, id int64) (grant *OAuth2Grant, err error) {
|
||||
grant = new(OAuth2Grant)
|
||||
|
||||
@@ -311,17 +311,17 @@ func (opts *CommitStatusOptions) ToConds() builder.Cond {
|
||||
func (opts *CommitStatusOptions) ToOrders() string {
|
||||
switch opts.SortType {
|
||||
case "oldest":
|
||||
return "created_unix ASC"
|
||||
return "created_unix ASC, `index` ASC"
|
||||
case "recentupdate":
|
||||
return "updated_unix DESC"
|
||||
return "updated_unix DESC, `index` DESC"
|
||||
case "leastupdate":
|
||||
return "updated_unix ASC"
|
||||
return "updated_unix ASC, `index` ASC"
|
||||
case "leastindex":
|
||||
return "`index` DESC"
|
||||
case "highestindex":
|
||||
return "`index` ASC"
|
||||
default:
|
||||
return "created_unix DESC"
|
||||
return "created_unix DESC, `index` DESC" // timestamps have 1s resolution, `index` keeps paging stable
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,28 +32,15 @@ func TestGetCommitStatuses(t *testing.T) {
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 5, int(maxResults))
|
||||
assert.Len(t, statuses, 5)
|
||||
|
||||
assert.Equal(t, "ci/awesomeness", statuses[0].Context)
|
||||
assert.Equal(t, commitstatus.CommitStatusPending, statuses[0].State)
|
||||
var indexes []int64
|
||||
for _, status := range statuses {
|
||||
indexes = append(indexes, status.Index)
|
||||
}
|
||||
assert.Equal(t, []int64{5, 4, 3, 2, 1}, indexes)
|
||||
assert.Equal(t, "deploy/awesomeness", statuses[0].Context)
|
||||
assert.Equal(t, commitstatus.CommitStatusError, statuses[0].State)
|
||||
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[0].APIURL(t.Context()))
|
||||
|
||||
assert.Equal(t, "cov/awesomeness", statuses[1].Context)
|
||||
assert.Equal(t, commitstatus.CommitStatusWarning, statuses[1].State)
|
||||
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[1].APIURL(t.Context()))
|
||||
|
||||
assert.Equal(t, "cov/awesomeness", statuses[2].Context)
|
||||
assert.Equal(t, commitstatus.CommitStatusSuccess, statuses[2].State)
|
||||
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[2].APIURL(t.Context()))
|
||||
|
||||
assert.Equal(t, "ci/awesomeness", statuses[3].Context)
|
||||
assert.Equal(t, commitstatus.CommitStatusFailure, statuses[3].State)
|
||||
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[3].APIURL(t.Context()))
|
||||
|
||||
assert.Equal(t, "deploy/awesomeness", statuses[4].Context)
|
||||
assert.Equal(t, commitstatus.CommitStatusError, statuses[4].State)
|
||||
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[4].APIURL(t.Context()))
|
||||
|
||||
statuses, maxResults, err = db.FindAndCount[git_model.CommitStatus](t.Context(), &git_model.CommitStatusOptions{
|
||||
ListOptions: db.ListOptions{Page: 2, PageSize: 50},
|
||||
RepoID: repo1.ID,
|
||||
|
||||
@@ -407,6 +407,21 @@ func (pr *PullRequest) GetGitHeadRefName() string { // TODO: make it return RefN
|
||||
return git.RefNameFromPullIndex(pr.Index).String()
|
||||
}
|
||||
|
||||
func (pr *PullRequest) GetInstructionsCliArgs() (ret struct {
|
||||
BaseBranchArg string
|
||||
HeadBranchArg string
|
||||
LocalBranchArg string
|
||||
},
|
||||
) {
|
||||
ret.BaseBranchArg = util.ShellEscape(pr.BaseBranch)
|
||||
ret.HeadBranchArg = util.ShellEscape(pr.HeadBranch)
|
||||
ret.LocalBranchArg = ret.HeadBranchArg
|
||||
if pr.HeadRepo != nil && pr.HeadRepoID != pr.BaseRepoID {
|
||||
ret.LocalBranchArg = util.ShellEscape(pr.HeadRepo.OwnerName) + "-" + ret.HeadBranchArg
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// GetReviewCommentsCount returns the number of review comments made on the diff of a PR review (not including comments on commits or issues in a PR)
|
||||
func (pr *PullRequest) GetReviewCommentsCount(ctx context.Context) int {
|
||||
opts := FindCommentsOptions{
|
||||
|
||||
@@ -225,7 +225,6 @@ func replaceScalars(node *yaml.Node, replace func(string) string) {
|
||||
// buildMatrixCombos builds one Job per matrix combination from src, baking the combination into the
|
||||
// strategy and interpolating the name, runs-on and continue-on-error with it.
|
||||
func buildMatrixCombos(jobID string, src *Job, matrixes []map[string]any, gitCtx *model.GithubContext, results map[string]*JobResult, vars map[string]string, inputs map[string]any) ([]*Job, error) {
|
||||
srcRunsOn := model.RunsOnFromNode(src.RawRunsOn)
|
||||
order, names := make([]int, len(matrixes)), make([]string, len(matrixes))
|
||||
for index, matrix := range matrixes {
|
||||
order[index], names[index] = index, matrixName(matrix)
|
||||
@@ -259,14 +258,15 @@ func buildMatrixCombos(jobID string, src *Job, matrixes []map[string]any, gitCtx
|
||||
if err := evaluator.EvaluateYamlNode(&rawRunsOn); err != nil {
|
||||
return nil, fmt.Errorf("interpolate runs-on for job %q: %w", jobID, err)
|
||||
}
|
||||
runsOn := model.RunsOnFromNode(rawRunsOn)
|
||||
if len(runsOn) == 0 && len(srcRunsOn) > 0 { // match no runner rather than every runner
|
||||
runsOn = []string{""}
|
||||
if rawRunsOn.Kind != 0 && runsOnProblem(&rawRunsOn) != "" {
|
||||
combo.RawRunsOn = rawRunsOn
|
||||
} else {
|
||||
runsOn := model.RunsOnFromNode(rawRunsOn)
|
||||
for i := range runsOn {
|
||||
runsOn[i] = escapeExpressions(runsOn[i])
|
||||
}
|
||||
combo.RawRunsOn = model.RunsOnNode(runsOn, "")
|
||||
}
|
||||
for i := range runsOn {
|
||||
runsOn[i] = escapeExpressions(runsOn[i])
|
||||
}
|
||||
combo.RawRunsOn = model.RunsOnNode(runsOn, "")
|
||||
}
|
||||
if err := evaluator.EvaluateYamlNode(&combo.RawContinueOnError); err != nil {
|
||||
return nil, fmt.Errorf("evaluate continue-on-error for job %q: %w", jobID, err)
|
||||
|
||||
@@ -290,17 +290,26 @@ func TestParseInterpolatesRunName(t *testing.T) {
|
||||
assert.Empty(t, result[0].RunName)
|
||||
}
|
||||
|
||||
func TestParseRunsOnFromJSONArray(t *testing.T) {
|
||||
func TestParseRunsOnFromJSONKeepsWhatGitHubRejectsForTheJobToFail(t *testing.T) {
|
||||
content := []byte("on: push\njobs:\n build:\n runs-on: ${{ fromJSON(vars.RUNNER) }}\n steps: [{run: echo}]\n")
|
||||
_, err := Parse(content)
|
||||
require.NoError(t, err)
|
||||
for runner, want := range map[string][]string{`["self-hosted", "linux"]`: {"self-hosted", "linux"}, "[]": {""}} {
|
||||
for runner, want := range map[string][]string{`["self-hosted", "linux"]`: {"self-hosted", "linux"}, "[]": {}, "{}": {}} {
|
||||
result, err := Parse(content, WithGitContext(&model.GithubContext{}), WithVars(map[string]string{"RUNNER": runner}))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result, 1)
|
||||
_, job := result[0].Job()
|
||||
assert.Equal(t, want, job.RunsOn(), runner)
|
||||
}
|
||||
for runner, problem := range map[string]string{`["a"]`: "", `""`: "Unexpected value ''", `[["a"]]`: "A sequence was not expected"} {
|
||||
result, err := Parse(content, WithGitContext(&model.GithubContext{}), WithVars(map[string]string{"RUNNER": runner}))
|
||||
require.NoError(t, err)
|
||||
payload, err := result[0].Marshal()
|
||||
require.NoError(t, err)
|
||||
_, job, err := ParseRawSingleWorkflow(payload)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, problem, job.RunsOnProblem(), runner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobFieldsWithoutMatrix(t *testing.T) {
|
||||
@@ -458,6 +467,37 @@ func TestReadWorkflowJobConditionContexts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkflowStaticJobKindAndRunsOnLikeGitHub(t *testing.T) {
|
||||
for job, want := range map[string]string{
|
||||
"{runs-on: x, steps: [{run: echo}]}": "",
|
||||
"{uses: o/r/.gitea/workflows/c.yml@main}": "",
|
||||
"{runs-on: []}": "",
|
||||
"{runs-on: {}}": "",
|
||||
"{runs-on: {group: org/g, labels: [a, 1]}}": "",
|
||||
"{runs-on: {group: '${{ vars.G }}'}}": "",
|
||||
"{steps: [{run: echo}]}": "Required property is missing: runs-on",
|
||||
"{with: {}}": "Required property is missing: uses",
|
||||
"{runs-on: x, uses: o/r/.gitea/workflows/c.yml@main}": "Unexpected value 'uses'",
|
||||
"{Runs-On: x}": "Unexpected value 'Runs-On'",
|
||||
"{runs-on: ~}": "runs-on: Unexpected value ''",
|
||||
"{runs-on: ['']}": "runs-on: Unexpected value ''",
|
||||
"{runs-on: [[a]]}": "runs-on: A sequence was not expected",
|
||||
"{runs-on: {labels: {a: b}}}": "runs-on: A mapping was not expected",
|
||||
"{runs-on: {foo: x}}": "runs-on: Unexpected value 'foo'",
|
||||
"{runs-on: {group: org/}}": "runs-on: Invalid runs-on group name 'org/'.",
|
||||
"{runs-on: {group: a/b/c}}": "runs-on: Invalid runs-on group name 'a/b/c'. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.",
|
||||
"{if: true}": "There's not enough info to determine what you meant. Add one of these properties: " +
|
||||
"cancel-timeout-minutes, container, continue-on-error, defaults, env, environment, outputs, runs-on, secrets, services, snapshot, steps, timeout-minutes, uses, with",
|
||||
} {
|
||||
_, err := ValidateWorkflowStatic([]byte("on: push\njobs:\n build: " + job + "\n"))
|
||||
if want == "" {
|
||||
assert.NoError(t, err, job)
|
||||
} else {
|
||||
assert.EqualError(t, err, "job build: "+want, job)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsUnevaluatedMatrixFilters(t *testing.T) {
|
||||
for _, filter := range []string{"include", "exclude"} {
|
||||
t.Run(filter, func(t *testing.T) {
|
||||
|
||||
@@ -174,6 +174,14 @@ func (j *Job) EraseNeeds() *Job {
|
||||
return j
|
||||
}
|
||||
|
||||
// RunsOnProblem returns github.com's error for the job's runs-on, "" if valid.
|
||||
func (j *Job) RunsOnProblem() string {
|
||||
if j.RawRunsOn.Kind == 0 {
|
||||
return ""
|
||||
}
|
||||
return runsOnProblem(&j.RawRunsOn)
|
||||
}
|
||||
|
||||
// RunsOn returns the labels Gitea matches runners against, unescaped like DisplayName.
|
||||
func (j *Job) RunsOn() []string {
|
||||
runsOn := model.RunsOnFromNode(j.RawRunsOn)
|
||||
|
||||
@@ -7,10 +7,13 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/actionslib/pkg/expreval"
|
||||
"gitea.dev/actionslib/pkg/exprparser"
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
// jobConditionContexts are what github.com gives `jobs.<job_id>.if`, which it decides before the matrix, plus the `gitea` alias.
|
||||
@@ -21,7 +24,7 @@ func ValidateWorkflowStatic(content []byte) ([]*Event, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Keep unknown and case-distinct keys accepted for existing Gitea workflows.
|
||||
// Keep unknown and case-distinct keys outside of jobs accepted for existing Gitea workflows.
|
||||
workflow, err := readWorkflowDoc(doc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -33,6 +36,9 @@ func ValidateWorkflowStatic(content []byte) ([]*Event, error) {
|
||||
if err := validateWorkflowStructure(workflow); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateJobKinds(doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var header struct {
|
||||
RunName string `yaml:"run-name"`
|
||||
}
|
||||
@@ -76,7 +82,6 @@ func validateWorkflowStructure(workflow *model.Workflow) error {
|
||||
if job == nil {
|
||||
return fmt.Errorf("job %q has no configuration", id)
|
||||
}
|
||||
// a job without runs-on is accepted and runs on any runner, github.com rejects it
|
||||
for _, dependency := range job.Needs() {
|
||||
if _, ok := workflow.Jobs[dependency]; !ok {
|
||||
return fmt.Errorf("job %q needs unknown job %q", id, dependency)
|
||||
@@ -110,3 +115,116 @@ func validateWorkflowStructure(workflow *model.Workflow) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// job keys of github.com's workflow schema, by the kind of job allowing them
|
||||
var (
|
||||
stepsJobKeys = []string{"cancel-timeout-minutes", "container", "continue-on-error", "defaults", "env", "environment", "outputs", "runs-on", "services", "snapshot", "steps", "timeout-minutes"}
|
||||
callerJobKeys = []string{"secrets", "uses", "with"}
|
||||
sharedJobKeys = []string{"concurrency", "if", "name", "needs", "permissions", "strategy"}
|
||||
)
|
||||
|
||||
// validateJobKinds applies github.com's job kinds, decided by the first kind-specific key.
|
||||
func validateJobKinds(doc *yaml.Node) error {
|
||||
jobs := mappingValue(doc.Content[0], "jobs")
|
||||
for i := 0; i+1 < len(jobs.Content); i += 2 {
|
||||
id, job := jobs.Content[i].Value, jobs.Content[i+1]
|
||||
var required string
|
||||
for j := 0; j+1 < len(job.Content); j += 2 {
|
||||
key := job.Content[j].Value
|
||||
isStepsKey, isCallerKey := slices.Contains(stepsJobKeys, key), slices.Contains(callerJobKeys, key)
|
||||
switch {
|
||||
case required == "runs-on" && isCallerKey, required == "uses" && isStepsKey, !isStepsKey && !isCallerKey && !slices.Contains(sharedJobKeys, key):
|
||||
return fmt.Errorf("job %s: Unexpected value '%s'", id, key)
|
||||
case required == "" && isStepsKey:
|
||||
required = "runs-on"
|
||||
case required == "" && isCallerKey:
|
||||
required = "uses"
|
||||
}
|
||||
}
|
||||
if required == "" {
|
||||
keys := slices.Concat(stepsJobKeys, callerJobKeys)
|
||||
slices.Sort(keys)
|
||||
return fmt.Errorf("job %s: There's not enough info to determine what you meant. Add one of these properties: %s", id, strings.Join(keys, ", "))
|
||||
}
|
||||
value := mappingValue(job, required)
|
||||
if value == nil {
|
||||
return fmt.Errorf("job %s: Required property is missing: %s", id, required)
|
||||
}
|
||||
if required != "runs-on" {
|
||||
continue
|
||||
}
|
||||
if problem := runsOnProblem(value); problem != "" {
|
||||
return fmt.Errorf("job %s: runs-on: %s", id, problem)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runsOnProblem returns github.com's schema error for a runs-on, "" if valid.
|
||||
func runsOnProblem(node *yaml.Node) string {
|
||||
if node.Kind != yaml.MappingNode {
|
||||
return runsOnLabelsProblem(node)
|
||||
}
|
||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||
var problem string
|
||||
switch key := node.Content[i].Value; key {
|
||||
case "labels":
|
||||
problem = runsOnLabelsProblem(node.Content[i+1])
|
||||
case "group":
|
||||
problem = runsOnGroupProblem(node.Content[i+1])
|
||||
default:
|
||||
problem = fmt.Sprintf("Unexpected value '%s'", key)
|
||||
}
|
||||
if problem != "" {
|
||||
return problem
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func runsOnLabelsProblem(node *yaml.Node) string {
|
||||
if node.Kind != yaml.SequenceNode {
|
||||
return nonEmptyStringProblem(node)
|
||||
}
|
||||
for _, label := range node.Content {
|
||||
if problem := nonEmptyStringProblem(label); problem != "" {
|
||||
return problem
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func runsOnGroupProblem(node *yaml.Node) string {
|
||||
if problem := nonEmptyStringProblem(node); problem != "" || hasExpression(node.Value) {
|
||||
return problem
|
||||
}
|
||||
switch prefix, name, found := strings.Cut(node.Value, "/"); {
|
||||
case found && name == "":
|
||||
return fmt.Sprintf("Invalid runs-on group name '%s'.", node.Value)
|
||||
case found && (strings.Contains(name, "/") || !slices.Contains([]string{"org", "organization", "ent", "enterprise"}, prefix)):
|
||||
return fmt.Sprintf("Invalid runs-on group name '%s'. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.", node.Value)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// nonEmptyStringProblem mirrors github.com's non-empty-string, which also accepts non-string scalars.
|
||||
func nonEmptyStringProblem(node *yaml.Node) string {
|
||||
switch {
|
||||
case node.Kind == yaml.SequenceNode:
|
||||
return "A sequence was not expected"
|
||||
case node.Kind == yaml.MappingNode:
|
||||
return "A mapping was not expected"
|
||||
case node.Value == "" || node.ShortTag() == "!!null":
|
||||
return "Unexpected value ''"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func mappingValue(node *yaml.Node, key string) *yaml.Node {
|
||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||
if node.Content[i].Value == key {
|
||||
return node.Content[i+1]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -30,18 +30,23 @@ jobs:
|
||||
|
||||
func TestReadWorkflowEventsStaticErrors(t *testing.T) {
|
||||
for content, static := range map[string]bool{
|
||||
"on: push\njobs: {}": true,
|
||||
"on: push\njobs: {test: {needs: absent}}": true,
|
||||
"on: push\njobs: {one: {needs: two}, two: {needs: one}}": true,
|
||||
"on: push\njobs: {test: {strategy: {matrix: {os: []}}}}": true,
|
||||
"on: push\nrun-name: ${{ secrets.TOKEN }}\njobs: {test: {}}": true,
|
||||
"on: push\nrun-name: ${{ fromJSON(inputs.x) }}\njobs: {test: {steps: [{run: echo}]}}": false,
|
||||
"on: push\njobs: {}": true,
|
||||
"on: push\njobs: {test: {runs-on: x, needs: absent}}": true,
|
||||
"on: push\njobs: {one: {runs-on: x, needs: two}, two: {runs-on: x, needs: one}}": true,
|
||||
"on: push\njobs: {test: {runs-on: x, strategy: {matrix: {os: []}}}}": true,
|
||||
"on: push\nrun-name: ${{ secrets.TOKEN }}\njobs: {test: {runs-on: x}}": true,
|
||||
"on: push\njobs: {test: {steps: [{run: echo}]}}": true,
|
||||
"on: push\nrun-name: ${{ fromJSON(inputs.x) }}\njobs: {test: {runs-on: x, steps: [{run: echo}]}}": false,
|
||||
} {
|
||||
_, gotStatic, err := readWorkflowEvents([]byte(content))
|
||||
require.Error(t, err, content)
|
||||
assert.Equal(t, static, gotStatic, content)
|
||||
}
|
||||
for _, content := range []string{"on: push\njobs: {test: {steps: [{run: echo}]}}", "on: push\nrun-name: ${{ github.ref }}\njobs: {test: {}}"} {
|
||||
for _, content := range []string{
|
||||
"on: push\njobs: {test: {runs-on: x, steps: [{run: echo}]}}",
|
||||
"on: push\nrun-name: ${{ github.ref }}\njobs: {test: {runs-on: x}}",
|
||||
"on: push\njobs: {call: {uses: ./.gitea/workflows/called.yml}}",
|
||||
} {
|
||||
_, _, err := readWorkflowEvents([]byte(content))
|
||||
assert.NoError(t, err, content)
|
||||
}
|
||||
|
||||
@@ -49,8 +49,8 @@ type Command struct {
|
||||
cmd *process.Cmd
|
||||
|
||||
cmdCtx context.Context
|
||||
cmdCancel process.CancelCauseFunc
|
||||
cmdFinished process.FinishedFunc
|
||||
cmdCtxCancel process.CancelCauseFunc
|
||||
cmdFinished func()
|
||||
cmdStartTime time.Time
|
||||
|
||||
pipelineFunc func(Context) error
|
||||
@@ -428,19 +428,24 @@ func (c *Command) Start(ctx context.Context) (retErr error) {
|
||||
if c.callerInfo == "" {
|
||||
c.WithParentCallerInfo()
|
||||
}
|
||||
|
||||
// these logs are for debugging purposes only, so no guarantee of correctness or stability
|
||||
desc := fmt.Sprintf("git.Run(by:%s, repo:%s): %s", c.callerInfo, logArgSanitize(c.gitDir), cmdLogString)
|
||||
log.Debug("git.Command: %s", desc)
|
||||
|
||||
_, span := gtprof.GetTracer().Start(ctx, gtprof.TraceSpanGitRun)
|
||||
defer span.End()
|
||||
span.SetAttributeString(gtprof.TraceAttrFuncCaller, c.callerInfo)
|
||||
span.SetAttributeString(gtprof.TraceAttrGitCommand, cmdLogString)
|
||||
|
||||
var cmdCtxFinished func()
|
||||
if c.cmdTimeout <= 0 {
|
||||
c.cmdCtx, c.cmdCancel, c.cmdFinished = process.GetManager().AddContext(ctx, desc)
|
||||
c.cmdCtx, c.cmdCtxCancel, cmdCtxFinished = process.GetManager().AddContext(ctx, desc)
|
||||
} else {
|
||||
c.cmdCtx, c.cmdCancel, c.cmdFinished = process.GetManager().AddContextTimeout(ctx, c.cmdTimeout, desc)
|
||||
c.cmdCtx, c.cmdCtxCancel, cmdCtxFinished = process.GetManager().AddContextTimeout(ctx, c.cmdTimeout, desc)
|
||||
}
|
||||
c.cmdFinished = func() {
|
||||
cmdCtxFinished()
|
||||
span.End()
|
||||
}
|
||||
|
||||
c.cmdStartTime = time.Now()
|
||||
|
||||
@@ -27,6 +27,6 @@ func (c *cmdContext) CancelPipeline(err error) error {
|
||||
// * context canceled by pipeline caller with/without error (normal cancellation)
|
||||
// * context canceled by parent context (still context.Canceled error)
|
||||
// * other causes
|
||||
c.cmd.cmdCancel(pipelineError{err})
|
||||
c.cmd.cmdCtxCancel(pipelineError{err})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/cache"
|
||||
"github.com/go-git/go-git/v5/storage/filesystem"
|
||||
"github.com/go-git/go-git/v5/storage/filesystem/dotgit"
|
||||
)
|
||||
|
||||
const isGogit = true
|
||||
@@ -31,8 +32,8 @@ type Repository struct {
|
||||
gogitStorage *reindexingStorage
|
||||
}
|
||||
|
||||
// reindexingStorage picks up packs that git wrote after go-git loaded its index
|
||||
// https://github.com/go-git/go-git/issues/2439
|
||||
// reindexingStorage reloads the pack index when git added or removed packs after go-git loaded it
|
||||
// https://github.com/go-git/go-git/issues/2439 https://github.com/go-git/go-git/issues/1623
|
||||
type reindexingStorage struct {
|
||||
*filesystem.Storage
|
||||
packs []plumbing.Hash
|
||||
@@ -40,7 +41,7 @@ type reindexingStorage struct {
|
||||
|
||||
func (s *reindexingStorage) EncodedObject(t plumbing.ObjectType, h plumbing.Hash) (plumbing.EncodedObject, error) {
|
||||
obj, err := s.Storage.EncodedObject(t, h)
|
||||
if !errors.Is(err, plumbing.ErrObjectNotFound) {
|
||||
if !errors.Is(err, plumbing.ErrObjectNotFound) && !errors.Is(err, dotgit.ErrPackfileNotFound) {
|
||||
return obj, err
|
||||
}
|
||||
packs, _ := s.ObjectPacks()
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -39,6 +41,26 @@ func TestRepository_GetBranches(t *testing.T) {
|
||||
assert.ElementsMatch(t, []string{}, branches)
|
||||
}
|
||||
|
||||
func TestGetBranchNamesAfterRepack(t *testing.T) {
|
||||
repoDir := t.TempDir()
|
||||
require.NoError(t, gitcmd.NewCommand("init", "--bare").AddDynamicArguments(repoDir).Run(t.Context()))
|
||||
for _, from := range []string{"", "from refs/heads/main^0\n"} {
|
||||
stdin := "commit refs/heads/main\ncommitter a <a@a> 0 +0000\ndata 0\n" + from
|
||||
require.NoError(t, gitcmd.NewCommand("fast-import").WithDir(repoDir).WithStdinBytes([]byte(stdin)).Run(t.Context()))
|
||||
require.NoError(t, gitcmd.NewCommand("repack", "-d").WithDir(repoDir).Run(t.Context()))
|
||||
}
|
||||
|
||||
repo, err := OpenRepositoryLocal(t.Context(), repoDir)
|
||||
require.NoError(t, err)
|
||||
defer repo.Close()
|
||||
require.False(t, repo.IsObjectExist(t.Context(), "0000000000000000000000000000000000000001"))
|
||||
require.NoError(t, gitcmd.NewCommand("repack", "-a", "-d").WithDir(repoDir).Run(t.Context()))
|
||||
|
||||
branches, _, err := repo.GetBranchNames(t.Context(), 0, 0)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"main"}, branches)
|
||||
}
|
||||
|
||||
func BenchmarkRepository_GetBranches(b *testing.B) {
|
||||
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
|
||||
bareRepo1, err := OpenRepositoryLocal(b.Context(), bareRepo1Path)
|
||||
|
||||
@@ -122,6 +122,8 @@ func (t *Tracer) Start(ctx context.Context, spanName string) (context.Context, *
|
||||
ts.parent = parentSpan
|
||||
}
|
||||
|
||||
// FIXME: this ctx handling is not right. The returned ctx should inherit the ctx passed in, but not from span's internal contexts
|
||||
// The returned ctx only needs to inherit the values of the internal contexts of spans
|
||||
parentCtx := ctx
|
||||
for internalSpanIdx, tsp := range starters {
|
||||
var internalSpan traceSpanInternal
|
||||
|
||||
@@ -6,14 +6,17 @@ package gtprof
|
||||
// Some interesting names could be found in https://github.com/open-telemetry/opentelemetry-go/tree/main/semconv
|
||||
|
||||
const (
|
||||
TraceSpanContext = "context"
|
||||
TraceSpanHTTP = "http"
|
||||
TraceSpanGitRun = "git-run"
|
||||
TraceSpanDatabase = "database"
|
||||
)
|
||||
|
||||
const (
|
||||
TraceAttrFuncCaller = "func.caller"
|
||||
TraceAttrDbSQL = "db.sql"
|
||||
TraceAttrGitCommand = "git.command"
|
||||
TraceAttrHTTPRoute = "http.route"
|
||||
TraceAttrGeneralName = "general.name"
|
||||
TraceAttrGeneralDesc = "general.desc"
|
||||
TraceAttrFuncCaller = "func.caller"
|
||||
TraceAttrDbSQL = "db.sql"
|
||||
TraceAttrGitCommand = "git.command"
|
||||
TraceAttrHTTPRoute = "http.route"
|
||||
)
|
||||
|
||||
@@ -45,6 +45,10 @@ func MarshalKeepOptionalEmpty(v any) ([]byte, error) {
|
||||
return jsonv2.Marshal(v, jsonV2.marshalKeepOptionalEmptyOptions)
|
||||
}
|
||||
|
||||
func MarshalDeterministic(v any) ([]byte, error) {
|
||||
return jsonv2.Marshal(v, jsonV2.marshalOptions, jsonv2.Deterministic(true))
|
||||
}
|
||||
|
||||
func (j *JSONv2) Marshal(v any) ([]byte, error) {
|
||||
return jsonv2.Marshal(v, j.marshalOptions)
|
||||
}
|
||||
|
||||
@@ -121,6 +121,7 @@ type PackageMetadataVersion struct {
|
||||
Engines map[string]string `json:"engines,omitempty"`
|
||||
CPU []string `json:"cpu,omitempty"`
|
||||
OS []string `json:"os,omitempty"`
|
||||
Libc []string `json:"libc,omitempty"`
|
||||
Directories map[string]string `json:"directories,omitempty"`
|
||||
Funding any `json:"funding,omitempty"`
|
||||
AcceptDependencies map[string]string `json:"acceptDependencies,omitempty"`
|
||||
@@ -129,12 +130,9 @@ type PackageMetadataVersion struct {
|
||||
|
||||
// PackageDistribution https://github.com/npm/registry/blob/master/docs/REGISTRY-API.md#version
|
||||
type PackageDistribution struct {
|
||||
Integrity string `json:"integrity"`
|
||||
Shasum string `json:"shasum"`
|
||||
Tarball string `json:"tarball"`
|
||||
FileCount int `json:"fileCount,omitempty"`
|
||||
UnpackedSize int `json:"unpackedSize,omitempty"`
|
||||
NpmSignature string `json:"npm-signature,omitempty"`
|
||||
Integrity string `json:"integrity"`
|
||||
Shasum string `json:"shasum"`
|
||||
Tarball string `json:"tarball"`
|
||||
}
|
||||
|
||||
type PackageSearch struct {
|
||||
@@ -226,7 +224,7 @@ func (r *Repository) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
// Bin maps command names to executable files. npm also allows a single string,
|
||||
// in which case the command is named after the package (resolved in ParsePackage).
|
||||
// in which case the command is named after the package (resolved in parseUploadPackage).
|
||||
type Bin map[string]string
|
||||
|
||||
// UnmarshalJSON is needed because the bin field can be a string or an object.
|
||||
@@ -264,7 +262,7 @@ type packageUpload struct {
|
||||
// is non-nil on success; a body without `_attachments` is a deprecate request,
|
||||
// otherwise it is a "publish".
|
||||
func ParseUpload(r io.Reader) (*Package, *PackageDeprecation, error) {
|
||||
body, err := io.ReadAll(io.LimitReader(r, 10*1024*1024))
|
||||
body, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -280,16 +278,6 @@ func ParseUpload(r io.Reader) (*Package, *PackageDeprecation, error) {
|
||||
return p, nil, err
|
||||
}
|
||||
|
||||
// ParsePackage parses a npm publish PUT body. Bodies without `_attachments`
|
||||
// surface as ErrInvalidAttachment once name/version validation has passed.
|
||||
func ParsePackage(r io.Reader) (*Package, error) {
|
||||
var upload packageUpload
|
||||
if err := json.NewDecoder(r).Decode(&upload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseUploadPackage(&upload)
|
||||
}
|
||||
|
||||
// parseUploadPackage builds a Package from a decoded publish body.
|
||||
func parseUploadPackage(upload *packageUpload) (*Package, error) {
|
||||
for _, meta := range upload.Versions {
|
||||
@@ -343,6 +331,7 @@ func parseUploadPackage(upload *packageUpload) (*Package, error) {
|
||||
Engines: meta.Engines,
|
||||
CPU: meta.CPU,
|
||||
OS: meta.OS,
|
||||
Libc: meta.Libc,
|
||||
Directories: meta.Directories,
|
||||
Funding: meta.Funding,
|
||||
AcceptDependencies: meta.AcceptDependencies,
|
||||
@@ -356,12 +345,12 @@ func parseUploadPackage(upload *packageUpload) (*Package, error) {
|
||||
|
||||
p.Filename = strings.ToLower(fmt.Sprintf("%s-%s.tgz", name, p.Version))
|
||||
|
||||
attachment := func() *PackageAttachment {
|
||||
attachment := upload.Attachments[meta.Name+"-"+meta.Version+".tgz"] // not the sigstore bundle of `npm publish --provenance`
|
||||
if attachment == nil && len(upload.Attachments) == 1 {
|
||||
for _, a := range upload.Attachments {
|
||||
return a
|
||||
attachment = a
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
}
|
||||
if attachment == nil || len(attachment.Data) == 0 {
|
||||
return nil, ErrInvalidAttachment
|
||||
}
|
||||
@@ -393,8 +382,6 @@ func parseUploadPackage(upload *packageUpload) (*Package, error) {
|
||||
return nil, ErrInvalidIntegrity
|
||||
}
|
||||
|
||||
// Derive _hasShrinkwrap and hasInstallScript from the tarball; the
|
||||
// packument can lie about either.
|
||||
p.Metadata.HasShrinkwrap, p.Metadata.HasInstallScript = inspectTarball(data)
|
||||
|
||||
return p, nil
|
||||
@@ -410,11 +397,7 @@ const maxNpmTarballScanBytes = int64(32 * 1024 * 1024) // 32 MiB
|
||||
// maxNpmPackageJSONBytes caps the package.json bytes decoded from the tarball.
|
||||
const maxNpmPackageJSONBytes = int64(1 * 1024 * 1024) // 1 MiB
|
||||
|
||||
// inspectTarball reports hasShrinkwrap (presence of package/npm-shrinkwrap.json)
|
||||
// and hasInstallScript (package/package.json declares any of preinstall,
|
||||
// install, postinstall). Both must be derived server-side because the client
|
||||
// can lie in the packument. Any read/decode error yields (false, false) so a
|
||||
// malformed archive does not block publishing.
|
||||
// inspectTarball trusts the tarball over the client's packument, read errors yield zero values to not block publishing
|
||||
func inspectTarball(data []byte) (hasShrinkwrap, hasInstallScript bool) {
|
||||
gr, err := gzip.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
@@ -422,11 +405,16 @@ func inspectTarball(data []byte) (hasShrinkwrap, hasInstallScript bool) {
|
||||
}
|
||||
defer gr.Close()
|
||||
|
||||
var hasGypFile bool
|
||||
var pkg struct {
|
||||
Scripts map[string]string `json:"scripts"`
|
||||
Gypfile any `json:"gypfile"`
|
||||
}
|
||||
tr := tar.NewReader(io.LimitReader(gr, maxNpmTarballScanBytes))
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err != nil {
|
||||
return hasShrinkwrap, hasInstallScript
|
||||
break
|
||||
}
|
||||
// npm pack puts files under a single root directory (usually "package/").
|
||||
name := strings.TrimPrefix(hdr.Name, "./")
|
||||
@@ -436,30 +424,14 @@ func inspectTarball(data []byte) (hasShrinkwrap, hasInstallScript bool) {
|
||||
switch {
|
||||
case strings.HasSuffix(name, "/npm-shrinkwrap.json"):
|
||||
hasShrinkwrap = true
|
||||
case strings.HasSuffix(name, ".gyp"):
|
||||
hasGypFile = true
|
||||
case strings.HasSuffix(name, "/package.json"):
|
||||
hasInstallScript = tarballDeclaresInstallScript(tr)
|
||||
}
|
||||
if hasShrinkwrap && hasInstallScript {
|
||||
return hasShrinkwrap, hasInstallScript
|
||||
_ = json.NewDecoder(io.LimitReader(tr, maxNpmPackageJSONBytes)).Decode(&pkg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tarballDeclaresInstallScript reports whether a package.json declares any
|
||||
// of preinstall, install, postinstall.
|
||||
func tarballDeclaresInstallScript(r io.Reader) bool {
|
||||
var pkg struct {
|
||||
Scripts map[string]string `json:"scripts"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r, maxNpmPackageJSONBytes)).Decode(&pkg); err != nil {
|
||||
return false
|
||||
}
|
||||
for _, name := range []string{"preinstall", "install", "postinstall"} {
|
||||
if strings.TrimSpace(pkg.Scripts[name]) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
// npm publish adds "install": "node-gyp rebuild" for a root *.gyp file to the manifest, but not to the tarball
|
||||
return hasShrinkwrap, strings.TrimSpace(pkg.Scripts["preinstall"]+pkg.Scripts["install"]+pkg.Scripts["postinstall"]) != "" || hasGypFile && pkg.Gypfile != false
|
||||
}
|
||||
|
||||
func validateName(name string) bool {
|
||||
|
||||
@@ -41,21 +41,20 @@ func TestParsePackage(t *testing.T) {
|
||||
integrity := "sha512-" + base64Sha512(dataBytes)
|
||||
|
||||
t.Run("InvalidUpload", func(t *testing.T) {
|
||||
p, err := ParsePackage(bytes.NewReader([]byte{0}))
|
||||
p, _, err := ParseUpload(bytes.NewReader([]byte{0}))
|
||||
assert.Nil(t, p)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("InvalidUploadNoData", func(t *testing.T) {
|
||||
b, _ := json.Marshal(packageUpload{})
|
||||
p, err := ParsePackage(bytes.NewReader(b))
|
||||
p, err := parseUploadPackage(&packageUpload{})
|
||||
assert.Nil(t, p)
|
||||
assert.ErrorIs(t, err, ErrInvalidPackage)
|
||||
})
|
||||
|
||||
t.Run("InvalidPackageName", func(t *testing.T) {
|
||||
test := func(t *testing.T, name string) {
|
||||
b, _ := json.Marshal(packageUpload{
|
||||
p, err := parseUploadPackage(&packageUpload{
|
||||
PackageMetadata: PackageMetadata{
|
||||
ID: name,
|
||||
Name: name,
|
||||
@@ -66,8 +65,6 @@ func TestParsePackage(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(b))
|
||||
assert.Nil(t, p)
|
||||
assert.ErrorIs(t, err, ErrInvalidPackageName)
|
||||
}
|
||||
@@ -94,7 +91,7 @@ func TestParsePackage(t *testing.T) {
|
||||
|
||||
t.Run("ValidPackageName", func(t *testing.T) {
|
||||
test := func(t *testing.T, name string) {
|
||||
b, _ := json.Marshal(packageUpload{
|
||||
p, err := parseUploadPackage(&packageUpload{
|
||||
PackageMetadata: PackageMetadata{
|
||||
ID: name,
|
||||
Name: name,
|
||||
@@ -105,8 +102,6 @@ func TestParsePackage(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(b))
|
||||
assert.Nil(t, p)
|
||||
assert.ErrorIs(t, err, ErrInvalidPackageVersion)
|
||||
}
|
||||
@@ -125,7 +120,7 @@ func TestParsePackage(t *testing.T) {
|
||||
|
||||
t.Run("InvalidPackageVersion", func(t *testing.T) {
|
||||
version := "first-version"
|
||||
b, _ := json.Marshal(packageUpload{
|
||||
p, err := parseUploadPackage(&packageUpload{
|
||||
PackageMetadata: PackageMetadata{
|
||||
ID: packageFullName,
|
||||
Name: packageFullName,
|
||||
@@ -137,8 +132,6 @@ func TestParsePackage(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(b))
|
||||
assert.Nil(t, p)
|
||||
assert.ErrorIs(t, err, ErrInvalidPackageVersion)
|
||||
})
|
||||
@@ -160,7 +153,7 @@ func TestParsePackage(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(b))
|
||||
p, _, err := ParseUpload(bytes.NewReader(b))
|
||||
assert.Nil(t, p)
|
||||
assert.ErrorIs(t, err, ErrInvalidAttachment)
|
||||
})
|
||||
@@ -185,7 +178,7 @@ func TestParsePackage(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(b))
|
||||
p, _, err := ParseUpload(bytes.NewReader(b))
|
||||
assert.Nil(t, p)
|
||||
assert.ErrorIs(t, err, ErrInvalidAttachment)
|
||||
})
|
||||
@@ -213,7 +206,7 @@ func TestParsePackage(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(b))
|
||||
p, _, err := ParseUpload(bytes.NewReader(b))
|
||||
assert.Nil(t, p)
|
||||
assert.ErrorIs(t, err, ErrInvalidIntegrity)
|
||||
})
|
||||
@@ -241,7 +234,7 @@ func TestParsePackage(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(b))
|
||||
p, _, err := ParseUpload(bytes.NewReader(b))
|
||||
assert.Nil(t, p)
|
||||
assert.ErrorIs(t, err, ErrInvalidIntegrity)
|
||||
})
|
||||
@@ -281,10 +274,13 @@ func TestParsePackage(t *testing.T) {
|
||||
filename: {
|
||||
Data: data,
|
||||
},
|
||||
packageFullName + "-" + packageVersion + ".sigstore": {
|
||||
Data: "{}",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(b))
|
||||
p, _, err := ParseUpload(bytes.NewReader(b))
|
||||
assert.NotNil(t, p)
|
||||
assert.NoError(t, err)
|
||||
|
||||
@@ -329,7 +325,7 @@ func TestParsePackage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}`
|
||||
p, err := ParsePackage(strings.NewReader(packageJSON))
|
||||
p, _, err := ParseUpload(strings.NewReader(packageJSON))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "MIT", string(p.Metadata.License))
|
||||
})
|
||||
@@ -354,7 +350,7 @@ func TestParsePackage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}`
|
||||
p, err := ParsePackage(strings.NewReader(packageJSON))
|
||||
p, _, err := ParseUpload(strings.NewReader(packageJSON))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://gitea.io/gitea/test.git", p.Metadata.Repository.URL)
|
||||
// a string bin is named after the package
|
||||
@@ -426,6 +422,15 @@ func TestInspectTarball(t *testing.T) {
|
||||
// npm pack sometimes emits "./package/..." entries.
|
||||
wantShrinkwrap: true,
|
||||
},
|
||||
{
|
||||
name: "gyp file implies node-gyp install",
|
||||
files: map[string]string{"package/binding.gyp": "{}"},
|
||||
wantInstaller: true,
|
||||
},
|
||||
{
|
||||
name: "gypfile false disables gyp install",
|
||||
files: map[string]string{"package/binding.gyp": "{}", "package/package.json": `{"gypfile":false}`},
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
@@ -460,41 +465,6 @@ func TestParseUpload(t *testing.T) {
|
||||
require.NotNil(t, dep)
|
||||
assert.Equal(t, map[string]string{"1.0.0": "gone", "1.0.1": ""}, dep.Versions)
|
||||
})
|
||||
|
||||
t.Run("dispatches publish when _attachments present", func(t *testing.T) {
|
||||
// Reuse a minimal tarball with a package.json.
|
||||
data := buildTarball(map[string]string{"package/package.json": `{}`})
|
||||
integrity := "sha512-" + base64Sha512(data)
|
||||
body := fmt.Sprintf(
|
||||
`{"name":%q,"versions":{"1.0.0":{"name":%q,"version":"1.0.0","dist":{"integrity":%q}}},"_attachments":{"x.tgz":{"data":%q}}}`,
|
||||
pkg, pkg, integrity, base64.StdEncoding.EncodeToString(data),
|
||||
)
|
||||
p, dep, err := ParseUpload(strings.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, dep)
|
||||
require.NotNil(t, p)
|
||||
assert.Equal(t, pkg, p.Name)
|
||||
})
|
||||
|
||||
t.Run("publish whose readme mentions deprecated is not misrouted", func(t *testing.T) {
|
||||
// The old fast-path used a substring check for "deprecated"; make sure
|
||||
// the new dispatch keys off _attachments only.
|
||||
data := buildTarball(map[string]string{"package/package.json": `{}`})
|
||||
integrity := "sha512-" + base64Sha512(data)
|
||||
body := fmt.Sprintf(
|
||||
`{"name":%q,"versions":{"1.0.0":{"name":%q,"version":"1.0.0","readme":"this package is deprecated!","dist":{"integrity":%q}}},"_attachments":{"x.tgz":{"data":%q}}}`,
|
||||
pkg, pkg, integrity, base64.StdEncoding.EncodeToString(data),
|
||||
)
|
||||
p, dep, err := ParseUpload(strings.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, dep)
|
||||
require.NotNil(t, p)
|
||||
})
|
||||
|
||||
t.Run("invalid json errors out", func(t *testing.T) {
|
||||
_, _, err := ParseUpload(strings.NewReader("not json"))
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func base64Sha512(data []byte) string {
|
||||
|
||||
@@ -29,6 +29,7 @@ type Metadata struct {
|
||||
Engines map[string]string `json:"engines,omitempty"`
|
||||
CPU []string `json:"cpu,omitempty"`
|
||||
OS []string `json:"os,omitempty"`
|
||||
Libc []string `json:"libc,omitempty"`
|
||||
Directories map[string]string `json:"directories,omitempty"`
|
||||
Funding any `json:"funding,omitempty"`
|
||||
AcceptDependencies map[string]string `json:"accept_dependencies,omitempty"`
|
||||
|
||||
@@ -36,6 +36,7 @@ import "strings"
|
||||
|
||||
const (
|
||||
tildePrefix = '~'
|
||||
commentPrefix = '#'
|
||||
needsEscape = " \t\n|&;()<>${}[]*?!\"'`\\"
|
||||
needsSingleQuote = "!\n"
|
||||
)
|
||||
@@ -74,7 +75,7 @@ func ShellEscape(toEscape string) string {
|
||||
}
|
||||
|
||||
// Now for simplicity we'll look at the rest of the string
|
||||
if !strings.ContainsAny(toEscape[start:], needsEscape) {
|
||||
if !strings.ContainsAny(toEscape[start:], needsEscape) && toEscape[0] != commentPrefix {
|
||||
return toEscape
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,10 @@ func TestShellEscape(t *testing.T) {
|
||||
"Double quote and escape `...",
|
||||
"~/gitea`",
|
||||
"~/\"gitea\\`\"",
|
||||
}, {
|
||||
"Double quote leading #",
|
||||
"#123",
|
||||
`"#123"`,
|
||||
}, {
|
||||
"Double quotes can handle a number of things without having to escape them but not everything ...",
|
||||
"~/<gitea> ${gitea} `gitea` [gitea] (gitea) \"gitea\" \\gitea\\ 'gitea'",
|
||||
|
||||
@@ -433,6 +433,7 @@
|
||||
"auth.authorize_application_created_by": "This application was created by %s.",
|
||||
"auth.authorize_application_description": "If you grant access, it will be able to access and write to all your account information, including private repos and organizations.",
|
||||
"auth.authorize_application_with_scopes": "With scopes: %s",
|
||||
"auth.authorize_application_new_scopes": "New scopes: %s",
|
||||
"auth.authorize_title": "Authorize \"%s\" to access your account?",
|
||||
"auth.authorization_failed": "Authorization failed",
|
||||
"auth.authorization_failed_desc": "The authorization failed because we detected an invalid request. Please contact the maintainer of the app you tried to authorize.",
|
||||
|
||||
@@ -277,7 +277,10 @@
|
||||
"install.domain_helper": "服务器的域名或主机地址。",
|
||||
"install.ssh_port": "SSH 服务端口",
|
||||
"install.ssh_port_helper": "SSH 服务器的端口号,为空则禁用它。",
|
||||
"install.http_port": "HTTP 服务端口",
|
||||
"install.http_port_helper": "Gitea Web 服务器将侦听的端口号。",
|
||||
"install.app_url": "Gitea 网站 URL",
|
||||
"install.app_url_helper": "Gitea Web 应用程序用于 HTTP(S) 访问、克隆 URL 及电子邮件通知的公共 URL。",
|
||||
"install.optional_title": "可选设置",
|
||||
"install.email_title": "电子邮箱设置",
|
||||
"install.smtp_addr": "SMTP 主机地址",
|
||||
@@ -290,8 +293,11 @@
|
||||
"install.register_confirm": "需要邮件确认注册",
|
||||
"install.mail_notify": "启用邮件通知提醒",
|
||||
"install.server_service_title": "服务器和第三方服务设置",
|
||||
"install.disable_registration": "只有管理员可以创建用户帐户(禁止自助注册)",
|
||||
"install.enable_captcha": "启用注册验证码",
|
||||
"install.enable_captcha_popup": "要求在用户注册时输入预验证码",
|
||||
"install.require_sign_in_view": "需要登录才能查看页面(推荐用于私有实例)",
|
||||
"install.require_sign_in_view_popup": "仅允许已登录用户访问页面。访客只能看到注册和登录页。",
|
||||
"install.admin_setting_desc": "创建管理员帐户是可选的。第一个注册用户将自动成为管理员。",
|
||||
"install.admin_title": "管理员帐号设置",
|
||||
"install.admin_name": "管理员用户名",
|
||||
@@ -314,6 +320,7 @@
|
||||
"install.default_allow_create_organization_popup": "默认情况下,允许新用户帐户创建组织。",
|
||||
"install.no_reply_address": "隐藏邮件域",
|
||||
"install.no_reply_address_helper": "具有隐藏邮箱地址的用户的域名。例如,如果隐藏邮箱域名设置为「noreply.example.org」,那么用户名「joe」在 Git 中将显示为「joe@noreply.example.org」。",
|
||||
"install.enable_update_checker": "启用更新检查",
|
||||
"install.env_config_keys": "环境配置",
|
||||
"install.env_config_keys_prompt": "以下环境变量也将应用于您的配置文件:",
|
||||
"install.config_write_file_prompt": "这些配置选项将写入以下位置: %s",
|
||||
@@ -1091,6 +1098,7 @@
|
||||
"repo.migrate.github_token_desc": "您可以在此处输入一个或多个令牌(以逗号分隔),以绕过 GitHub API 速率限制来加快迁移速度。警告:滥用此功能可能会违反服务提供商的政策并导致帐户被封禁。",
|
||||
"repo.migrate.clone_local_path": "或服务器本地路径",
|
||||
"repo.migrate.permission_denied": "您没有获得导入本地仓库的权限。",
|
||||
"repo.migrate.permission_denied_blocked": "您不能从不允许的主机导入。请询问管理员以检查 [migrations] 部分中的ALLOWED_HOST_LIST/BLOCKED_HOST_LIST 设置。",
|
||||
"repo.migrate.invalid_local_path": "本地路径无效。它不存在或不是一个目录。",
|
||||
"repo.migrate.invalid_lfs_endpoint": "LFS 网址无效。",
|
||||
"repo.migrate.failed": "迁移失败:%v",
|
||||
@@ -3896,6 +3904,11 @@
|
||||
"actions.workflow.has_no_workflow_dispatch": "工作流「%s」没有 workflow_dispatch 事件触发器。",
|
||||
"actions.need_approval_desc": "该工作流由派生仓库的合并请求所触发,需要批准方可运行。",
|
||||
"actions.approve_all_success": "所有工作流运行已成功批准。",
|
||||
"actions.job_queue.title": "任务队列",
|
||||
"actions.job_queue.runs_on": "运行于",
|
||||
"actions.job_queue.waiting_or_started": "等待 / 已开始",
|
||||
"actions.job_queue.no_jobs": "没有正在运行或等待处理的任务。",
|
||||
"actions.job_queue.filter_owner_no_select": "所有所有者",
|
||||
"actions.job_queue.filter_repo_no_select": "所有仓库",
|
||||
"actions.variables": "变量",
|
||||
"actions.variables.management": "变量管理",
|
||||
|
||||
@@ -406,12 +406,15 @@ func CommonRoutes() *web.Router {
|
||||
})
|
||||
r.Group("/npm", func() {
|
||||
r.Get("/-/v1/search", npm.PackageSearch)
|
||||
r.Get("/-/ping", npm.Ping)
|
||||
r.Get("/-/whoami", npm.Whoami)
|
||||
r.PathGroup("/*", func(g *web.RouterPathGroup) {
|
||||
// HINT: NPM-ROUTE-PATH-PATTERN: search this keyword to see more details
|
||||
packageId := `/<id:(@` + npm_module.RegexpNamePart + `/)?` + npm_module.RegexpNamePart + ">"
|
||||
g.UseUnescapedPath()
|
||||
g.MatchPath("DELETE", packageId+"/-/<version>/<filename>/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageVersion)
|
||||
g.MatchPath("GET", packageId+"/-/<version>/<filename>", npm.DownloadPackageFile)
|
||||
g.MatchPath("DELETE", packageId+"/-/<filename>/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageVersion)
|
||||
g.MatchPath("GET", packageId+"/-/<version>/<filename>", npm.DownloadPackageFileByName) // former tarball URL, still in lockfiles
|
||||
g.MatchPath("GET", packageId+"/-/<filename>", npm.DownloadPackageFileByName)
|
||||
g.MatchPath("DELETE", packageId+"/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackage)
|
||||
g.MatchPath("PUT", packageId+"/-rev/<revision>", reqPackageAccess(perm.AccessModeWrite), npm.DeletePreview)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"slices"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
@@ -25,6 +25,7 @@ func createPackageMetadataResponse(registryURL string, pds []*packages_model.Pac
|
||||
distTags := make(map[string]string)
|
||||
times := make(map[string]time.Time)
|
||||
firstPublished, lastPublished := pds[0].Version.CreatedUnix, pds[0].Version.CreatedUnix
|
||||
var latest *packages_model.PackageDescriptor
|
||||
for _, pd := range pds {
|
||||
semVer := pd.SemVer.String()
|
||||
versions[semVer] = createPackageMetadataVersion(registryURL, pd)
|
||||
@@ -35,6 +36,9 @@ func createPackageMetadataResponse(registryURL string, pds []*packages_model.Pac
|
||||
for _, pvp := range pd.VersionProperties {
|
||||
if pvp.Name == npm_module.TagProperty {
|
||||
distTags[pvp.Value] = pd.Version.Version
|
||||
if pvp.Value == "latest" {
|
||||
latest = pd
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,7 +47,16 @@ func createPackageMetadataResponse(registryURL string, pds []*packages_model.Pac
|
||||
times["created"] = firstPublished.AsTimeInLocation(time.UTC)
|
||||
times["modified"] = lastPublished.AsTimeInLocation(time.UTC)
|
||||
|
||||
latest := pds[len(pds)-1]
|
||||
if latest == nil { // yarn and pnpm fail without it, e.g. after its version got deleted
|
||||
latest = pds[len(pds)-1]
|
||||
for _, pd := range slices.Backward(pds) {
|
||||
if pd.SemVer.Prerelease() == "" {
|
||||
latest = pd
|
||||
break
|
||||
}
|
||||
}
|
||||
distTags["latest"] = latest.Version.Version
|
||||
}
|
||||
|
||||
metadata := packages_model.DescriptorMetadata[*npm_module.Metadata](latest)
|
||||
|
||||
@@ -86,13 +99,13 @@ func createPackageMetadataVersion(registryURL string, pd *packages_model.Package
|
||||
PeerDependencies: metadata.PeerDependencies,
|
||||
PeerDependenciesMeta: metadata.PeerDependenciesMeta,
|
||||
OptionalDependencies: metadata.OptionalDependencies,
|
||||
Readme: metadata.Readme,
|
||||
Bin: metadata.Bin,
|
||||
HasInstallScript: metadata.HasInstallScript,
|
||||
HasShrinkwrap: metadata.HasShrinkwrap,
|
||||
Engines: metadata.Engines,
|
||||
CPU: metadata.CPU,
|
||||
OS: metadata.OS,
|
||||
Libc: metadata.Libc,
|
||||
Directories: metadata.Directories,
|
||||
Funding: metadata.Funding,
|
||||
AcceptDependencies: metadata.AcceptDependencies,
|
||||
@@ -100,7 +113,7 @@ func createPackageMetadataVersion(registryURL string, pd *packages_model.Package
|
||||
Dist: npm_module.PackageDistribution{
|
||||
Shasum: pd.Files[0].Blob.HashSHA1,
|
||||
Integrity: "sha512-" + base64.StdEncoding.EncodeToString(hashBytes),
|
||||
Tarball: fmt.Sprintf("%s/%s/-/%s/%s", registryURL, url.PathEscape(pd.Package.Name), url.PathEscape(pd.Version.Version), url.PathEscape(pd.Files[0].File.LowerName)),
|
||||
Tarball: fmt.Sprintf("%s/%s/-/%s", registryURL, pd.Package.Name, pd.Files[0].File.LowerName), // npmjs shape, which npm parses for allowScripts and yarn keeps registry-relative
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestCreatePackageMetadataResponse(t *testing.T) {
|
||||
Owner: &user_model.User{Name: "alice"},
|
||||
Version: &packages_model.PackageVersion{Version: v, CreatedUnix: timeutil.TimeStamp(publishedUnix)},
|
||||
SemVer: version.Must(version.NewVersion(v)),
|
||||
Metadata: &npm_module.Metadata{Keywords: []string{"gitea"}, Repository: repo},
|
||||
Metadata: &npm_module.Metadata{Readme: v, Keywords: []string{"gitea"}, Repository: repo},
|
||||
Files: []*packages_model.PackageFileDescriptor{{
|
||||
File: &packages_model.PackageFile{LowerName: "test-" + v + ".tgz"},
|
||||
Blob: &packages_model.PackageBlob{},
|
||||
@@ -35,21 +35,26 @@ func TestCreatePackageMetadataResponse(t *testing.T) {
|
||||
|
||||
result := createPackageMetadataResponse("https://gitea.dev/api/packages/alice/npm", []*packages_model.PackageDescriptor{
|
||||
descriptor("1.1.0", 1000, npm_module.Repository{}),
|
||||
descriptor("2.0.0-rc.1", 1500, repository),
|
||||
descriptor("1.0.0", 2000, repository),
|
||||
})
|
||||
|
||||
assert.Equal(t, map[string]time.Time{
|
||||
"1.0.0": time.Unix(2000, 0).UTC(),
|
||||
"1.1.0": time.Unix(1000, 0).UTC(),
|
||||
"created": time.Unix(1000, 0).UTC(),
|
||||
"modified": time.Unix(2000, 0).UTC(),
|
||||
"1.0.0": time.Unix(2000, 0).UTC(),
|
||||
"1.1.0": time.Unix(1000, 0).UTC(),
|
||||
"2.0.0-rc.1": time.Unix(1500, 0).UTC(),
|
||||
"created": time.Unix(1000, 0).UTC(),
|
||||
"modified": time.Unix(2000, 0).UTC(),
|
||||
}, result.Time)
|
||||
assert.Equal(t, map[string]string{"latest": "1.1.0"}, result.DistTags)
|
||||
assert.Equal(t, "1.1.0", result.Readme)
|
||||
assert.Empty(t, result.Versions["1.1.0"].Readme)
|
||||
assert.Equal(t, []npm_module.User{{Name: "alice"}}, result.Maintainers)
|
||||
assert.Equal(t, []string{"gitea"}, result.Keywords)
|
||||
assert.Equal(t, []string{"gitea"}, result.Versions["1.0.0"].Keywords)
|
||||
assert.Equal(t, []npm_module.User{{Name: "alice"}}, result.Versions["1.0.0"].Maintainers)
|
||||
assert.Equal(t,
|
||||
"https://gitea.dev/api/packages/alice/npm/@scope%2Ftest/-/1.0.0/test-1.0.0.tgz",
|
||||
"https://gitea.dev/api/packages/alice/npm/@scope/test/-/test-1.0.0.tgz",
|
||||
result.Versions["1.0.0"].Dist.Tarball,
|
||||
)
|
||||
assert.Equal(t, repository, result.Versions["1.0.0"].Repository)
|
||||
|
||||
@@ -6,7 +6,9 @@ package npm
|
||||
import (
|
||||
"bytes"
|
||||
std_ctx "context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -55,28 +57,41 @@ func buildNpmRegistryURL(ctx std_ctx.Context, owner *user_model.User) string {
|
||||
return httplib.GuessCurrentAppURL(ctx) + "api/packages/" + url.PathEscape(owner.Name) + "/npm"
|
||||
}
|
||||
|
||||
// PackageMetadata returns the metadata for a single package
|
||||
func PackageMetadata(ctx *context.Context) {
|
||||
packageName := packageNameFromParams(ctx)
|
||||
|
||||
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageName)
|
||||
func packageMetadata(ctx *context.Context) *npm_module.PackageMetadata {
|
||||
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageNameFromParams(ctx))
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
if len(pvs) == 0 {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
pds, err := packages_model.GetPackageDescriptors(ctx, pvs)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
resp := createPackageMetadataResponse(buildNpmRegistryURL(ctx, ctx.Package.Owner), pds)
|
||||
ctx.JSON(http.StatusOK, resp)
|
||||
return createPackageMetadataResponse(buildNpmRegistryURL(ctx, ctx.Package.Owner), pds)
|
||||
}
|
||||
|
||||
// PackageMetadata returns the metadata for a single package
|
||||
func PackageMetadata(ctx *context.Context) {
|
||||
if metadata := packageMetadata(ctx); metadata != nil {
|
||||
serveMetadata(ctx, metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func serveMetadata(ctx *context.Context, obj any) {
|
||||
body, err := json.MarshalDeterministic(obj)
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
ctx.Resp.Header().Set("ETag", fmt.Sprintf(`W/"%x"`, sha256.Sum256(body)))
|
||||
ctx.ServeContent(bytes.NewReader(body), context.ServeHeaderOptions{ContentType: "application/json;charset=utf-8"})
|
||||
}
|
||||
|
||||
// PackageVersionMetadata returns the metadata for a single version or dist-tag
|
||||
@@ -100,7 +115,11 @@ func PackageVersionMetadata(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
if len(pvs) == 0 {
|
||||
apiError(ctx, http.StatusNotFound, "version not found: "+versionOrTag)
|
||||
if versionOrTag != "latest" {
|
||||
apiError(ctx, http.StatusNotFound, "version not found: "+versionOrTag)
|
||||
} else if metadata := packageMetadata(ctx); metadata != nil { // unset, so serve the packument's fallback
|
||||
serveMetadata(ctx, metadata.Versions[metadata.DistTags["latest"]])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -110,25 +129,40 @@ func PackageVersionMetadata(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, createPackageMetadataVersion(buildNpmRegistryURL(ctx, ctx.Package.Owner), pd))
|
||||
serveMetadata(ctx, createPackageMetadataVersion(buildNpmRegistryURL(ctx, ctx.Package.Owner), pd))
|
||||
}
|
||||
|
||||
// DownloadPackageFile serves the content of a package
|
||||
func DownloadPackageFile(ctx *context.Context) {
|
||||
packageName := packageNameFromParams(ctx)
|
||||
packageVersion := ctx.PathParam("version")
|
||||
filename := ctx.PathParam("filename")
|
||||
func packageVersionByFilename(ctx *context.Context) *packages_model.PackageVersion {
|
||||
pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
Type: packages_model.TypeNpm,
|
||||
Name: packages_model.SearchValue{ExactMatch: true, Value: packageNameFromParams(ctx)},
|
||||
HasFileWithName: ctx.PathParam("filename"),
|
||||
IsInternal: optional.Some(false),
|
||||
})
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return nil
|
||||
}
|
||||
if len(pvs) != 1 {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return nil
|
||||
}
|
||||
return pvs[0]
|
||||
}
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion(
|
||||
// DownloadPackageFileByName finds the version and serves the contents of a package
|
||||
func DownloadPackageFileByName(ctx *context.Context) {
|
||||
pv := packageVersionByFilename(ctx)
|
||||
if pv == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageVersion(
|
||||
ctx,
|
||||
&packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeNpm,
|
||||
Name: packageName,
|
||||
Version: packageVersion,
|
||||
},
|
||||
pv,
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: filename,
|
||||
Filename: ctx.PathParam("filename"),
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
@@ -140,54 +174,14 @@ func DownloadPackageFile(ctx *context.Context) {
|
||||
helper.ServePackageFile(ctx, s, u, pf)
|
||||
}
|
||||
|
||||
// DownloadPackageFileByName finds the version and serves the contents of a package
|
||||
func DownloadPackageFileByName(ctx *context.Context) {
|
||||
filename := ctx.PathParam("filename")
|
||||
|
||||
pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
Type: packages_model.TypeNpm,
|
||||
Name: packages_model.SearchValue{
|
||||
ExactMatch: true,
|
||||
Value: packageNameFromParams(ctx),
|
||||
},
|
||||
HasFileWithName: filename,
|
||||
IsInternal: optional.Some(false),
|
||||
})
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if len(pvs) != 1 {
|
||||
apiError(ctx, http.StatusNotFound, nil)
|
||||
return
|
||||
}
|
||||
|
||||
s, u, pf, err := packages_service.OpenFileForDownloadByPackageVersion(
|
||||
ctx,
|
||||
pvs[0],
|
||||
&packages_service.PackageFileInfo{
|
||||
Filename: filename,
|
||||
},
|
||||
ctx.Req.Method,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageFileNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
helper.ServePackageFile(ctx, s, u, pf)
|
||||
}
|
||||
|
||||
// UploadPackage creates a new package
|
||||
func UploadPackage(ctx *context.Context) {
|
||||
npmPackage, deprecation, err := npm_module.ParseUpload(ctx.Req.Body)
|
||||
// about the npmjs and GitHub Packages limit, fits base64 tarballs up to ~200 MB
|
||||
npmPackage, deprecation, err := npm_module.ParseUpload(http.MaxBytesReader(ctx.Resp, ctx.Req.Body, 256*1024*1024))
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
|
||||
apiError(ctx, http.StatusRequestEntityTooLarge, err)
|
||||
} else if errors.Is(err, util.ErrInvalidArgument) {
|
||||
apiError(ctx, http.StatusBadRequest, err)
|
||||
} else {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
@@ -340,26 +334,14 @@ func deprecatePackage(ctx *context.Context, dep *npm_module.PackageDeprecation)
|
||||
ctx.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// DeletePackageVersion deletes the package version
|
||||
// DeletePackageVersion deletes the package version, which `npm unpublish` addresses by its tarball
|
||||
func DeletePackageVersion(ctx *context.Context) {
|
||||
packageName := packageNameFromParams(ctx)
|
||||
packageVersion := ctx.PathParam("version")
|
||||
pv := packageVersionByFilename(ctx)
|
||||
if pv == nil {
|
||||
return
|
||||
}
|
||||
|
||||
err := packages_service.RemovePackageVersionByNameAndVersion(
|
||||
ctx,
|
||||
ctx.Doer,
|
||||
&packages_service.PackageInfo{
|
||||
Owner: ctx.Package.Owner,
|
||||
PackageType: packages_model.TypeNpm,
|
||||
Name: packageName,
|
||||
Version: packageVersion,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, packages_model.ErrPackageNotExist) {
|
||||
apiError(ctx, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
if err := packages_service.RemovePackageVersion(ctx, ctx.Doer, pv); err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
@@ -394,9 +376,7 @@ func DeletePackage(ctx *context.Context) {
|
||||
|
||||
// ListPackageTags returns all tags for a package
|
||||
func ListPackageTags(ctx *context.Context) {
|
||||
packageName := packageNameFromParams(ctx)
|
||||
|
||||
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageName)
|
||||
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeNpm, packageNameFromParams(ctx))
|
||||
if err != nil {
|
||||
apiError(ctx, http.StatusInternalServerError, err)
|
||||
return
|
||||
@@ -414,7 +394,11 @@ func ListPackageTags(ctx *context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, tags)
|
||||
if _, ok := tags["latest"]; ok {
|
||||
ctx.JSON(http.StatusOK, tags)
|
||||
} else if metadata := packageMetadata(ctx); metadata != nil { // unset, so list the packument's fallback
|
||||
ctx.JSON(http.StatusOK, metadata.DistTags)
|
||||
}
|
||||
}
|
||||
|
||||
// AddPackageTag adds a tag to the package
|
||||
@@ -524,6 +508,18 @@ func setPackageTag(ctx std_ctx.Context, tag string, pv *packages_model.PackageVe
|
||||
})
|
||||
}
|
||||
|
||||
func Ping(ctx *context.Context) {
|
||||
ctx.JSON(http.StatusOK, map[string]any{})
|
||||
}
|
||||
|
||||
func Whoami(ctx *context.Context) {
|
||||
if ctx.Doer == nil {
|
||||
apiError(ctx, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, map[string]string{"username": ctx.Doer.Name})
|
||||
}
|
||||
|
||||
func PackageSearch(ctx *context.Context) {
|
||||
pvs, total, err := packages_model.SearchLatestVersions(ctx, &packages_model.PackageSearchOptions{
|
||||
OwnerID: ctx.Package.Owner.ID,
|
||||
|
||||
@@ -1032,7 +1032,7 @@ func MergePullRequest(ctx *context.APIContext) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := pull_service.Merge(pr.ID, ctx.Doer, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil {
|
||||
if err := pull_service.Merge(ctx, pr.ID, ctx.Doer, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil {
|
||||
if pull_service.IsErrInvalidMergeStyle(err) {
|
||||
ctx.APIError(http.StatusMethodNotAllowed, fmt.Sprintf("%s is not allowed an allowed merge style for this repository", repo_model.MergeStyle(form.Do)))
|
||||
} else if conflictError, ok := err.(pull_service.ErrMergeConflicts); ok {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
audit_model "gitea.dev/models/audit"
|
||||
"gitea.dev/models/auth"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/services/audit"
|
||||
auth_service "gitea.dev/services/auth"
|
||||
@@ -321,9 +323,18 @@ func AuthorizeOAuth(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var addedScopes, removedScopes []string
|
||||
if grant != nil {
|
||||
if form.Scope == "" {
|
||||
form.Scope = grant.Scope
|
||||
}
|
||||
addedScopes, removedScopes = util.DiffSlice(strings.Fields(grant.Scope), strings.Fields(form.Scope))
|
||||
}
|
||||
scopeChanged := len(addedScopes) > 0 || len(removedScopes) > 0
|
||||
|
||||
// Redirect if user already granted access and the application is confidential or trusted otherwise
|
||||
// I.e. always require authorization for untrusted public clients as recommended by RFC 6749 Section 10.2
|
||||
if (app.ConfidentialClient || app.SkipSecondaryAuthorization) && grant != nil {
|
||||
if (app.ConfidentialClient || app.SkipSecondaryAuthorization) && grant != nil && !scopeChanged {
|
||||
code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, form.CodeChallenge, form.CodeChallengeMethod)
|
||||
if err != nil {
|
||||
handleServerError(ctx, form.State, form.RedirectURI)
|
||||
@@ -347,6 +358,7 @@ func AuthorizeOAuth(ctx *context.Context) {
|
||||
|
||||
// check if additional scopes
|
||||
ctx.Data["AdditionalScopes"] = oauth2_provider.GrantAdditionalScopes(form.Scope) != auth.AccessTokenScopeAll
|
||||
ctx.Data["AddedScopes"] = addedScopes
|
||||
|
||||
// show authorize page to grant access
|
||||
ctx.Data["Application"] = app
|
||||
@@ -432,12 +444,10 @@ func GrantApplicationOAuth(ctx *context.Context) {
|
||||
|
||||
audit.Record(ctx, audit_model.UserOAuth2ApplicationGrant, ctx.Doer, "oauth2_application", app.Name, "granted_scope", form.Scope)
|
||||
} else if grant.Scope != form.Scope {
|
||||
handleAuthorizeError(ctx, AuthorizeError{
|
||||
State: form.State,
|
||||
ErrorDescription: "a grant exists with different scope",
|
||||
ErrorCode: ErrorCodeServerError,
|
||||
}, form.RedirectURI)
|
||||
return
|
||||
if err := auth.UpdateGrantScope(ctx, grant, form.Scope); err != nil {
|
||||
handleServerError(ctx, form.State, form.RedirectURI)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if len(form.Nonce) > 0 {
|
||||
|
||||
@@ -13,6 +13,10 @@ import (
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/egress/policy"
|
||||
"gitea.dev/modules/session"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/services/contexttest"
|
||||
"gitea.dev/services/forms"
|
||||
"gitea.dev/services/oauth2_provider"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
@@ -105,3 +109,27 @@ func TestOAuth2AvatarClientBlocksCloudMetadata(t *testing.T) {
|
||||
assert.ErrorIs(t, err, policy.ErrDenied,
|
||||
"avatar client must refuse a link-local cloud-metadata address")
|
||||
}
|
||||
|
||||
func TestOAuth2ScopeChange(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
app := unittest.AssertExistsAndLoadBean(t, &auth.OAuth2Application{ID: 1})
|
||||
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("oauth2-scope-change")}
|
||||
authorize := func(scope string) int {
|
||||
ctx, resp := contexttest.MockContext(t, "/login/oauth/authorize", mockOpt)
|
||||
ctx.Doer = doer
|
||||
web.SetForm(ctx, &forms.AuthorizationForm{ResponseType: "code", ClientID: app.ClientID, RedirectURI: app.RedirectURIs[0], State: "state", Scope: scope})
|
||||
AuthorizeOAuth(ctx)
|
||||
return resp.Code
|
||||
}
|
||||
assert.Equal(t, http.StatusSeeOther, authorize(""))
|
||||
assert.Equal(t, http.StatusSeeOther, authorize("profile openid"))
|
||||
assert.Equal(t, http.StatusOK, authorize("openid profile email"))
|
||||
|
||||
ctx, resp := contexttest.MockContext(t, "/login/oauth/grant", mockOpt)
|
||||
ctx.Doer = doer
|
||||
web.SetForm(ctx, &forms.GrantApplicationForm{ClientID: app.ClientID, Granted: true, RedirectURI: app.RedirectURIs[0], State: "state", Scope: "openid profile email"})
|
||||
GrantApplicationOAuth(ctx)
|
||||
assert.Equal(t, http.StatusSeeOther, resp.Code)
|
||||
unittest.AssertExistsAndLoadBean(t, &auth.OAuth2Grant{ID: 1, Scope: "openid profile email"})
|
||||
}
|
||||
|
||||
@@ -569,7 +569,7 @@ func (data *actionRunListData) processActionRuns(ctx *context.Context) bool {
|
||||
break
|
||||
}
|
||||
}
|
||||
if job.Status.IsWaiting() {
|
||||
if job.Status.IsWaiting() && !job.IsReusableCaller {
|
||||
hasOnlineRunner := false
|
||||
for _, runner := range runners {
|
||||
if !runner.IsDisabled && runner.CanMatchLabels(job.RunsOn) {
|
||||
|
||||
@@ -773,7 +773,7 @@ func describePendingJobDetail(ctx *context_module.Context, current *actions_mode
|
||||
if pending := pendingNeeds(current, jobs); len(pending) > 0 {
|
||||
return ctx.Locale.TrString("actions.runs.waiting_for_dependent_jobs", strings.Join(pending, ", "))
|
||||
}
|
||||
case current.Status.IsWaiting():
|
||||
case current.Status.IsWaiting() && !current.IsReusableCaller: // a caller waits on its called jobs, never on a runner
|
||||
// A waiting job has no runner to pick it up yet. A busy runner is still
|
||||
// "online", so distinguish three cases: no runner online at all, online
|
||||
// runners but none match the labels, and a matching runner that is busy.
|
||||
|
||||
@@ -1145,7 +1145,7 @@ func MergePullRequest(ctx *context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := pull_service.Merge(pr.ID, ctx.Doer, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil {
|
||||
if err := pull_service.Merge(ctx, pr.ID, ctx.Doer, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil {
|
||||
if pull_service.IsErrInvalidMergeStyle(err) {
|
||||
ctx.JSONError(ctx.Tr("repo.pulls.invalid_merge_option"))
|
||||
} else if conflictError, ok := err.(pull_service.ErrMergeConflicts); ok {
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
@@ -99,6 +100,20 @@ func ApproveRuns(ctx context.Context, repo *repo_model.Repository, doer *user_mo
|
||||
if !slots.available(job) {
|
||||
continue
|
||||
}
|
||||
if invalid := invalidRunsOn(job); invalid != nil {
|
||||
job.Status, job.Stopped = actions_model.StatusFailure, timeutil.TimeStampNow()
|
||||
n, err := actions_model.UpdateRunJob(ctx, job, nil, "status", "stopped")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
updatedJobs = append(updatedJobs, job)
|
||||
}
|
||||
if err := upsertJobErrorSummary(ctx, job, "runs-on", invalid); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
var jobsToCancel []*actions_model.ActionRunJob
|
||||
job.Status, jobsToCancel, err = PrepareToStartJobWithConcurrency(ctx, job)
|
||||
if err != nil {
|
||||
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
assert.NotEmpty(t, persisted.RawConcurrency)
|
||||
}
|
||||
|
||||
func TestPrepareRunAndInsert_JobIf(t *testing.T) {
|
||||
func TestPrepareRunAndInsert_JobIfAndRunsOn(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
defer test.MockVariableValue(&EmitJobsIfReadyByRun, func(int64) error { return nil })()
|
||||
|
||||
@@ -123,6 +123,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo
|
||||
unset-runs-on:
|
||||
runs-on: ${{ vars.UNSET }}
|
||||
steps:
|
||||
- run: echo
|
||||
`, false)
|
||||
|
||||
jobs := map[string]*actions_model.ActionRunJob{}
|
||||
@@ -134,9 +138,12 @@ jobs:
|
||||
assert.False(t, jobs["skip"].IsConcurrencyEvaluated)
|
||||
assert.Equal(t, actions_model.StatusSkipped, jobs["skip-caller"].Status)
|
||||
assert.Equal(t, actions_model.StatusSkipped, jobs["invalid"].Status)
|
||||
summary, err := actions_model.GetActionRunJobSummary(t.Context(), run.RepoID, run.ID, run.LatestAttemptID, jobs["invalid"].ID, 0)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, summary.Content, "Error when evaluating `if` for job `invalid`")
|
||||
assert.Equal(t, actions_model.StatusFailure, jobs["unset-runs-on"].Status)
|
||||
for id, key := range map[string]string{"invalid": "if", "unset-runs-on": "runs-on"} {
|
||||
summary, err := actions_model.GetActionRunJobSummary(t.Context(), run.RepoID, run.ID, run.LatestAttemptID, jobs[id].ID, 0)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, summary.Content, "Error when evaluating `"+key+"` for job `"+id+"`")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeReusableCallerOutputs(t *testing.T) {
|
||||
|
||||
@@ -183,6 +183,18 @@ func upsertJobErrorSummary(ctx context.Context, job *actions_model.ActionRunJob,
|
||||
return actions_model.UpsertActionRunJobSummary(ctx, job.RepoID, job.RunID, job.RunAttemptID, job.ID, 0, actions_model.JobSummaryContentTypeMarkdown, []byte(content))
|
||||
}
|
||||
|
||||
// invalidRunsOn returns github.com's error for the job's evaluated runs-on.
|
||||
func invalidRunsOn(job *actions_model.ActionRunJob) error {
|
||||
parsed, err := job.ParseJob()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if problem := parsed.RunsOnProblem(); problem != "" {
|
||||
return errors.New(problem)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findJobNeedsAndFillJobResults(ctx context.Context, job *actions_model.ActionRunJob) (map[string]*jobparser.JobResult, error) {
|
||||
taskNeeds, jobsByID, err := FindTaskNeeds(ctx, job)
|
||||
if err != nil {
|
||||
|
||||
@@ -32,40 +32,46 @@ func handleInvalidWorkflows(ctx context.Context, input *notifyInput, ref git.Ref
|
||||
if actionsConfig.IsWorkflowDisabled(entryName) {
|
||||
continue
|
||||
}
|
||||
now := timeutil.TimeStampNow()
|
||||
run := &actions_model.ActionRun{
|
||||
Title: util.EllipsisDisplayString(commit.MessageTitle(), 255), RepoID: input.Repo.ID, Repo: input.Repo, OwnerID: input.Repo.OwnerID,
|
||||
insertInvalidWorkflowRun(ctx, &actions_model.ActionRun{
|
||||
Title: commit.MessageTitle(), RepoID: input.Repo.ID, Repo: input.Repo, OwnerID: input.Repo.OwnerID,
|
||||
WorkflowID: entryName, TriggerUserID: input.Doer.ID, TriggerUser: input.Doer, Ref: ref.String(),
|
||||
CommitSHA: commit.ID.String(), Event: input.Event, TriggerEvent: string(input.Event), EventPayload: string(payload),
|
||||
WorkflowRepoID: input.Repo.ID, WorkflowCommitSHA: commit.ID.String(), Status: actions_model.StatusFailure, Started: now, Stopped: now,
|
||||
}
|
||||
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||
if run.Index, err = db.GetNextResourceIndex(ctx, "action_run_index", run.RepoID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Insert(ctx, run); err != nil {
|
||||
return err
|
||||
}
|
||||
attempt := &actions_model.ActionRunAttempt{RepoID: run.RepoID, RunID: run.ID, Attempt: 1, TriggerUserID: run.TriggerUserID, Status: run.Status, Started: now, Stopped: now}
|
||||
if err := db.Insert(ctx, attempt); err != nil {
|
||||
return err
|
||||
}
|
||||
run.LatestAttemptID = attempt.ID
|
||||
if err := actions_model.UpdateRun(ctx, run, "latest_attempt_id"); err != nil {
|
||||
return err
|
||||
}
|
||||
content := fmt.Sprintf("**Invalid workflow file: %s**\n\n```\n%v\n```\n", entryName, parseErr)
|
||||
return db.Insert(ctx, &actions_model.ActionRunJobSummary{
|
||||
RepoID: run.RepoID, RunID: run.ID, RunAttemptID: attempt.ID, Content: content, ContentSize: int64(len(content)), ContentType: actions_model.JobSummaryContentTypeMarkdown,
|
||||
})
|
||||
}); err != nil {
|
||||
log.Error("insert run for invalid workflow %q: %v", entryName, err)
|
||||
continue
|
||||
}
|
||||
if err := createWorkflowCommitStatus(ctx, run.Repo, run.CommitSHA, entryName+" ("+run.TriggerEvent+")", run.WorkflowID,
|
||||
commitstatus.CommitStatusFailure, run.Link(), "Invalid workflow file", false); err != nil {
|
||||
log.Error("create commit status for invalid workflow %q: %v", entryName, err)
|
||||
}
|
||||
NotifyWorkflowRunStatusUpdate(ctx, run)
|
||||
WorkflowRepoID: input.Repo.ID, WorkflowCommitSHA: commit.ID.String(),
|
||||
}, parseErr)
|
||||
}
|
||||
}
|
||||
|
||||
// insertInvalidWorkflowRun records run as failed with parseErr as its summary.
|
||||
func insertInvalidWorkflowRun(ctx context.Context, run *actions_model.ActionRun, parseErr error) {
|
||||
now := timeutil.TimeStampNow()
|
||||
run.Title = util.EllipsisDisplayString(run.Title, 255)
|
||||
run.Status, run.Started, run.Stopped = actions_model.StatusFailure, now, now
|
||||
if err := db.WithTx(ctx, func(ctx context.Context) (err error) {
|
||||
if run.Index, err = db.GetNextResourceIndex(ctx, "action_run_index", run.RepoID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Insert(ctx, run); err != nil {
|
||||
return err
|
||||
}
|
||||
attempt := &actions_model.ActionRunAttempt{RepoID: run.RepoID, RunID: run.ID, Attempt: 1, TriggerUserID: run.TriggerUserID, Status: run.Status, Started: now, Stopped: now}
|
||||
if err := db.Insert(ctx, attempt); err != nil {
|
||||
return err
|
||||
}
|
||||
run.LatestAttemptID = attempt.ID
|
||||
if err := actions_model.UpdateRun(ctx, run, "latest_attempt_id"); err != nil {
|
||||
return err
|
||||
}
|
||||
content := fmt.Sprintf("**Invalid workflow file: %s**\n\n```\n%v\n```\n", run.WorkflowID, parseErr)
|
||||
return db.Insert(ctx, &actions_model.ActionRunJobSummary{
|
||||
RepoID: run.RepoID, RunID: run.ID, RunAttemptID: attempt.ID, Content: content, ContentSize: int64(len(content)), ContentType: actions_model.JobSummaryContentTypeMarkdown,
|
||||
})
|
||||
}); err != nil {
|
||||
log.Error("insert run for invalid workflow %q: %v", run.WorkflowID, err)
|
||||
return
|
||||
}
|
||||
if err := createWorkflowCommitStatus(ctx, run.Repo, run.CommitSHA, run.WorkflowID+" ("+run.TriggerEvent+")", run.WorkflowID,
|
||||
commitstatus.CommitStatusFailure, run.Link(), "Invalid workflow file", false); err != nil {
|
||||
log.Error("create commit status for invalid workflow %q: %v", run.WorkflowID, err)
|
||||
}
|
||||
NotifyWorkflowRunStatusUpdate(ctx, run)
|
||||
}
|
||||
|
||||
@@ -590,6 +590,14 @@ func (r *jobStatusResolver) resolve(ctx context.Context) (map[int64]actions_mode
|
||||
continue
|
||||
}
|
||||
|
||||
if err := invalidRunsOn(actionRunJob); err != nil {
|
||||
if err := upsertJobErrorSummary(ctx, actionRunJob, "runs-on", err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret[id] = actions_model.StatusFailure
|
||||
continue
|
||||
}
|
||||
|
||||
// update concurrency and check whether the job can run now
|
||||
if err := updateConcurrencyEvaluationForJobWithNeeds(ctx, actionRunJob, r.vars); errors.Is(err, util.ErrInvalidArgument) {
|
||||
if err := upsertJobErrorSummary(ctx, actionRunJob, "concurrency", err); err != nil {
|
||||
|
||||
@@ -173,6 +173,15 @@ jobs:
|
||||
want: map[int64]actions_model.Status{2: actions_model.StatusFailure},
|
||||
note: "Error when evaluating `concurrency` for job `job2`.",
|
||||
},
|
||||
{
|
||||
name: "invalid evaluated `runs-on` fails the job with an annotation",
|
||||
jobs: actions_model.ActionJobList{
|
||||
{ID: 1, RepoID: 1, JobID: "job1", Status: actions_model.StatusSuccess},
|
||||
{ID: 2, RepoID: 1, JobID: "job2", Status: actions_model.StatusBlocked, Needs: []string{"job1"}, WorkflowPayload: []byte("jobs: {job2: {runs-on: ''}}")},
|
||||
},
|
||||
want: map[int64]actions_model.Status{2: actions_model.StatusFailure},
|
||||
note: "Error when evaluating `runs-on` for job `job2`.",
|
||||
},
|
||||
{
|
||||
name: "max-parallel: a freed slot promotes the lowest blocked job id",
|
||||
jobs: actions_model.ActionJobList{
|
||||
|
||||
@@ -394,6 +394,14 @@ func buildApproveAndInsertRun(
|
||||
IsScopedRun: isScopedRun,
|
||||
}
|
||||
|
||||
if err := validateCalledWorkflows(ctx, run, dwf.Content); err != nil {
|
||||
if isScopedRun {
|
||||
return err
|
||||
}
|
||||
insertInvalidWorkflowRun(ctx, run, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
approvalUsers, err := getApprovalUsers(ctx, input, isForkPullRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
@@ -97,6 +99,50 @@ func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRu
|
||||
}
|
||||
}
|
||||
|
||||
// validateCalledWorkflows validates all workflows content calls, recursively.
|
||||
func validateCalledWorkflows(ctx context.Context, run *actions_model.ActionRun, content []byte) error {
|
||||
validated := make(container.Set[string])
|
||||
var validate func(content []byte, source *actions_model.ActionRunJob, level int) error
|
||||
validate = func(content []byte, source *actions_model.ActionRunJob, level int) error {
|
||||
workflow, err := jobparser.ReadWorkflow(content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range slices.Sorted(maps.Keys(workflow.Jobs)) {
|
||||
uses := workflow.Jobs[id].Uses
|
||||
if uses == "" {
|
||||
continue
|
||||
}
|
||||
if level > MaxReusableCallLevels {
|
||||
return errCallLevelExceeded(uses)
|
||||
}
|
||||
if !validated.Add(fmt.Sprintf("%d@%s:%s", source.WorkflowSourceRepoID, source.WorkflowSourceCommitSHA, uses)) {
|
||||
continue
|
||||
}
|
||||
ref, err := ResolveUses(ctx, uses)
|
||||
if err != nil {
|
||||
return fmt.Errorf("job %s: %w", id, err)
|
||||
}
|
||||
called, repoID, commitSHA, err := loadReusableWorkflowSource(ctx, run, source, ref)
|
||||
if err != nil {
|
||||
return fmt.Errorf("job %s: %w", id, err)
|
||||
}
|
||||
if _, err = jobparser.ValidateWorkflowStatic(called); err == nil {
|
||||
err = validate(called, &actions_model.ActionRunJob{WorkflowSourceRepoID: repoID, WorkflowSourceCommitSHA: commitSHA}, level+1)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("job %s: Error from called workflow %s: %w", id, uses, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return validate(content, &actions_model.ActionRunJob{WorkflowSourceRepoID: run.WorkflowRepoID, WorkflowSourceCommitSHA: run.WorkflowCommitSHA}, 0)
|
||||
}
|
||||
|
||||
func errCallLevelExceeded(uses string) error {
|
||||
return fmt.Errorf("reusable workflow call exceeds the maximum nesting level of %d at %q", MaxReusableCallLevels, uses)
|
||||
}
|
||||
|
||||
// resolveSameRepoWorkflowSourceCommit returns the commit to read a same-repo reusable workflow from.
|
||||
// pull_request_target runs must resolve local `uses:` at the PR base commit, not a stored head SHA.
|
||||
func resolveSameRepoWorkflowSourceCommit(run *actions_model.ActionRun, caller *actions_model.ActionRunJob) string {
|
||||
@@ -149,7 +195,7 @@ func checkCallerChain(ctx context.Context, caller *actions_model.ActionRunJob) e
|
||||
current = next
|
||||
depth++
|
||||
if depth > MaxReusableCallLevels {
|
||||
return fmt.Errorf("reusable workflow call exceeds the maximum nesting level of %d at %q", MaxReusableCallLevels, caller.CallUses)
|
||||
return errCallLevelExceeded(caller.CallUses)
|
||||
}
|
||||
if current.IsReusableCaller && current.CallUses != "" && !visited.Add(canonicalCallUses(current)) {
|
||||
return fmt.Errorf("reusable workflow call cycle detected: %q", current.CallUses)
|
||||
@@ -225,6 +271,9 @@ func expandReusableWorkflowCaller(ctx context.Context, run *actions_model.Action
|
||||
if err := checkResolvedCallerCycle(ctx, caller, contentSourceRepoID, contentSourceCommitSHA, ref.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := jobparser.ValidateWorkflowStatic(content); err != nil {
|
||||
return fmt.Errorf("invalid called workflow: %w", err)
|
||||
}
|
||||
|
||||
// 4. Parse the called workflow's spec (used by both secret validation and input evaluation).
|
||||
wcSpec, err := jobparser.ParseWorkflowCallConfig(content)
|
||||
|
||||
+13
-5
@@ -5,6 +5,7 @@ package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
act_model "gitea.dev/actionslib/pkg/model"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"go.yaml.in/yaml/v4"
|
||||
@@ -185,6 +187,7 @@ func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt
|
||||
id, job := workflowJob.Job()
|
||||
needs := job.Needs()
|
||||
isMatrixDeferred := jobparser.HasDeferredMatrix(job)
|
||||
runsOnProblem := job.RunsOnProblem() // SetJob's encoding drops the node's null tag
|
||||
if err := workflowJob.SetJob(id, job.EraseNeeds()); err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
@@ -238,10 +241,15 @@ func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt
|
||||
}
|
||||
|
||||
// a skipped job must neither cancel its group peers nor take a slot
|
||||
invalidIf, err := decideJobIf(ctx, run, runAttempt, runJob, vars)
|
||||
invalidErr, err := decideJobIf(ctx, run, runAttempt, runJob, vars)
|
||||
if err != nil {
|
||||
return nil, nil, false, fmt.Errorf("evaluate job if: %w", err)
|
||||
}
|
||||
invalidKey := "if"
|
||||
if runsOnProblem != "" && runJob.Status.IsWaiting() && slots.available(runJob) {
|
||||
invalidKey, invalidErr = "runs-on", errors.New(runsOnProblem)
|
||||
runJob.Status, runJob.Stopped = actions_model.StatusFailure, timeutil.TimeStampNow()
|
||||
}
|
||||
|
||||
var cancelledConcurrencyJobs []*actions_model.ActionRunJob
|
||||
// check job concurrency
|
||||
@@ -275,8 +283,8 @@ func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt
|
||||
if err := db.Insert(ctx, runJob); err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
if invalidIf != nil {
|
||||
if err := upsertJobErrorSummary(ctx, runJob, "if", invalidIf); err != nil {
|
||||
if invalidErr != nil {
|
||||
if err := upsertJobErrorSummary(ctx, runJob, invalidKey, invalidErr); err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
}
|
||||
@@ -287,8 +295,8 @@ func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt
|
||||
}
|
||||
}
|
||||
|
||||
// the emitter resolves an expanded caller's children and a skipped job's dependents
|
||||
return runJob, cancelledConcurrencyJobs, runJob.IsExpanded || runJob.Status == actions_model.StatusSkipped, nil
|
||||
// the emitter resolves an expanded caller's children and a skipped or failed job's dependents
|
||||
return runJob, cancelledConcurrencyJobs, runJob.IsExpanded || runJob.Status.In(actions_model.StatusSkipped, actions_model.StatusFailure), nil
|
||||
}
|
||||
|
||||
func expandInlineReusableCaller(ctx context.Context, run *actions_model.ActionRun, runAttempt *actions_model.ActionRunAttempt, caller *actions_model.ActionRunJob, vars map[string]string) error {
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/timeutil"
|
||||
@@ -144,6 +145,14 @@ func CreateScheduleTaskBySpec(ctx context.Context, spec *actions_model.ActionSch
|
||||
WorkflowCommitSHA: cron.CommitSHA,
|
||||
}
|
||||
|
||||
_, err := jobparser.ValidateWorkflowStatic(cron.Content)
|
||||
if err == nil {
|
||||
err = validateCalledWorkflows(ctx, run, cron.Content)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid workflow: %w", err)
|
||||
}
|
||||
|
||||
// FIXME cron.Content might be outdated if the workflow file has been changed.
|
||||
// Load the latest sha from default branch
|
||||
// Insert the action run and its associated jobs into the database
|
||||
|
||||
@@ -103,7 +103,7 @@ func TestStartTasks(t *testing.T) {
|
||||
}
|
||||
|
||||
due := timeutil.TimeStamp(time.Now().Add(-time.Minute).Unix())
|
||||
validWorkflow := "jobs:\n job:\n runs-on: ubuntu-latest\n steps:\n - run: true\n"
|
||||
validWorkflow := "on:\n schedule:\n - cron: '0 0 * * *'\njobs:\n job:\n runs-on: ubuntu-latest\n steps:\n - run: true\n"
|
||||
|
||||
// specs are processed by ascending id, so the broken one runs first and used to abort the whole pass
|
||||
broken := insertSchedule(1, 2, "broken.yml", "@every 1m", "this: [is: not: a: workflow", due)
|
||||
|
||||
@@ -140,7 +140,10 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if _, err := jobparser.ValidateWorkflowStatic(content); err != nil {
|
||||
if _, err = jobparser.ValidateWorkflowStatic(content); err == nil {
|
||||
err = validateCalledWorkflows(ctx, run, content)
|
||||
}
|
||||
if err != nil {
|
||||
return 0, util.ErrorWrapTranslatable(util.NewInvalidArgumentErrorf("invalid workflow %q: %v", workflowID, err), "actions.runs.invalid_workflow_helper", err.Error())
|
||||
}
|
||||
workflow, err := jobparser.ReadWorkflow(content)
|
||||
|
||||
@@ -224,7 +224,7 @@ func handlePullRequestAutoMerge(ctx context.Context, pr *issues_model.PullReques
|
||||
|
||||
// although expectedHeadCommitID is checked before, we should pass it to the Merge function to
|
||||
// make it be checked again in case the head commit id changed after the previous check.
|
||||
if err := pull_service.Merge(pr.ID, doer, scheduledPRM.MergeStyle, expectedHeadCommitID, scheduledPRM.Message, true); err != nil {
|
||||
if err := pull_service.Merge(ctx, pr.ID, doer, scheduledPRM.MergeStyle, expectedHeadCommitID, scheduledPRM.Message, true); err != nil {
|
||||
if pull_service.IsErrSHADoesNotMatch(err) {
|
||||
return errors.Join(errSkipAutoMerge, err)
|
||||
}
|
||||
|
||||
+16
-1
@@ -14,6 +14,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
"uuid"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
@@ -27,6 +28,7 @@ import (
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/globallock"
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/gtprof"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/references"
|
||||
@@ -289,9 +291,22 @@ func hasPullRequestCommitBeenMerged(ctx context.Context, pr *issues_model.PullRe
|
||||
|
||||
// Merge merges pull request to base repository.
|
||||
// Caller should check PR is ready to be merged (review and status checks)
|
||||
func Merge(prID int64, doer *user_model.User, mergeStyle repo_model.MergeStyle, expectedHeadCommitID, message string, wasAutoMerged bool) error {
|
||||
func Merge(outerCtx context.Context, prID int64, doer *user_model.User, mergeStyle repo_model.MergeStyle, expectedHeadCommitID, message string, wasAutoMerged bool) error {
|
||||
outerCtxId := uuid.NewV4().String()
|
||||
|
||||
_, outerSpan := gtprof.GetTracer().Start(outerCtx, gtprof.TraceSpanContext)
|
||||
outerSpan.SetAttributeString("context.trace-id", outerCtxId) // this attribute is only used internally for debugging purpose
|
||||
defer outerSpan.End()
|
||||
|
||||
// TODO: in the future, the contexts from graceful.GetManager() should be wrapped with gtprof tracing, refactor the code to framework-level support
|
||||
ctx := graceful.GetManager().HammerContext() // don't abort the git operation even if the user's request is canceled
|
||||
|
||||
ctx, span := gtprof.GetTracer().Start(ctx, gtprof.TraceSpanContext)
|
||||
span.SetAttributeString(gtprof.TraceAttrGeneralName, "merge-pull-request")
|
||||
span.SetAttributeString(gtprof.TraceAttrGeneralDesc, fmt.Sprintf("merge pull request %d with merge style %s", prID, mergeStyle))
|
||||
span.SetAttributeString("context.trace-id-outer", outerCtxId) // this attribute is only used internally for debugging purpose
|
||||
defer span.End()
|
||||
|
||||
err := globallock.LockAndDo(ctx, getPullWorkingLockKey(prID), func(ctx context.Context) error {
|
||||
pr, err := issues_model.GetPullRequestByID(ctx, prID)
|
||||
if err != nil {
|
||||
|
||||
@@ -7,20 +7,26 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
const gitLogGraphFormatSep = "^" // disallowed char in git ref names
|
||||
|
||||
// GetCommitGraph return a list of commit (GraphItems) from all branches
|
||||
func GetCommitGraph(ctx context.Context, gitRepo *git.Repository, page, maxAllowedColors int, hidePRRefs bool, refs, files []string) (*Graph, error) {
|
||||
format := "DATA:%D|%H|%ad|%h|%s"
|
||||
|
||||
if page == 0 {
|
||||
page = 1
|
||||
}
|
||||
format := "DATA:" + strings.Join([]string{
|
||||
"%D", // ref names without the " (", ")" wrapping.
|
||||
"%H", // commit hash
|
||||
"%ad", // author date (format respects --date= option)
|
||||
"%h", // abbreviated commit hash
|
||||
"%s", // subject
|
||||
}, gitLogGraphFormatSep)
|
||||
|
||||
page = max(page, 1)
|
||||
graphCmd := gitcmd.NewCommand("log", "--graph", "--date-order", "--decorate=full")
|
||||
|
||||
if hidePRRefs {
|
||||
@@ -31,7 +37,7 @@ func GetCommitGraph(ctx context.Context, gitRepo *git.Repository, page, maxAllow
|
||||
graphCmd.AddArguments("--tags", "--branches")
|
||||
}
|
||||
|
||||
graphCmd.AddArguments("-C", "-M", "--date=iso-strict").
|
||||
graphCmd.AddArguments("--find-copies", "--find-renames", "--date=iso-strict").
|
||||
AddOptionFormat("-n %d", setting.UI.GraphMaxCommitNum*page).
|
||||
AddOptionFormat("--pretty=format:%s", format)
|
||||
|
||||
|
||||
@@ -216,7 +216,7 @@ func parseGitTime(timeStr string) time.Time {
|
||||
|
||||
// NewCommit creates a new commit from a provided line
|
||||
func NewCommit(row, column int, line []byte) (*Commit, error) {
|
||||
data := bytes.SplitN(line, []byte("|"), 5)
|
||||
data := bytes.SplitN(line, []byte(gitLogGraphFormatSep), 5)
|
||||
if len(data) < 5 {
|
||||
return nil, fmt.Errorf("malformed data section on line %d with commit: %s", row, string(line))
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ func BenchmarkGetCommitGraph(b *testing.B) {
|
||||
}
|
||||
|
||||
func BenchmarkParseCommitString(b *testing.B) {
|
||||
testString := "* DATA:|4e61bacab44e9b4730e44a6615d04098dd3a8eaf|2016-12-20 21:10:41 +0100|4e61bac|Add route for graph"
|
||||
testString := "* DATA:^4e61bacab44e9b4730e44a6615d04098dd3a8eaf^2016-12-20 21:10:41 +0100^4e61bac^Add route for graph"
|
||||
|
||||
parser := &Parser{}
|
||||
parser.Reset()
|
||||
@@ -224,14 +224,14 @@ func TestParseGlyphs(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCommitStringParsing(t *testing.T) {
|
||||
dataFirstPart := "* DATA:|4e61bacab44e9b4730e44a6615d04098dd3a8eaf|2016-12-20 21:10:41 +0100|4e61bac|"
|
||||
dataFirstPart := "* DATA:^4e61bacab44e9b4730e44a6615d04098dd3a8eaf^2016-12-20 21:10:41 +0100^4e61bac^"
|
||||
tests := []struct {
|
||||
shouldPass bool
|
||||
testName string
|
||||
commitMessage string
|
||||
}{
|
||||
{true, "normal", "not a fancy message"},
|
||||
{true, "extra pipe", "An extra pipe: |"},
|
||||
{true, "extra sep", "An extra sep"},
|
||||
{true, "extra 'Data:'", "DATA: might be trouble"},
|
||||
}
|
||||
|
||||
|
||||
@@ -9,18 +9,15 @@
|
||||
<h3 class="tw-m-0">{{ctx.Locale.Tr "repo.pulls.cmd_instruction_checkout_title"}}</h3>
|
||||
{{ctx.Locale.Tr "repo.pulls.cmd_instruction_checkout_desc"}}
|
||||
</div>
|
||||
{{$localBranch := $pull.HeadBranch}}
|
||||
{{if ne $pull.HeadRepo.ID $pull.BaseRepo.ID}}
|
||||
{{$localBranch = print $pull.HeadRepo.OwnerName "-" $pull.HeadBranch}}
|
||||
{{end}}
|
||||
{{$args := $pull.GetInstructionsCliArgs}}
|
||||
<div class="ui secondary segment tw-font-mono">
|
||||
{{$gitRemoteName := ctx.RootData.SystemConfig.Repository.GitGuideRemoteName.Value ctx}}
|
||||
{{if eq $pull.Flow 0}}
|
||||
<div>git fetch -u {{if ne $pull.HeadRepo.ID $pull.BaseRepo.ID}}{{$pull.HeadRepo.HTMLURL ctx}}{{else}}{{$gitRemoteName}}{{end}} {{$pull.HeadBranch}}:{{$localBranch}}</div>
|
||||
<div>git fetch -u {{if ne $pull.HeadRepo.ID $pull.BaseRepo.ID}}{{$pull.HeadRepo.HTMLURL ctx}}{{else}}{{$gitRemoteName}}{{end}} {{$args.HeadBranchArg}}:{{$args.LocalBranchArg}}</div>
|
||||
{{else}}
|
||||
<div>git fetch -u {{$gitRemoteName}} {{$pull.GetGitHeadRefName}}:{{$localBranch}}</div>
|
||||
<div>git fetch -u {{$gitRemoteName}} {{$pull.GetGitHeadRefName}}:{{$args.LocalBranchArg}}</div>
|
||||
{{end}}
|
||||
<div>git checkout {{$localBranch}}</div>
|
||||
<div>git checkout {{$args.LocalBranchArg}}</div>
|
||||
</div>
|
||||
{{if $data.ShowMergeInstructions}}
|
||||
<div>
|
||||
@@ -32,32 +29,32 @@
|
||||
</div>
|
||||
<div class="ui secondary segment tw-font-mono">
|
||||
<div data-pull-merge-style="merge">
|
||||
<div>git checkout {{$pull.BaseBranch}}</div>
|
||||
<div>git merge --no-ff {{$localBranch}}</div>
|
||||
<div>git checkout {{$args.BaseBranchArg}}</div>
|
||||
<div>git merge --no-ff {{$args.LocalBranchArg}}</div>
|
||||
</div>
|
||||
<div class="tw-hidden" data-pull-merge-style="rebase">
|
||||
<div>git checkout {{$pull.BaseBranch}}</div>
|
||||
<div>git merge --ff-only {{$localBranch}}</div>
|
||||
<div>git checkout {{$args.BaseBranchArg}}</div>
|
||||
<div>git merge --ff-only {{$args.LocalBranchArg}}</div>
|
||||
</div>
|
||||
<div class="tw-hidden" data-pull-merge-style="rebase-merge">
|
||||
<div>git checkout {{$localBranch}}</div>
|
||||
<div>git rebase {{$pull.BaseBranch}}</div>
|
||||
<div>git checkout {{$pull.BaseBranch}}</div>
|
||||
<div>git merge --no-ff {{$localBranch}}</div>
|
||||
<div>git checkout {{$args.LocalBranchArg}}</div>
|
||||
<div>git rebase {{$args.BaseBranchArg}}</div>
|
||||
<div>git checkout {{$args.BaseBranchArg}}</div>
|
||||
<div>git merge --no-ff {{$args.LocalBranchArg}}</div>
|
||||
</div>
|
||||
<div class="tw-hidden" data-pull-merge-style="squash">
|
||||
<div>git checkout {{$pull.BaseBranch}}</div>
|
||||
<div>git merge --squash {{$localBranch}}</div>
|
||||
<div>git checkout {{$args.BaseBranchArg}}</div>
|
||||
<div>git merge --squash {{$args.LocalBranchArg}}</div>
|
||||
</div>
|
||||
<div class="tw-hidden" data-pull-merge-style="fast-forward-only">
|
||||
<div>git checkout {{$pull.BaseBranch}}</div>
|
||||
<div>git merge --ff-only {{$localBranch}}</div>
|
||||
<div>git checkout {{$args.BaseBranchArg}}</div>
|
||||
<div>git merge --ff-only {{$args.LocalBranchArg}}</div>
|
||||
</div>
|
||||
<div class="tw-hidden" data-pull-merge-style="manually-merged">
|
||||
<div>git checkout {{$pull.BaseBranch}}</div>
|
||||
<div>git merge {{$localBranch}}</div>
|
||||
<div>git checkout {{$args.BaseBranchArg}}</div>
|
||||
<div>git merge {{$args.LocalBranchArg}}</div>
|
||||
</div>
|
||||
<div>git push {{$gitRemoteName}} {{$pull.BaseBranch}}</div>
|
||||
<div>git push {{$gitRemoteName}} {{$args.BaseBranchArg}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
{{end}}
|
||||
{{ctx.Locale.Tr "auth.authorize_application_created_by" .ApplicationCreatorLinkHTML}}<br>
|
||||
{{ctx.Locale.Tr "auth.authorize_application_with_scopes" (HTMLFormat "<b>%s</b>" .Scope)}}
|
||||
{{if .AddedScopes}}<br>{{ctx.Locale.Tr "auth.authorize_application_new_scopes" (HTMLFormat "<b>%s</b>" (StringUtils.Join .AddedScopes " "))}}{{end}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="ui attached segment">
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestActionsInvalidWorkflowPush(t *testing.T) {
|
||||
content string
|
||||
wantErrors []string
|
||||
}{
|
||||
{"expression", "on: push\nrun-name: '${{ github.ref'\njobs: {check: {if: unknown.x}}\n", []string{"Unrecognized named-value: 'unknown'", "unclosed expression"}},
|
||||
{"expression", "on: push\nrun-name: '${{ github.ref'\njobs: {check: {runs-on: ubuntu-latest, if: unknown.x}}\n", []string{"Unrecognized named-value: 'unknown'", "unclosed expression"}},
|
||||
{"trigger", "on:\njobs: {check: {runs-on: ubuntu-latest, steps: [{run: echo hello}]}}\n", []string{"invalid event"}},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
|
||||
@@ -405,8 +405,8 @@ jobs:
|
||||
from: 'consumer'
|
||||
`)
|
||||
|
||||
// Phase 1: no grant. The cross-repo read check fails, and NO ActionRun row gets persisted.
|
||||
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumerRepo.ID}))
|
||||
// Phase 1: no grant.
|
||||
assertInvalidWorkflowRun(t, consumerRepo.ID, "cross-caller.yaml", "reusable workflow repository user2/reusable-lib-private does not exist or is not readable")
|
||||
runner.fetchNoTask(t)
|
||||
|
||||
// Phase 2: user2 (libRepo owner) adds user4 (consumer owner) as a Collaborative Owner of libRepo.
|
||||
@@ -418,7 +418,7 @@ jobs:
|
||||
// Phase 3: trigger the workflow again
|
||||
createRepoWorkflowFile(t, user4, user4Token, consumerRepo, "marker.txt", "trigger after grant")
|
||||
|
||||
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumerRepo.ID})
|
||||
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumerRepo.ID, Index: 2})
|
||||
crossJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "cross_job"})
|
||||
assert.True(t, crossJob.IsReusableCaller)
|
||||
assert.True(t, crossJob.IsExpanded)
|
||||
@@ -484,8 +484,7 @@ jobs:
|
||||
uses: user2/reusable-lib-public-denied/.gitea/workflows/reusable_lib.yaml@main
|
||||
`)
|
||||
|
||||
// Denied: the cross-repo read check fails for the public caller, so NO ActionRun is persisted and no task is dispatched.
|
||||
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumerRepo.ID}))
|
||||
assertInvalidWorkflowRun(t, consumerRepo.ID, "cross-caller.yaml", "reusable workflow repository user2/reusable-lib-public-denied does not exist or is not readable")
|
||||
runner.fetchNoTask(t)
|
||||
})
|
||||
|
||||
@@ -563,35 +562,32 @@ jobs:
|
||||
unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "util_consumer_job"})
|
||||
})
|
||||
|
||||
t.Run("Missing callee file", func(t *testing.T) {
|
||||
// A caller workflow references a callee path that does not exist in the repo.
|
||||
|
||||
apiRepo := createActionsTestRepo(t, user2Token, "caller-missing-callee", false)
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
|
||||
|
||||
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/caller.yaml",
|
||||
`name: Caller
|
||||
on: push
|
||||
jobs:
|
||||
plain_job:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo 'job'
|
||||
call_missing:
|
||||
uses: ./.gitea/workflows/does-not-exist.yml
|
||||
`)
|
||||
|
||||
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
|
||||
t.Run("Missing or invalid callee fails the run as an invalid workflow file", func(t *testing.T) {
|
||||
for name, testCase := range map[string]struct{ callee, want string }{
|
||||
"missing": {"", "job call: read user2/caller-missing-callee@"},
|
||||
"no-runs-on": {"on: workflow_call\njobs:\n inner:\n steps:\n - run: echo\n", "job call: Error from called workflow ./.gitea/workflows/callee.yml: job inner: Required property is missing: runs-on"},
|
||||
} {
|
||||
apiRepo := createActionsTestRepo(t, user2Token, "caller-"+name+"-callee", false)
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
|
||||
if testCase.callee != "" {
|
||||
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/callee.yml", testCase.callee)
|
||||
}
|
||||
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/caller.yaml",
|
||||
"on: push\njobs:\n plain_job:\n runs-on: ubuntu-latest\n steps:\n - run: echo\n call:\n needs: plain_job\n uses: ./.gitea/workflows/callee.yml\n")
|
||||
assertInvalidWorkflowRun(t, repo.ID, "caller.yaml", testCase.want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Nested caller with missing callee fails with the error as summary instead of blocking", func(t *testing.T) {
|
||||
// When the expansion hits a terminal error (e.g. missing callee), the emitter must fail the caller and let the run finish as failed, not retry the expansion forever.
|
||||
apiRepo := createActionsTestRepo(t, user2Token, "nested-caller-missing-callee", false)
|
||||
t.Run("Nested caller failing to expand fails with the error as summary instead of blocking", func(t *testing.T) {
|
||||
// When the expansion hits a terminal error, the emitter must fail the caller and let the run finish as failed, not retry the expansion forever.
|
||||
apiRepo := createActionsTestRepo(t, user2Token, "nested-caller-bad-callee", false)
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
|
||||
|
||||
runner := newMockRunner()
|
||||
runner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-runner", []string{"ubuntu-latest"}, false)
|
||||
|
||||
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/lib.yml",
|
||||
"on:\n workflow_call:\n secrets:\n token:\n required: true\njobs:\n inner:\n runs-on: ubuntu-latest\n steps:\n - run: echo\n")
|
||||
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/caller.yaml",
|
||||
`name: Caller
|
||||
on: push
|
||||
@@ -602,7 +598,7 @@ jobs:
|
||||
- run: echo 'job'
|
||||
bad_caller:
|
||||
needs: plain_job
|
||||
uses: ./.gitea/workflows/does-not-exist.yml
|
||||
uses: ./.gitea/workflows/lib.yml
|
||||
`)
|
||||
|
||||
plainTask := runner.fetchTask(t)
|
||||
@@ -614,7 +610,7 @@ jobs:
|
||||
|
||||
runner.execTask(t, plainTask, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
||||
|
||||
// The emitter now tries to expand bad_caller, hits the missing callee, and fails the caller.
|
||||
// The emitter now tries to expand bad_caller, misses the required secret, and fails the caller.
|
||||
badCaller := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: badCallerPre.ID})
|
||||
assert.Equal(t, actions_model.StatusFailure, badCaller.Status)
|
||||
// No children were inserted (the terminal error precedes the child inserts).
|
||||
@@ -626,7 +622,7 @@ jobs:
|
||||
runner.fetchNoTask(t) // no task scheduled for the failed caller; the run is not stuck
|
||||
summary, err := actions_model.GetActionRunJobSummary(t.Context(), repo.ID, run.ID, badCaller.RunAttemptID, badCaller.ID, 0)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, summary.Content, "does-not-exist.yml")
|
||||
assert.Contains(t, summary.Content, "secret token is required, but not provided while calling")
|
||||
})
|
||||
|
||||
t.Run("Fork PR with secrets: inherit does not leak base repo secrets", func(t *testing.T) {
|
||||
@@ -989,6 +985,16 @@ jobs:
|
||||
})
|
||||
}
|
||||
|
||||
func assertInvalidWorkflowRun(t *testing.T, repoID int64, workflowID, want string) {
|
||||
t.Helper()
|
||||
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repoID, WorkflowID: workflowID})
|
||||
assert.Equal(t, actions_model.StatusFailure, run.Status)
|
||||
assert.Zero(t, unittest.GetCount(t, &actions_model.ActionRunJob{RunID: run.ID}))
|
||||
summary, err := actions_model.GetActionRunJobSummary(t.Context(), repoID, run.ID, run.LatestAttemptID, 0, 0)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, summary.Content, want)
|
||||
}
|
||||
|
||||
// token must belong to u (the commit identity) and have write access to repo. Reuse the caller's
|
||||
// existing token rather than logging in per call, which would re-run bcrypt password verification each time.
|
||||
func createRepoWorkflowFile(t *testing.T, u *user_model.User, token string, repo *repo_model.Repository, treePath, content string) {
|
||||
|
||||
@@ -104,6 +104,7 @@ func TestPackageNpm(t *testing.T) {
|
||||
},
|
||||
"cpu": ["x64", "arm64"],
|
||||
"os": ["linux", "darwin"],
|
||||
"libc": ["glibc"],
|
||||
"directories": {
|
||||
"doc": "./doc",
|
||||
"man": "./man"
|
||||
@@ -218,7 +219,7 @@ func TestPackageNpm(t *testing.T) {
|
||||
assert.Equal(t, packageBinPath, pmv.Bin[packageBinName])
|
||||
assert.Equal(t, integrity, pmv.Dist.Integrity)
|
||||
assert.Equal(t, sha1SumHex, pmv.Dist.Shasum)
|
||||
assert.Equal(t, fmt.Sprintf("%s%s/-/%s/%s", setting.AppURL, root[1:], packageVersion, filename), pmv.Dist.Tarball)
|
||||
assert.Equal(t, fmt.Sprintf("%sapi/packages/%s/npm/%s/-/%s", setting.AppURL, user.Name, packageName, filename), pmv.Dist.Tarball)
|
||||
assert.Equal(t, repoType, result.Repository.Type)
|
||||
assert.Equal(t, repoURL, result.Repository.URL)
|
||||
assert.Equal(t, map[string]string{"tea": "2.x", "soy-milk": "1.2"}, pmv.PeerDependencies)
|
||||
@@ -228,10 +229,24 @@ func TestPackageNpm(t *testing.T) {
|
||||
assert.Equal(t, map[string]string{"node": ">=22.7.0", "npm": ">=10.8.2"}, pmv.Engines)
|
||||
assert.Equal(t, []string{"x64", "arm64"}, pmv.CPU)
|
||||
assert.Equal(t, []string{"linux", "darwin"}, pmv.OS)
|
||||
assert.Equal(t, []string{"glibc"}, pmv.Libc)
|
||||
assert.Equal(t, map[string]string{"doc": "./doc", "man": "./man"}, pmv.Directories)
|
||||
assert.Equal(t, "https://example.com/fund", pmv.Funding)
|
||||
assert.Equal(t, map[string]string{"left-pad": "1.x"}, pmv.AcceptDependencies)
|
||||
assert.Empty(t, pmv.Deprecated)
|
||||
|
||||
req = NewRequest(t, "GET", root).AddTokenAuth(token).SetHeader("If-None-Match", resp.Header().Get("ETag"))
|
||||
MakeRequest(t, req, http.StatusNotModified)
|
||||
})
|
||||
|
||||
t.Run("PingWhoami", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
registry := fmt.Sprintf("/api/packages/%s/npm/-/", user.Name)
|
||||
MakeRequest(t, NewRequest(t, "GET", registry+"ping"), http.StatusOK)
|
||||
MakeRequest(t, NewRequest(t, "GET", registry+"whoami"), http.StatusUnauthorized)
|
||||
resp := MakeRequest(t, NewRequest(t, "GET", registry+"whoami").AddTokenAuth(token), http.StatusOK)
|
||||
assert.JSONEq(t, `{"username":"`+user.Name+`"}`, resp.Body.String())
|
||||
})
|
||||
|
||||
t.Run("PackageVersionMetadata", func(t *testing.T) {
|
||||
@@ -290,22 +305,6 @@ func TestPackageNpm(t *testing.T) {
|
||||
assert.Equal(t, packageVersion, result[packageTag2])
|
||||
})
|
||||
|
||||
t.Run("PackageMetadataDistTags", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
req := NewRequest(t, "GET", root).
|
||||
AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
result := DecodeJSON(t, resp, &npm.PackageMetadata{})
|
||||
|
||||
assert.Len(t, result.DistTags, 2)
|
||||
assert.Contains(t, result.DistTags, packageTag)
|
||||
assert.Equal(t, packageVersion, result.DistTags[packageTag])
|
||||
assert.Contains(t, result.DistTags, packageTag2)
|
||||
assert.Equal(t, packageVersion, result.DistTags[packageTag2])
|
||||
})
|
||||
|
||||
t.Run("DeleteTag", func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
@@ -319,6 +318,12 @@ func TestPackageNpm(t *testing.T) {
|
||||
test(t, http.StatusBadRequest, "1.0")
|
||||
test(t, http.StatusOK, "dummy")
|
||||
test(t, http.StatusOK, packageTag2)
|
||||
test(t, http.StatusOK, packageTag)
|
||||
|
||||
resp := MakeRequest(t, NewRequest(t, "GET", tagsRoot).AddTokenAuth(token), http.StatusOK)
|
||||
assert.Equal(t, map[string]string{packageTag: packageVersion}, DecodeJSON(t, resp, map[string]string{}))
|
||||
resp = MakeRequest(t, NewRequest(t, "GET", root+"/"+packageTag).AddTokenAuth(token), http.StatusOK)
|
||||
assert.Equal(t, packageVersion, DecodeJSON(t, resp, &npm.PackageMetadataVersion{}).Version)
|
||||
})
|
||||
|
||||
t.Run("Search", func(t *testing.T) {
|
||||
@@ -523,7 +528,7 @@ func TestPackageNpm(t *testing.T) {
|
||||
req := NewRequest(t, "DELETE", fmt.Sprintf("%s/-/%s/%s/-rev/dummy", root, packageVersion, filename))
|
||||
MakeRequest(t, req, http.StatusUnauthorized)
|
||||
|
||||
req = NewRequest(t, "DELETE", fmt.Sprintf("%s/-/%s/%s/-rev/dummy", root, packageVersion, filename)).
|
||||
req = NewRequest(t, "DELETE", fmt.Sprintf("%s/-/%s/-rev/dummy", root, filename)).
|
||||
AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
|
||||
@@ -378,11 +378,11 @@ func TestCantMergeConflict(t *testing.T) {
|
||||
BaseBranch: "base",
|
||||
})
|
||||
|
||||
err := pull_service.Merge(pr.ID, user1, repo_model.MergeStyleMerge, "", "CONFLICT", false)
|
||||
err := pull_service.Merge(t.Context(), pr.ID, user1, repo_model.MergeStyleMerge, "", "CONFLICT", false)
|
||||
assert.Error(t, err, "Merge should return an error due to conflict")
|
||||
assert.True(t, pull_service.IsErrMergeConflicts(err), "Merge error is not a conflict error")
|
||||
|
||||
err = pull_service.Merge(pr.ID, user1, repo_model.MergeStyleRebase, "", "CONFLICT", false)
|
||||
err = pull_service.Merge(t.Context(), pr.ID, user1, repo_model.MergeStyleRebase, "", "CONFLICT", false)
|
||||
assert.Error(t, err, "Merge should return an error due to conflict")
|
||||
assert.True(t, pull_service.IsErrRebaseConflicts(err), "Merge error is not a conflict error")
|
||||
})
|
||||
@@ -473,7 +473,7 @@ func TestCantMergeUnrelated(t *testing.T) {
|
||||
BaseBranch: "base",
|
||||
})
|
||||
|
||||
err = pull_service.Merge(pr.ID, user1, repo_model.MergeStyleMerge, "", "UNRELATED", false)
|
||||
err = pull_service.Merge(t.Context(), pr.ID, user1, repo_model.MergeStyleMerge, "", "UNRELATED", false)
|
||||
assert.Error(t, err, "Merge should return an error due to unrelated")
|
||||
assert.True(t, pull_service.IsErrMergeUnrelatedHistories(err), "Merge error is not a unrelated histories error")
|
||||
})
|
||||
@@ -509,7 +509,7 @@ func TestFastForwardOnlyMerge(t *testing.T) {
|
||||
BaseBranch: "master",
|
||||
})
|
||||
|
||||
err := pull_service.Merge(pr.ID, user1, repo_model.MergeStyleFastForwardOnly, "", "FAST-FORWARD-ONLY", false)
|
||||
err := pull_service.Merge(t.Context(), pr.ID, user1, repo_model.MergeStyleFastForwardOnly, "", "FAST-FORWARD-ONLY", false)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
@@ -596,7 +596,7 @@ func TestFastForwardOnlyMergeWithRequiredSignedCommits(t *testing.T) {
|
||||
pb.RequireSignedCommits = false
|
||||
require.NoError(t, git_model.UpdateProtectBranch(t.Context(), repo1, pb, git_model.WhitelistOptions{}))
|
||||
|
||||
require.NoError(t, pull_service.Merge(pr.ID, user1, repo_model.MergeStyleFastForwardOnly, "", "FAST-FORWARD-ONLY", false))
|
||||
require.NoError(t, pull_service.Merge(t.Context(), pr.ID, user1, repo_model.MergeStyleFastForwardOnly, "", "FAST-FORWARD-ONLY", false))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -631,7 +631,7 @@ func TestCantFastForwardOnlyMergeDiverging(t *testing.T) {
|
||||
BaseBranch: "master",
|
||||
})
|
||||
|
||||
err := pull_service.Merge(pr.ID, user1, repo_model.MergeStyleFastForwardOnly, "", "DIVERGING", false)
|
||||
err := pull_service.Merge(t.Context(), pr.ID, user1, repo_model.MergeStyleFastForwardOnly, "", "DIVERGING", false)
|
||||
assert.Error(t, err, "Merge should return an error due to being for a diverging branch")
|
||||
assert.True(t, pull_service.IsErrMergeDivergingFastForwardOnly(err), "Merge error is not a diverging fast-forward-only error")
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user