mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-26 05:19:44 +09:00
Compare commits
5
Commits
300331313b
...
c186cc4b8d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c186cc4b8d | ||
|
|
53d7d3f053 | ||
|
|
8161479fde | ||
|
|
2551f9949a | ||
|
|
3f833fd681 |
@@ -608,7 +608,8 @@ ENABLED = true
|
||||
;; * https://github.com/hickford/git-credential-oauth
|
||||
;; * https://github.com/git-ecosystem/git-credential-manager
|
||||
;; * https://gitea.com/gitea/tea
|
||||
;DEFAULT_APPLICATIONS = git-credential-oauth, git-credential-manager, tea
|
||||
;; * Gitea App (the official Gitea mobile app)
|
||||
;DEFAULT_APPLICATIONS = git-credential-oauth, git-credential-manager, tea, gitea-app
|
||||
;;
|
||||
;; By default, OAuth2 applications can only use "http" and "https" as their redirect URI schemes.
|
||||
;; If you need to use other schemes (e.g. for desktop applications), you can specify them here as a comma-separated list.
|
||||
@@ -1111,6 +1112,9 @@ LEVEL = Info
|
||||
;; The default branch name of new repositories
|
||||
;DEFAULT_BRANCH = main
|
||||
;;
|
||||
;; The default Git object format of new repositories. Available values: sha1, sha256.
|
||||
;DEFAULT_OBJECT_FORMAT = sha1
|
||||
;;
|
||||
;; Allow adoption of unadopted repositories
|
||||
;ALLOW_ADOPTION_OF_UNADOPTED_REPOSITORIES = false
|
||||
;;
|
||||
|
||||
@@ -9,10 +9,10 @@ package actions
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
@@ -147,7 +147,7 @@ type FindArtifactsOptions struct {
|
||||
db.ListOptions
|
||||
RepoID int64
|
||||
RunID int64
|
||||
RunAttemptID optional.Option[int64] // use optional to allow filtering by zero (legacy artifacts have run_attempt_id=0)
|
||||
RunAttemptIDs []int64 // empty means every attempt; pass 0 to target legacy artifacts, which have run_attempt_id=0
|
||||
ArtifactName string
|
||||
Status int
|
||||
FinalizedArtifactsV4 bool
|
||||
@@ -167,8 +167,8 @@ func (opts FindArtifactsOptions) ToConds() builder.Cond {
|
||||
if opts.RunID > 0 {
|
||||
cond = cond.And(builder.Eq{"run_id": opts.RunID})
|
||||
}
|
||||
if opts.RunAttemptID.Has() {
|
||||
cond = cond.And(builder.Eq{"run_attempt_id": opts.RunAttemptID.Value()})
|
||||
if len(opts.RunAttemptIDs) > 0 {
|
||||
cond = cond.And(builder.In("run_attempt_id", opts.RunAttemptIDs))
|
||||
}
|
||||
if opts.ArtifactName != "" {
|
||||
cond = cond.And(builder.Eq{"artifact_name": opts.ArtifactName})
|
||||
@@ -185,6 +185,27 @@ func (opts FindArtifactsOptions) ToConds() builder.Cond {
|
||||
return cond
|
||||
}
|
||||
|
||||
// FindReadableArtifacts returns the artifacts of opts.RunAttemptIDs, only keeps the ones from a newer attempt.
|
||||
func FindReadableArtifacts(ctx context.Context, opts FindArtifactsOptions) ([]*ActionArtifact, error) {
|
||||
arts, err := db.Find[ActionArtifact](ctx, opts)
|
||||
if err != nil || len(opts.RunAttemptIDs) <= 1 {
|
||||
return arts, err
|
||||
}
|
||||
return keepLatestAttemptArtifacts(arts), nil
|
||||
}
|
||||
|
||||
// keepLatestAttemptArtifacts keeps, per name, only the artifacts of the newest attempt that has it.
|
||||
// A v3 artifact is one row per uploaded file, so the whole group of the winning attempt is kept.
|
||||
func keepLatestAttemptArtifacts(arts []*ActionArtifact) []*ActionArtifact {
|
||||
latest := make(map[string]int64)
|
||||
for _, art := range arts {
|
||||
latest[art.ArtifactName] = max(latest[art.ArtifactName], art.RunAttemptID)
|
||||
}
|
||||
return slices.DeleteFunc(arts, func(art *ActionArtifact) bool {
|
||||
return art.RunAttemptID != latest[art.ArtifactName]
|
||||
})
|
||||
}
|
||||
|
||||
// ActionArtifactMeta is the meta-data of an artifact
|
||||
type ActionArtifactMeta struct {
|
||||
ArtifactName string
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestKeepLatestAttemptArtifacts(t *testing.T) {
|
||||
arts := []*ActionArtifact{
|
||||
{ID: 1, RunAttemptID: 1, ArtifactName: "inherited"},
|
||||
{ID: 2, RunAttemptID: 1, ArtifactName: "shadowed", ArtifactPath: "a.txt"},
|
||||
{ID: 3, RunAttemptID: 1, ArtifactName: "shadowed", ArtifactPath: "b.txt"},
|
||||
{ID: 4, RunAttemptID: 2, ArtifactName: "shadowed", ArtifactPath: "c.txt"},
|
||||
{ID: 5, RunAttemptID: 2, ArtifactName: "own"},
|
||||
}
|
||||
|
||||
// the whole "shadowed" group of attempt 1 is dropped, its multi-file rows must not mix with attempt 2
|
||||
var ids []int64
|
||||
for _, art := range keepLatestAttemptArtifacts(arts) {
|
||||
ids = append(ids, art.ID)
|
||||
}
|
||||
assert.Equal(t, []int64{1, 4, 5}, ids)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
@@ -96,6 +97,56 @@ func GetRunAttemptByRunIDAndAttemptNum(ctx context.Context, runID, attemptNum in
|
||||
return &attempt, nil
|
||||
}
|
||||
|
||||
// GetArtifactAttemptIDs returns the IDs of the attempts whose artifacts the job may read, newest first,
|
||||
// always including the job's own attempt.
|
||||
// An attempt that re-ran only some of the run's jobs keeps the artifacts of the attempt it re-ran from,
|
||||
// because the jobs it passed through never upload them again; a rerun of the whole run starts over.
|
||||
func GetArtifactAttemptIDs(ctx context.Context, job *ActionRunJob) ([]int64, error) {
|
||||
if job.Attempt <= 1 || job.RunAttemptID == 0 {
|
||||
return []int64{job.RunAttemptID}, nil
|
||||
}
|
||||
|
||||
attempts, err := ListRunAttemptsByRunID(ctx, job.RunID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// a newer attempt is never readable, and attempt 1 has nothing older to continue into
|
||||
candidateIDs := container.FilterSlice(attempts, func(a *ActionRunAttempt) (int64, bool) {
|
||||
return a.ID, a.Attempt > 1 && a.Attempt <= job.Attempt
|
||||
})
|
||||
passThroughAttemptIDs, err := findPassThroughAttemptIDs(ctx, candidateIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids := make([]int64, 0, len(attempts))
|
||||
for _, attempt := range attempts {
|
||||
if attempt.Attempt > job.Attempt {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, attempt.ID)
|
||||
if !slices.Contains(passThroughAttemptIDs, attempt.ID) {
|
||||
// stops at the first attempt that passed no job through
|
||||
break
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// findPassThroughAttemptIDs narrows the given attempts to those that were a rerun of selected jobs:
|
||||
// only such a rerun clones jobs carrying a source task.
|
||||
// TODO: best-effort. Needs a better way to distinguish between "partial re-run" and "full re-run".
|
||||
func findPassThroughAttemptIDs(ctx context.Context, attemptIDs []int64) ([]int64, error) {
|
||||
passThroughAttemptIDs := make([]int64, 0, len(attemptIDs))
|
||||
return passThroughAttemptIDs, db.GetEngine(ctx).
|
||||
Table("action_run_job").
|
||||
Cols("run_attempt_id").
|
||||
In("run_attempt_id", attemptIDs).
|
||||
Where("source_task_id <> 0").
|
||||
Distinct("run_attempt_id").
|
||||
Find(&passThroughAttemptIDs)
|
||||
}
|
||||
|
||||
// FindConcurrentRunAttempts returns attempts in the given concurrency group and status set.
|
||||
// Results are unordered; callers must not depend on any particular row order.
|
||||
func FindConcurrentRunAttempts(ctx context.Context, repoID int64, concurrencyGroup string, statuses []Status) ([]*ActionRunAttempt, error) {
|
||||
|
||||
@@ -83,6 +83,11 @@ func BuiltinApplications() map[string]*BuiltinOAuth2Application {
|
||||
DisplayName: "tea",
|
||||
RedirectURIs: []string{"http://127.0.0.1", "https://127.0.0.1"},
|
||||
}
|
||||
m["b757811a-05c8-4c76-8d74-a5ee3d2073f2"] = &BuiltinOAuth2Application{
|
||||
ConfigName: "gitea-app",
|
||||
DisplayName: "Gitea App",
|
||||
RedirectURIs: []string{"com.gitea.app://oauth/callback"},
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ func ParseScopedWorkflows(ctx context.Context, gitRepo *git.Repository, sourceCo
|
||||
func MatchScopedWorkflows(
|
||||
ctx context.Context,
|
||||
parsed []*ParsedScopedWorkflow,
|
||||
sourceCommitSHA string,
|
||||
consumerGitRepo *git.Repository,
|
||||
consumerCommit *git.Commit,
|
||||
inputEvent webhook_module.HookEventType,
|
||||
@@ -74,9 +75,10 @@ func MatchScopedWorkflows(
|
||||
continue
|
||||
}
|
||||
dwf := &DetectedWorkflow{
|
||||
EntryName: p.EntryName,
|
||||
TriggerEvent: evt,
|
||||
Content: p.Content,
|
||||
EntryName: p.EntryName,
|
||||
TriggerEvent: evt,
|
||||
Content: p.Content,
|
||||
SourceCommitSHA: sourceCommitSHA,
|
||||
}
|
||||
switch detectWorkflowMatch(ctx, consumerGitRepo, consumerCommit, inputEvent, payload, evt) {
|
||||
case detectMatched:
|
||||
|
||||
@@ -29,6 +29,8 @@ type DetectedWorkflow struct {
|
||||
EntryName string
|
||||
TriggerEvent *jobparser.Event
|
||||
Content []byte
|
||||
// SourceCommitSHA is the commit Content was read from, and must always be filled in together with Content.
|
||||
SourceCommitSHA string
|
||||
}
|
||||
|
||||
type detectResult int
|
||||
@@ -205,17 +207,19 @@ func DetectWorkflows(
|
||||
if evt.IsSchedule() {
|
||||
if detectSchedule {
|
||||
dwf := &DetectedWorkflow{
|
||||
EntryName: entry.Name(),
|
||||
TriggerEvent: evt,
|
||||
Content: content,
|
||||
EntryName: entry.Name(),
|
||||
TriggerEvent: evt,
|
||||
Content: content,
|
||||
SourceCommitSHA: commit.ID.String(),
|
||||
}
|
||||
schedules = append(schedules, dwf)
|
||||
}
|
||||
} else {
|
||||
dwf := &DetectedWorkflow{
|
||||
EntryName: entry.Name(),
|
||||
TriggerEvent: evt,
|
||||
Content: content,
|
||||
EntryName: entry.Name(),
|
||||
TriggerEvent: evt,
|
||||
Content: content,
|
||||
SourceCommitSHA: commit.ID.String(),
|
||||
}
|
||||
switch detectWorkflowMatch(ctx, gitRepo, commit, triggedEvent, payload, evt) {
|
||||
case detectMatched:
|
||||
@@ -254,9 +258,10 @@ func DetectScheduledWorkflows(ctx context.Context, gitRepo *git.Repository, comm
|
||||
if evt.IsSchedule() {
|
||||
log.Trace("detect scheduled workflow: %q", entry.Name())
|
||||
dwf := &DetectedWorkflow{
|
||||
EntryName: entry.Name(),
|
||||
TriggerEvent: evt,
|
||||
Content: content,
|
||||
EntryName: entry.Name(),
|
||||
TriggerEvent: evt,
|
||||
Content: content,
|
||||
SourceCommitSHA: commit.ID.String(),
|
||||
}
|
||||
wfs = append(wfs, dwf)
|
||||
}
|
||||
|
||||
@@ -115,9 +115,42 @@ func (h Sha256ObjectFormatImpl) ComputeHash(t ObjectType, content []byte) Object
|
||||
return h.MustID(hasher.Sum(nil))
|
||||
}
|
||||
|
||||
type invalidObjectFormatImpl struct{}
|
||||
|
||||
var emptyInvalidObjectID = &Sha1Hash{}
|
||||
|
||||
func (h invalidObjectFormatImpl) Name() string {
|
||||
return "invalid-object-format"
|
||||
}
|
||||
|
||||
func (h invalidObjectFormatImpl) EmptyObjectID() ObjectID {
|
||||
return emptyInvalidObjectID
|
||||
}
|
||||
|
||||
func (h invalidObjectFormatImpl) EmptyTree() ObjectID {
|
||||
return emptyInvalidObjectID
|
||||
}
|
||||
|
||||
func (h invalidObjectFormatImpl) FullLength() int {
|
||||
return len(emptyInvalidObjectID) * 2
|
||||
}
|
||||
|
||||
func (h invalidObjectFormatImpl) IsValid(input string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (h invalidObjectFormatImpl) MustID(b []byte) ObjectID {
|
||||
return emptyInvalidObjectID
|
||||
}
|
||||
|
||||
func (h invalidObjectFormatImpl) ComputeHash(t ObjectType, content []byte) ObjectID {
|
||||
return emptyInvalidObjectID
|
||||
}
|
||||
|
||||
var (
|
||||
Sha1ObjectFormat ObjectFormat = Sha1ObjectFormatImpl{}
|
||||
Sha256ObjectFormat ObjectFormat = Sha256ObjectFormatImpl{}
|
||||
Sha1ObjectFormat ObjectFormat = Sha1ObjectFormatImpl{}
|
||||
Sha256ObjectFormat ObjectFormat = Sha256ObjectFormatImpl{}
|
||||
invalidObjectFormat ObjectFormat = invalidObjectFormatImpl{}
|
||||
)
|
||||
|
||||
func ObjectFormatFromName(name string) ObjectFormat {
|
||||
@@ -126,9 +159,9 @@ func ObjectFormatFromName(name string) ObjectFormat {
|
||||
return objectFormat
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return invalidObjectFormat
|
||||
}
|
||||
|
||||
func IsValidObjectFormat(name string) bool {
|
||||
return ObjectFormatFromName(name) != nil
|
||||
return ObjectFormatFromName(name) != invalidObjectFormat
|
||||
}
|
||||
|
||||
@@ -10,14 +10,13 @@ import (
|
||||
)
|
||||
|
||||
func TestIsValidSHAPattern(t *testing.T) {
|
||||
h := Sha1ObjectFormat
|
||||
assert.True(t, h.IsValid("fee1"))
|
||||
assert.True(t, h.IsValid("abc000"))
|
||||
assert.True(t, h.IsValid("9023902390239023902390239023902390239023"))
|
||||
assert.False(t, h.IsValid("90239023902390239023902390239023902390239023"))
|
||||
assert.False(t, h.IsValid("abc"))
|
||||
assert.False(t, h.IsValid("123g"))
|
||||
assert.False(t, h.IsValid("some random text"))
|
||||
assert.True(t, Sha1ObjectFormat.IsValid("fee1"))
|
||||
assert.True(t, Sha1ObjectFormat.IsValid("abc000"))
|
||||
assert.True(t, Sha1ObjectFormat.IsValid("9023902390239023902390239023902390239023"))
|
||||
assert.False(t, Sha1ObjectFormat.IsValid("90239023902390239023902390239023902390239023"))
|
||||
assert.False(t, Sha1ObjectFormat.IsValid("abc"))
|
||||
assert.False(t, Sha1ObjectFormat.IsValid("123g"))
|
||||
assert.False(t, Sha1ObjectFormat.IsValid("some random text"))
|
||||
assert.Equal(t, "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391", ComputeBlobHash(Sha1ObjectFormat, nil).String())
|
||||
assert.Equal(t, "2e65efe2a145dda7ee51d1741299f848e5bf752e", ComputeBlobHash(Sha1ObjectFormat, []byte("a")).String())
|
||||
assert.Equal(t, "473a0f4c3be8a93681a267e3b1e9a7dcda1185436fe141f7749120a303721813", ComputeBlobHash(Sha256ObjectFormat, nil).String())
|
||||
@@ -25,3 +24,9 @@ func TestIsValidSHAPattern(t *testing.T) {
|
||||
assert.True(t, IsEmptyCommitID(""))
|
||||
assert.True(t, IsEmptyCommitID("0000000000000000000000000000000000000000"))
|
||||
}
|
||||
|
||||
func TestInvalidObjectFormat(t *testing.T) {
|
||||
of := ObjectFormatFromName("no-such")
|
||||
assert.NotNil(t, of)
|
||||
assert.False(t, IsValidObjectFormat("no-such"))
|
||||
}
|
||||
|
||||
Vendored
+33
-22
@@ -5,11 +5,11 @@ package external
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/markup"
|
||||
@@ -91,52 +91,63 @@ func (p *Renderer) GetExternalRendererOptions() (ret markup.ExternalRendererOpti
|
||||
return ret
|
||||
}
|
||||
|
||||
func envMark(envName string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "%" + envName + "%"
|
||||
func (p *Renderer) prepareExternalCommand(vars map[string]string) (string, []string, error) {
|
||||
fields, err := shellquote.Split(strings.TrimSpace(p.Command))
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return "$" + envName
|
||||
if len(fields) == 0 {
|
||||
return "", nil, errors.New("no command")
|
||||
}
|
||||
var replacements []string
|
||||
for k, v := range vars {
|
||||
replacements = append(replacements, "$"+k, v)
|
||||
replacements = append(replacements, "%"+k+"%", v) // for legacy Windows-style support
|
||||
}
|
||||
r := strings.NewReplacer(replacements...)
|
||||
for i := range fields {
|
||||
fields[i] = r.Replace(fields[i])
|
||||
}
|
||||
return fields[0], fields[1:], nil
|
||||
}
|
||||
|
||||
// Render renders the data of the document to HTML via the external tool.
|
||||
func (p *Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
|
||||
baseLinkSrc := ctx.RenderHelper.ResolveLink("", markup.LinkTypeDefault)
|
||||
baseLinkRaw := ctx.RenderHelper.ResolveLink("", markup.LinkTypeRaw)
|
||||
command := strings.NewReplacer(
|
||||
envMark("GITEA_PREFIX_SRC"), baseLinkSrc,
|
||||
envMark("GITEA_PREFIX_RAW"), baseLinkRaw,
|
||||
).Replace(p.Command)
|
||||
commands, err := shellquote.Split(command)
|
||||
if err != nil || len(commands) == 0 {
|
||||
return fmt.Errorf("%s invalid command %q: %w", p.Name(), p.Command, err)
|
||||
cmdVars := map[string]string{
|
||||
"GITEA_PREFIX_SRC": baseLinkSrc,
|
||||
"GITEA_PREFIX_RAW": baseLinkRaw,
|
||||
}
|
||||
cmdProg, cmdArgs, err := p.prepareExternalCommand(cmdVars)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid external render (%s) command %q: %w", p.Name(), p.Command, err)
|
||||
}
|
||||
args := commands[1:]
|
||||
|
||||
if p.IsInputFile {
|
||||
// write to temp file
|
||||
f, cleanup, err := setting.AppDataTempDir("git-repo-content").CreateTempFileRandom("gitea_input")
|
||||
tmpFile, cleanup, err := setting.AppDataTempDir("git-repo-content").CreateTempFileRandom("gitea_input")
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s create temp file when rendering %s failed: %w", p.Name(), p.Command, err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
_, err = io.Copy(f, input)
|
||||
_, err = io.Copy(tmpFile, input)
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
_ = tmpFile.Close()
|
||||
return fmt.Errorf("%s write data to temp file when rendering %s failed: %w", p.Name(), p.Command, err)
|
||||
}
|
||||
|
||||
err = f.Close()
|
||||
err = tmpFile.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s close temp file when rendering %s failed: %w", p.Name(), p.Command, err)
|
||||
}
|
||||
args = append(args, f.Name())
|
||||
cmdArgs = append(cmdArgs, tmpFile.Name())
|
||||
}
|
||||
|
||||
processCtx, _, finished := process.GetManager().AddContext(ctx, fmt.Sprintf("Render [%s] for %s", commands[0], baseLinkSrc))
|
||||
processCtx, _, finished := process.GetManager().AddContext(ctx, fmt.Sprintf("Render [%s] for %s", cmdProg, baseLinkSrc))
|
||||
defer finished()
|
||||
|
||||
cmd := exec.CommandContext(processCtx, commands[0], args...)
|
||||
cmd := exec.CommandContext(processCtx, cmdProg, cmdArgs...)
|
||||
cmd.Env = append(
|
||||
os.Environ(),
|
||||
"GITEA_PREFIX_SRC="+baseLinkSrc,
|
||||
@@ -151,7 +162,7 @@ func (p *Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.
|
||||
process.SetSysProcAttribute(cmd)
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("%s render run command %s %v failed: %w\nStderr: %s", p.Name(), commands[0], args, err, stderr.String())
|
||||
return fmt.Errorf("%s render run command %s %v failed: %w\nStderr: %s", p.Name(), cmdProg, shellquote.Join(cmdArgs...), err, stderr.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package external
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPrepareExternalCommand(t *testing.T) {
|
||||
r := &Renderer{MarkupRenderer: &setting.MarkupRenderer{Command: ""}}
|
||||
_, _, err := r.prepareExternalCommand(map[string]string{"KEY": "val"})
|
||||
assert.ErrorContains(t, err, "no command")
|
||||
|
||||
r = &Renderer{MarkupRenderer: &setting.MarkupRenderer{Command: `"/foo bar/bin" --opt $KEY "$KEY" %KEY% other`}}
|
||||
prog, args, err := r.prepareExternalCommand(map[string]string{"KEY": `a"b`})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "/foo bar/bin", prog)
|
||||
assert.Equal(t, []string{"--opt", `a"b`, `a"b`, `a"b`, "other"}, args)
|
||||
}
|
||||
@@ -108,7 +108,7 @@ var OAuth2 = struct {
|
||||
JWTSigningAlgorithm: "RS256",
|
||||
JWTSigningPrivateKeyFile: "jwt/private.pem",
|
||||
MaxTokenLength: math.MaxInt16,
|
||||
DefaultApplications: []string{"git-credential-oauth", "git-credential-manager", "tea"},
|
||||
DefaultApplications: []string{"git-credential-oauth", "git-credential-manager", "tea", "gitea-app"},
|
||||
}
|
||||
|
||||
func loadOAuth2From(rootCfg ConfigProvider) {
|
||||
|
||||
@@ -62,7 +62,7 @@ func TestGetGeneralSigningSecretSave(t *testing.T) {
|
||||
func TestOauth2DefaultApplications(t *testing.T) {
|
||||
cfg, _ := NewConfigProviderFromData(``)
|
||||
loadOAuth2From(cfg)
|
||||
assert.Equal(t, []string{"git-credential-oauth", "git-credential-manager", "tea"}, OAuth2.DefaultApplications)
|
||||
assert.Equal(t, []string{"git-credential-oauth", "git-credential-manager", "tea", "gitea-app"}, OAuth2.DefaultApplications)
|
||||
|
||||
cfg, _ = NewConfigProviderFromData(`[oauth2]
|
||||
DEFAULT_APPLICATIONS = tea
|
||||
|
||||
@@ -57,6 +57,7 @@ var (
|
||||
DisableMigrations bool
|
||||
DisableStars bool `ini:"DISABLE_STARS"`
|
||||
DefaultBranch string
|
||||
DefaultObjectFormat string
|
||||
AllowAdoptionOfUnadoptedRepositories bool
|
||||
AllowDeleteOfUnadoptedRepositories bool
|
||||
DisableDownloadSourceArchives bool
|
||||
@@ -186,6 +187,7 @@ var (
|
||||
DisableMigrations: false,
|
||||
DisableStars: false,
|
||||
DefaultBranch: "main",
|
||||
DefaultObjectFormat: "sha1",
|
||||
AllowForkWithoutMaximumLimit: true,
|
||||
StreamArchives: true,
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -74,7 +75,6 @@ import (
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/reqctx"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/storage"
|
||||
@@ -337,15 +337,19 @@ type (
|
||||
)
|
||||
|
||||
func (ar artifactRoutes) listArtifacts(ctx *ArtifactContext) {
|
||||
_, runID, ok := validateRunID(ctx)
|
||||
task, runID, ok := validateRunID(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
attemptIDs, ok := readableArtifactAttemptIDs(ctx, task)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{
|
||||
RunID: runID,
|
||||
RunAttemptID: optional.Some(ctx.ActionTask.Job.RunAttemptID),
|
||||
Status: int(actions.ArtifactStatusUploadConfirmed),
|
||||
artifacts, err := actions.FindReadableArtifacts(ctx, actions.FindArtifactsOptions{
|
||||
RunID: runID,
|
||||
RunAttemptIDs: attemptIDs,
|
||||
Status: int(actions.ArtifactStatusUploadConfirmed),
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("Error getting artifacts: %v", err)
|
||||
@@ -398,7 +402,7 @@ type (
|
||||
|
||||
// getDownloadArtifactURL generates download url for each artifact
|
||||
func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) {
|
||||
_, runID, ok := validateRunID(ctx)
|
||||
task, runID, ok := validateRunID(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -408,11 +412,16 @@ func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) {
|
||||
return
|
||||
}
|
||||
|
||||
artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{
|
||||
RunID: runID,
|
||||
RunAttemptID: optional.Some(ctx.ActionTask.Job.RunAttemptID),
|
||||
ArtifactName: itemPath,
|
||||
Status: int(actions.ArtifactStatusUploadConfirmed),
|
||||
attemptIDs, ok := readableArtifactAttemptIDs(ctx, task)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
artifacts, err := actions.FindReadableArtifacts(ctx, actions.FindArtifactsOptions{
|
||||
RunID: runID,
|
||||
RunAttemptIDs: attemptIDs,
|
||||
ArtifactName: itemPath,
|
||||
Status: int(actions.ArtifactStatusUploadConfirmed),
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("Error getting artifacts: %v", err)
|
||||
@@ -462,7 +471,7 @@ func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) {
|
||||
|
||||
// downloadArtifact downloads artifact content
|
||||
func (ar artifactRoutes) downloadArtifact(ctx *ArtifactContext) {
|
||||
_, runID, ok := validateRunID(ctx)
|
||||
task, runID, ok := validateRunID(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -484,10 +493,17 @@ func (ar artifactRoutes) downloadArtifact(ctx *ArtifactContext) {
|
||||
ctx.HTTPError(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if ctx.ActionTask.Job.RunAttemptID > 0 && artifact.RunAttemptID != ctx.ActionTask.Job.RunAttemptID {
|
||||
log.Error("Error mismatch runAttemptID and artifactID, task: %v, artifact: %v", ctx.ActionTask.Job.RunAttemptID, artifactID)
|
||||
ctx.HTTPError(http.StatusBadRequest)
|
||||
return
|
||||
// resolving the readable attempts costs a query, and an artifact of the task's own attempt never needs it
|
||||
if artifact.RunAttemptID != task.Job.RunAttemptID {
|
||||
attemptIDs, ok := readableArtifactAttemptIDs(ctx, task)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !slices.Contains(attemptIDs, artifact.RunAttemptID) {
|
||||
log.Error("Error artifact %d belongs to run attempt %d, which the task cannot read: %v", artifactID, artifact.RunAttemptID, attemptIDs)
|
||||
ctx.HTTPError(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if artifact.Status != actions.ArtifactStatusUploadConfirmed {
|
||||
log.Error("Error artifact not found: %s", artifact.Status.ToString())
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
"gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/storage"
|
||||
)
|
||||
@@ -261,9 +260,9 @@ func listOrderedChunksForArtifact(st storage.ObjectStorage, runID, artifactID in
|
||||
func mergeChunksForRun(ctx *ArtifactContext, st storage.ObjectStorage, runID, runAttemptID int64, artifactName string) error {
|
||||
// read all db artifacts by name
|
||||
artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{
|
||||
RunID: runID,
|
||||
RunAttemptID: optional.Some(runAttemptID),
|
||||
ArtifactName: artifactName,
|
||||
RunID: runID,
|
||||
RunAttemptIDs: []int64{runAttemptID},
|
||||
ArtifactName: artifactName,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -43,7 +43,7 @@ func validateRunID(ctx *ArtifactContext) (*actions.ActionTask, int64, bool) {
|
||||
return task, runID, true
|
||||
}
|
||||
|
||||
func validateRunIDV4(ctx *ArtifactContext, rawRunID string) (*actions.ActionTask, int64, bool) { //nolint:unparam // ActionTask is never used
|
||||
func validateRunIDV4(ctx *ArtifactContext, rawRunID string) (*actions.ActionTask, int64, bool) {
|
||||
task := ctx.ActionTask
|
||||
runID, err := strconv.ParseInt(rawRunID, 10, 64)
|
||||
if err != nil || task.Job.RunID != runID {
|
||||
@@ -54,6 +54,18 @@ func validateRunIDV4(ctx *ArtifactContext, rawRunID string) (*actions.ActionTask
|
||||
return task, runID, true
|
||||
}
|
||||
|
||||
// readableArtifactAttemptIDs resolves the attempts a task may read artifacts from:
|
||||
// its own attempt, plus the attempts it inherits from when only a subset of the run's jobs was re-run.
|
||||
func readableArtifactAttemptIDs(ctx *ArtifactContext, task *actions.ActionTask) ([]int64, bool) {
|
||||
attemptIDs, err := actions.GetArtifactAttemptIDs(ctx, task.Job)
|
||||
if err != nil {
|
||||
log.Error("Error getting readable artifact attempts: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError, "Error getting readable artifact attempts")
|
||||
return nil, false
|
||||
}
|
||||
return attemptIDs, true
|
||||
}
|
||||
|
||||
func validateArtifactHash(ctx *ArtifactContext, artifactName string) bool {
|
||||
paramHash := ctx.PathParam("artifact_hash")
|
||||
// use artifact name to create upload url
|
||||
|
||||
@@ -107,7 +107,6 @@ import (
|
||||
actions_module "gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/storage"
|
||||
"gitea.dev/modules/util"
|
||||
@@ -262,9 +261,28 @@ func (r *artifactV4Routes) verifySignature(ctx *ArtifactContext, endp string) (*
|
||||
return task, artifactName, true
|
||||
}
|
||||
|
||||
func (r *artifactV4Routes) getArtifactByName(ctx *ArtifactContext, runID, runAttemptID int64, name string) (*actions_model.ActionArtifact, error) {
|
||||
// getOwnAttemptArtifactByName resolves an artifact of the given attempt whatever its status,
|
||||
// since upload and finalize work on the pending row they just created.
|
||||
func (r *artifactV4Routes) getOwnAttemptArtifactByName(ctx *ArtifactContext, runID, runAttemptID int64, name string) (*actions_model.ActionArtifact, error) {
|
||||
return r.findArtifactByName(ctx, runID, []int64{runAttemptID}, name, nil)
|
||||
}
|
||||
|
||||
// getDownloadableArtifactByName resolves the newest artifact with the given name within the attempts whose content can still be served,
|
||||
// so a pending, deleted or expired row of a newer attempt does not shadow the confirmed copy inherited from an older one.
|
||||
func (r *artifactV4Routes) getDownloadableArtifactByName(ctx *ArtifactContext, runID int64, runAttemptIDs []int64, name string) (*actions_model.ActionArtifact, error) {
|
||||
return r.findArtifactByName(ctx, runID, runAttemptIDs, name, builder.Eq{"status": actions_model.ArtifactStatusUploadConfirmed})
|
||||
}
|
||||
|
||||
func (r *artifactV4Routes) findArtifactByName(ctx *ArtifactContext, runID int64, runAttemptIDs []int64, name string, extraCond builder.Cond) (*actions_model.ActionArtifact, error) {
|
||||
cond := builder.NewCond().
|
||||
And(builder.Eq{"run_id": runID, "artifact_name": name}, builder.Like{"content_encoding", "%/%"}).
|
||||
And(builder.In("run_attempt_id", runAttemptIDs))
|
||||
if extraCond != nil {
|
||||
cond = cond.And(extraCond)
|
||||
}
|
||||
|
||||
var art actions_model.ActionArtifact
|
||||
has, err := db.GetEngine(ctx).Where(builder.Eq{"run_id": runID, "run_attempt_id": runAttemptID, "artifact_name": name}, builder.Like{"content_encoding", "%/%"}).Get(&art)
|
||||
has, err := db.GetEngine(ctx).Where(cond).OrderBy("run_attempt_id DESC, id DESC").Get(&art)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
@@ -384,7 +402,7 @@ func (r *artifactV4Routes) uploadArtifact(ctx *ArtifactContext) {
|
||||
switch comp {
|
||||
case "block", "appendBlock":
|
||||
// get artifact by name
|
||||
artifact, err := r.getArtifactByName(ctx, task.Job.RunID, task.Job.RunAttemptID, artifactName)
|
||||
artifact, err := r.getOwnAttemptArtifactByName(ctx, task.Job.RunID, task.Job.RunAttemptID, artifactName)
|
||||
if err != nil {
|
||||
log.Error("Error artifact not found: %v", err)
|
||||
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
||||
@@ -471,7 +489,7 @@ func (r *artifactV4Routes) finalizeArtifact(ctx *ArtifactContext) {
|
||||
}
|
||||
|
||||
// get artifact by name
|
||||
artifact, err := r.getArtifactByName(ctx, runID, ctx.ActionTask.Job.RunAttemptID, req.Name)
|
||||
artifact, err := r.getOwnAttemptArtifactByName(ctx, runID, ctx.ActionTask.Job.RunAttemptID, req.Name)
|
||||
if err != nil {
|
||||
log.Error("Error artifact not found: %v", err)
|
||||
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
||||
@@ -578,14 +596,18 @@ func (r *artifactV4Routes) listArtifacts(ctx *ArtifactContext) {
|
||||
if ok := r.parseProtobufBody(ctx, &req); !ok {
|
||||
return
|
||||
}
|
||||
_, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
|
||||
task, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
attemptIDs, ok := readableArtifactAttemptIDs(ctx, task)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{
|
||||
artifacts, err := actions_model.FindReadableArtifacts(ctx, actions_model.FindArtifactsOptions{
|
||||
RunID: runID,
|
||||
RunAttemptID: optional.Some(ctx.ActionTask.Job.RunAttemptID),
|
||||
RunAttemptIDs: attemptIDs,
|
||||
Status: int(actions_model.ArtifactStatusUploadConfirmed),
|
||||
FinalizedArtifactsV4: true,
|
||||
})
|
||||
@@ -597,6 +619,8 @@ func (r *artifactV4Routes) listArtifacts(ctx *ArtifactContext) {
|
||||
|
||||
list := []*ListArtifactsResponse_MonolithArtifact{}
|
||||
|
||||
// both filters pick from what this attempt may read, so they run after the shadowed artifacts are gone:
|
||||
// a shadowed artifact is not downloadable either, GetSignedArtifactURL resolves by name
|
||||
table := map[string]*ListArtifactsResponse_MonolithArtifact{}
|
||||
for _, artifact := range artifacts {
|
||||
if _, ok := table[artifact.ArtifactName]; ok || req.IdFilter != nil && artifact.ID != req.IdFilter.Value || req.NameFilter != nil && artifact.ArtifactName != req.NameFilter.Value {
|
||||
@@ -631,7 +655,11 @@ func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) {
|
||||
if ok := r.parseProtobufBody(ctx, &req); !ok {
|
||||
return
|
||||
}
|
||||
_, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
|
||||
task, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
attemptIDs, ok := readableArtifactAttemptIDs(ctx, task)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -639,17 +667,12 @@ func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) {
|
||||
artifactName := req.Name
|
||||
|
||||
// get artifact by name
|
||||
artifact, err := r.getArtifactByName(ctx, runID, ctx.ActionTask.Job.RunAttemptID, artifactName)
|
||||
artifact, err := r.getDownloadableArtifactByName(ctx, runID, attemptIDs, artifactName)
|
||||
if err != nil {
|
||||
log.Error("Error artifact not found: %v", err)
|
||||
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
||||
return
|
||||
}
|
||||
if artifact.Status != actions_model.ArtifactStatusUploadConfirmed {
|
||||
log.Error("Error artifact not found: %s", artifact.Status.ToString())
|
||||
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
||||
return
|
||||
}
|
||||
|
||||
respData := GetSignedArtifactURLResponse{}
|
||||
|
||||
@@ -671,16 +694,15 @@ func (r *artifactV4Routes) downloadArtifact(ctx *ArtifactContext) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// get artifact by name
|
||||
artifact, err := r.getArtifactByName(ctx, task.Job.RunID, task.Job.RunAttemptID, artifactName)
|
||||
if err != nil {
|
||||
log.Error("Error artifact not found: %v", err)
|
||||
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
||||
attemptIDs, ok := readableArtifactAttemptIDs(ctx, task)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if artifact.Status != actions_model.ArtifactStatusUploadConfirmed {
|
||||
log.Error("Error artifact not found: %s", artifact.Status.ToString())
|
||||
|
||||
// get artifact by name
|
||||
artifact, err := r.getDownloadableArtifactByName(ctx, task.Job.RunID, attemptIDs, artifactName)
|
||||
if err != nil {
|
||||
log.Error("Error artifact not found: %v", err)
|
||||
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
||||
return
|
||||
}
|
||||
@@ -704,7 +726,7 @@ func (r *artifactV4Routes) deleteArtifact(ctx *ArtifactContext) {
|
||||
}
|
||||
|
||||
// get artifact by name
|
||||
artifact, err := r.getArtifactByName(ctx, runID, ctx.ActionTask.Job.RunAttemptID, req.Name)
|
||||
artifact, err := r.getOwnAttemptArtifactByName(ctx, runID, ctx.ActionTask.Job.RunAttemptID, req.Name)
|
||||
if err != nil {
|
||||
log.Error("Error artifact not found: %v", err)
|
||||
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
||||
|
||||
@@ -64,6 +64,7 @@ func RenderMarkup(ctx *context.Base, ctxRepo *context.Repository, mode, text, ur
|
||||
treePath = path.Dir(filePath) // it is "doc" if filePath is "doc/CHANGE.md"
|
||||
refPath = strings.Join(fields[3:], "/") // it is "branch/features/feat-12/doc"
|
||||
refPath = strings.TrimSuffix(refPath, "/"+treePath) // now we get the correct branch path: "branch/features/feat-12"
|
||||
refPath = util.PathEscapeSegments(refPath)
|
||||
} else if fields = strings.SplitN(repoLinkPath, "/", 3); len(fields) == 2 {
|
||||
repoOwnerName, repoName = fields[0], fields[1]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ package org
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
@@ -201,7 +200,7 @@ func prepareOrgProfileReadme(ctx *context.Context, prepareResult *shared_user.Pr
|
||||
}
|
||||
|
||||
rctx := renderhelper.NewRenderContextRepoFile(ctx, profileRepo, renderhelper.RepoFileOptions{
|
||||
CurrentRefSubURL: path.Join("branch", util.PathEscapeSegments(profileRepo.DefaultBranch)),
|
||||
CurrentRefSubURL: git.RefNameFromBranch(profileRepo.DefaultBranch).RefWebLinkPath(),
|
||||
})
|
||||
ctx.Data["ProfileReadmeContent"], err = markdown.RenderString(rctx, readmeBytes)
|
||||
if err != nil {
|
||||
|
||||
@@ -135,7 +135,7 @@ func createCommon(ctx *context.Context) {
|
||||
ctx.Data["CanCreateRepoInDoer"] = ctx.Doer.CanCreateRepoIn(ctx.Doer)
|
||||
ctx.Data["MaxCreationLimitOfDoer"] = ctx.Doer.MaxCreationLimit()
|
||||
ctx.Data["SupportedObjectFormats"] = git.DefaultFeatures().SupportedObjectFormats
|
||||
ctx.Data["DefaultObjectFormat"] = git.Sha1ObjectFormat
|
||||
ctx.Data["DefaultObjectFormat"] = git.ObjectFormatFromName(setting.Repository.DefaultObjectFormat)
|
||||
}
|
||||
|
||||
// Create render creating repository page
|
||||
|
||||
@@ -7,7 +7,6 @@ package user
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
activities_model "gitea.dev/models/activities"
|
||||
@@ -22,7 +21,6 @@ import (
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/routers/web/feed"
|
||||
"gitea.dev/routers/web/org"
|
||||
shared_user "gitea.dev/routers/web/shared/user"
|
||||
@@ -255,7 +253,7 @@ func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.R
|
||||
log.Error("failed to GetBlobContent: %v", err)
|
||||
} else {
|
||||
rctx := renderhelper.NewRenderContextRepoFile(ctx, profileDbRepo, renderhelper.RepoFileOptions{
|
||||
CurrentRefSubURL: path.Join("branch", util.PathEscapeSegments(profileDbRepo.DefaultBranch)),
|
||||
CurrentRefSubURL: git.RefNameFromBranch(profileDbRepo.DefaultBranch).RefWebLinkPath(),
|
||||
})
|
||||
if profileContent, err := markdown.RenderString(rctx, bytes); err != nil {
|
||||
log.Error("failed to RenderString: %v", err)
|
||||
|
||||
@@ -8,8 +8,10 @@ import (
|
||||
"fmt"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
actions_module "gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
@@ -51,6 +53,22 @@ func getInputsForJob(ctx context.Context, run *actions_model.ActionRun, job *act
|
||||
return p.Inputs, nil
|
||||
}
|
||||
|
||||
// pullRequestTargetBaseSHA returns the base branch commit of a pull_request_target run, and whether the run is one.
|
||||
func pullRequestTargetBaseSHA(run *actions_model.ActionRun) (string, bool) {
|
||||
if run.TriggerEvent != actions_module.GithubEventPullRequestTarget {
|
||||
return "", false
|
||||
}
|
||||
payload, err := run.GetPullRequestEventPayload()
|
||||
if err != nil {
|
||||
log.Error("run %d: get pull request event payload: %v", run.ID, err)
|
||||
return "", false
|
||||
}
|
||||
if payload.PullRequest == nil || payload.PullRequest.Base == nil || payload.PullRequest.Base.Sha == "" {
|
||||
return "", false
|
||||
}
|
||||
return payload.PullRequest.Base.Sha, true
|
||||
}
|
||||
|
||||
// evaluateJobIf evaluates a job's `if:`
|
||||
func evaluateJobIf(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, job *actions_model.ActionRunJob, vars map[string]string, allNeedsSucceed bool) (bool, error) {
|
||||
parsedJob, err := job.ParseJob()
|
||||
|
||||
@@ -8,6 +8,10 @@ import (
|
||||
"testing"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
actions_module "gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/json"
|
||||
api "gitea.dev/modules/structs"
|
||||
webhook_module "gitea.dev/modules/webhook"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -58,3 +62,59 @@ jobs:
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullRequestTargetBaseSHA(t *testing.T) {
|
||||
prPayload := func(baseSHA string) string {
|
||||
payload, err := json.Marshal(api.PullRequestPayload{
|
||||
PullRequest: &api.PullRequest{
|
||||
Base: &api.PRBranchInfo{Sha: baseSHA},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return string(payload)
|
||||
}
|
||||
|
||||
t.Run("pull_request_target with base SHA", func(t *testing.T) {
|
||||
run := &actions_model.ActionRun{
|
||||
Event: webhook_module.HookEventPullRequest,
|
||||
TriggerEvent: actions_module.GithubEventPullRequestTarget,
|
||||
EventPayload: prPayload("base-sha"),
|
||||
}
|
||||
got, ok := pullRequestTargetBaseSHA(run)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "base-sha", got)
|
||||
})
|
||||
|
||||
t.Run("non pull_request_target trigger", func(t *testing.T) {
|
||||
run := &actions_model.ActionRun{
|
||||
Event: webhook_module.HookEventPullRequest,
|
||||
TriggerEvent: actions_module.GithubEventPullRequest,
|
||||
EventPayload: prPayload("base-sha"),
|
||||
}
|
||||
got, ok := pullRequestTargetBaseSHA(run)
|
||||
assert.False(t, ok)
|
||||
assert.Empty(t, got)
|
||||
})
|
||||
|
||||
t.Run("missing base SHA", func(t *testing.T) {
|
||||
run := &actions_model.ActionRun{
|
||||
Event: webhook_module.HookEventPullRequest,
|
||||
TriggerEvent: actions_module.GithubEventPullRequestTarget,
|
||||
EventPayload: prPayload(""),
|
||||
}
|
||||
got, ok := pullRequestTargetBaseSHA(run)
|
||||
assert.False(t, ok)
|
||||
assert.Empty(t, got)
|
||||
})
|
||||
|
||||
t.Run("invalid payload", func(t *testing.T) {
|
||||
run := &actions_model.ActionRun{
|
||||
Event: webhook_module.HookEventPullRequest,
|
||||
TriggerEvent: actions_module.GithubEventPullRequestTarget,
|
||||
EventPayload: "{",
|
||||
}
|
||||
got, ok := pullRequestTargetBaseSHA(run)
|
||||
assert.False(t, ok)
|
||||
assert.Empty(t, got)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -336,8 +336,8 @@ func handleWorkflows(
|
||||
isForkPullRequest := isForkPullRequestInput(input)
|
||||
|
||||
for _, dwf := range detectedWorkflows {
|
||||
// repo-level run: the workflow content is this repo at this commit
|
||||
if err := buildApproveAndInsertRun(ctx, input, ref, commit, string(p), isForkPullRequest, dwf, input.Repo.ID, commit.ID.String(), false); err != nil {
|
||||
// repo-level run: the workflow content is this repo at dwf.SourceCommitSHA
|
||||
if err := buildApproveAndInsertRun(ctx, input, ref, commit, string(p), isForkPullRequest, dwf, input.Repo.ID, false); err != nil {
|
||||
log.Error("repo %s: %v", input.Repo.FullName(), err)
|
||||
continue
|
||||
}
|
||||
@@ -348,7 +348,7 @@ func handleWorkflows(
|
||||
// buildApproveAndInsertRun assembles an ActionRun for a detected workflow, runs the
|
||||
// fork-PR approval gate, and inserts it. Repo-level and scoped runs share this path so
|
||||
// run construction and the approval flow have a single implementation that can't drift.
|
||||
// workflowRepoID/workflowCommitSHA point at the repo+commit the workflow content comes
|
||||
// workflowRepoID and dwf.SourceCommitSHA point at the repo+commit the workflow content comes
|
||||
// from (the repo itself for repo-level runs, the source repo for scoped runs).
|
||||
func buildApproveAndInsertRun(
|
||||
ctx context.Context,
|
||||
@@ -359,9 +359,12 @@ func buildApproveAndInsertRun(
|
||||
isForkPullRequest bool,
|
||||
dwf *actions_module.DetectedWorkflow,
|
||||
workflowRepoID int64,
|
||||
workflowCommitSHA string,
|
||||
isScopedRun bool,
|
||||
) error {
|
||||
if dwf.SourceCommitSHA == "" {
|
||||
// unreachable in the normal flow; catches a test case that builds a DetectedWorkflow without it
|
||||
setting.PanicInDevOrTesting("workflow %q has no source commit", dwf.EntryName)
|
||||
}
|
||||
run := &actions_model.ActionRun{
|
||||
Title: commit.MessageTitle(),
|
||||
RepoID: input.Repo.ID,
|
||||
@@ -378,7 +381,7 @@ func buildApproveAndInsertRun(
|
||||
TriggerEvent: dwf.TriggerEvent.Name,
|
||||
Status: actions_model.StatusWaiting,
|
||||
WorkflowRepoID: workflowRepoID,
|
||||
WorkflowCommitSHA: workflowCommitSHA,
|
||||
WorkflowCommitSHA: dwf.SourceCommitSHA,
|
||||
IsScopedRun: isScopedRun,
|
||||
}
|
||||
|
||||
@@ -693,7 +696,7 @@ func detectAndHandleScopedWorkflows(
|
||||
continue
|
||||
}
|
||||
|
||||
sourceCommitSHA, detected, filtered, err := detectScopedWorkflowsForSource(ctx, input, consumerGitRepo, consumerCommit, sourceRepo)
|
||||
detected, filtered, err := detectScopedWorkflowsForSource(ctx, input, consumerGitRepo, consumerCommit, sourceRepo)
|
||||
if err != nil {
|
||||
log.Error("scoped workflows: source %d for consumer %s: %v", sourceRepoID, input.Repo.FullName(), err)
|
||||
continue
|
||||
@@ -706,7 +709,7 @@ func detectAndHandleScopedWorkflows(
|
||||
continue
|
||||
}
|
||||
|
||||
if err := buildApproveAndInsertRun(ctx, input, ref, consumerCommit, string(p), isForkPullRequest, dwf, sourceRepo.ID, sourceCommitSHA, true); err != nil {
|
||||
if err := buildApproveAndInsertRun(ctx, input, ref, consumerCommit, string(p), isForkPullRequest, dwf, sourceRepo.ID, true); err != nil {
|
||||
log.Error("scoped workflows: source %s workflow %s: %v", sourceRepo.FullName(), dwf.EntryName, err)
|
||||
continue
|
||||
}
|
||||
@@ -739,13 +742,13 @@ func detectScopedWorkflowsForSource(
|
||||
consumerGitRepo *git.Repository,
|
||||
consumerCommit *git.Commit,
|
||||
sourceRepo *repo_model.Repository,
|
||||
) (sourceCommitSHA string, detected, filtered []*actions_module.DetectedWorkflow, err error) {
|
||||
) (detected, filtered []*actions_module.DetectedWorkflow, err error) {
|
||||
// scoped workflow content is always taken from the source repo's default branch; the parse is cached per (source, default-branch SHA) and reused across consuming repos/events
|
||||
|
||||
sourceCommitSHA, parsed, err := LoadParsedScopedWorkflows(ctx, sourceRepo)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
detected, filtered = actions_module.MatchScopedWorkflows(ctx, parsed, consumerGitRepo, consumerCommit, input.Event, input.Payload)
|
||||
return sourceCommitSHA, detected, filtered, nil
|
||||
detected, filtered = actions_module.MatchScopedWorkflows(ctx, parsed, sourceCommitSHA, consumerGitRepo, consumerCommit, input.Event, input.Payload)
|
||||
return detected, filtered, nil
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/util"
|
||||
@@ -60,7 +61,11 @@ func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRu
|
||||
if err != nil {
|
||||
return nil, 0, "", fmt.Errorf("look up caller source repo %d: %w", caller.WorkflowSourceRepoID, err)
|
||||
}
|
||||
bytes, resolvedSHA, err := readWorkflowFromRepo(ctx, callerRepo, caller.WorkflowSourceCommitSHA, ref.Path)
|
||||
sourceCommitSHA := resolveSameRepoWorkflowSourceCommit(run, caller)
|
||||
if sourceCommitSHA != caller.WorkflowSourceCommitSHA {
|
||||
log.Warn("run %d (pull_request_target) records workflow source commit %s, resolving %q at base commit %s instead", run.ID, caller.WorkflowSourceCommitSHA, ref.Path, sourceCommitSHA)
|
||||
}
|
||||
bytes, resolvedSHA, err := readWorkflowFromRepo(ctx, callerRepo, sourceCommitSHA, ref.Path)
|
||||
if err != nil {
|
||||
return nil, 0, "", err
|
||||
}
|
||||
@@ -92,6 +97,19 @@ func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRu
|
||||
return nil, 0, "", fmt.Errorf("unsupported uses kind %d", ref.Kind)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// only a SHA copied from the run row can be the polluted head one; a SHA resolved from a `uses:` ref is right by construction
|
||||
if run.IsScopedRun || caller.WorkflowSourceRepoID != run.RepoID || caller.WorkflowSourceCommitSHA != run.WorkflowCommitSHA {
|
||||
return caller.WorkflowSourceCommitSHA
|
||||
}
|
||||
if baseSHA, ok := pullRequestTargetBaseSHA(run); ok && baseSHA != caller.WorkflowSourceCommitSHA {
|
||||
return baseSHA
|
||||
}
|
||||
return caller.WorkflowSourceCommitSHA
|
||||
}
|
||||
|
||||
// readWorkflowFromRepo loads a workflow file from `repo` at `refOrSHA` and returns its content plus the resolved commit SHA.
|
||||
func readWorkflowFromRepo(ctx context.Context, repo *repo_model.Repository, refOrSHA, path string) ([]byte, string, error) {
|
||||
gitRepo, err := git.OpenRepository(ctx, repo)
|
||||
|
||||
@@ -10,9 +10,13 @@ import (
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
actions_module "gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/test"
|
||||
webhook_module "gitea.dev/modules/webhook"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -289,3 +293,74 @@ func TestUndoExpansion(t *testing.T) {
|
||||
assert.False(t, refreshed.IsExpanded)
|
||||
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: sibling.ID})
|
||||
}
|
||||
|
||||
func TestResolveSameRepoWorkflowSourceCommit(t *testing.T) {
|
||||
prtRun := func(baseSHA string) *actions_model.ActionRun {
|
||||
payload, err := json.Marshal(api.PullRequestPayload{
|
||||
PullRequest: &api.PullRequest{
|
||||
Base: &api.PRBranchInfo{Sha: baseSHA},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// a run recorded before the fix points at the PR head commit
|
||||
return &actions_model.ActionRun{
|
||||
ID: 42,
|
||||
RepoID: 1,
|
||||
Event: webhook_module.HookEventPullRequest,
|
||||
TriggerEvent: actions_module.GithubEventPullRequestTarget,
|
||||
EventPayload: string(payload),
|
||||
WorkflowCommitSHA: "head-sha",
|
||||
}
|
||||
}
|
||||
pushRun := &actions_model.ActionRun{
|
||||
RepoID: 1,
|
||||
TriggerEvent: "push",
|
||||
WorkflowCommitSHA: "head-sha",
|
||||
}
|
||||
caller := func(sourceRepoID int64, sourceCommitSHA string) *actions_model.ActionRunJob {
|
||||
return &actions_model.ActionRunJob{WorkflowSourceRepoID: sourceRepoID, WorkflowSourceCommitSHA: sourceCommitSHA}
|
||||
}
|
||||
|
||||
t.Run("pull_request_target pins to base commit", func(t *testing.T) {
|
||||
got := resolveSameRepoWorkflowSourceCommit(prtRun("base-sha"), caller(1, "head-sha"))
|
||||
assert.Equal(t, "base-sha", got)
|
||||
})
|
||||
|
||||
t.Run("legacy nested caller (with head-sha) pins to base commit", func(t *testing.T) {
|
||||
nested := caller(1, "head-sha")
|
||||
nested.ParentJobID = 99
|
||||
got := resolveSameRepoWorkflowSourceCommit(prtRun("base-sha"), nested)
|
||||
assert.Equal(t, "base-sha", got)
|
||||
})
|
||||
|
||||
t.Run("pull_request_target keeps stored SHA when already base", func(t *testing.T) {
|
||||
run := prtRun("base-sha")
|
||||
run.WorkflowCommitSHA = "base-sha"
|
||||
got := resolveSameRepoWorkflowSourceCommit(run, caller(1, "base-sha"))
|
||||
assert.Equal(t, "base-sha", got)
|
||||
})
|
||||
|
||||
t.Run("non pull_request_target keeps stored SHA", func(t *testing.T) {
|
||||
got := resolveSameRepoWorkflowSourceCommit(pushRun, caller(1, "head-sha"))
|
||||
assert.Equal(t, "head-sha", got)
|
||||
})
|
||||
|
||||
t.Run("scoped run keeps stored SHA", func(t *testing.T) {
|
||||
run := prtRun("base-sha")
|
||||
run.IsScopedRun = true
|
||||
got := resolveSameRepoWorkflowSourceCommit(run, caller(1, "head-sha"))
|
||||
assert.Equal(t, "head-sha", got)
|
||||
})
|
||||
|
||||
t.Run("cross-repo caller keeps stored SHA", func(t *testing.T) {
|
||||
got := resolveSameRepoWorkflowSourceCommit(prtRun("base-sha"), caller(2, "head-sha"))
|
||||
assert.Equal(t, "head-sha", got)
|
||||
})
|
||||
|
||||
t.Run("caller resolved from a uses: ref keeps its own SHA", func(t *testing.T) {
|
||||
nested := caller(1, "tag-v1-sha")
|
||||
nested.ParentJobID = 99
|
||||
got := resolveSameRepoWorkflowSourceCommit(prtRun("base-sha"), nested)
|
||||
assert.Equal(t, "tag-v1-sha", got)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -185,12 +185,12 @@
|
||||
</div>
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.object_format"}}</label>
|
||||
<div class="ui selection owner dropdown">
|
||||
<input type="hidden" id="object_format_name" name="object_format_name" value="{{or .object_format_name .DefaultObjectFormat.Name}}" required>
|
||||
<div class="ui selection dropdown">
|
||||
<input type="hidden" name="object_format_name" value="{{or .object_format_name .DefaultObjectFormat.Name}}">
|
||||
<div class="default text">{{.DefaultObjectFormat.Name}}</div>
|
||||
<div class="menu">
|
||||
{{range .SupportedObjectFormats}}
|
||||
<div class="item" data-value="{{.Name}}">{{.Name}}</div>
|
||||
{{range $objFmt := .SupportedObjectFormats}}
|
||||
<div class="item" data-value="{{$objFmt.Name}}">{{$objFmt.Name}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
actions_module "gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/queue"
|
||||
@@ -623,8 +624,6 @@ jobs:
|
||||
|
||||
apiBaseRepo := createActionsTestRepo(t, user2Token, "fork-pr-inherit-test", false)
|
||||
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiBaseRepo.ID})
|
||||
user2APICtx := NewAPITestContext(t, baseRepo.OwnerName, baseRepo.Name, auth_model.AccessTokenScopeWriteRepository)
|
||||
defer doAPIDeleteRepository(user2APICtx)(t)
|
||||
|
||||
// Real secret that must never reach a fork PR task.
|
||||
req := NewRequestWithJSON(t, "PUT",
|
||||
@@ -665,7 +664,6 @@ jobs:
|
||||
apiForkRepo := DecodeJSON(t, resp, &api.Repository{})
|
||||
forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiForkRepo.ID})
|
||||
user4APICtx := NewAPITestContext(t, user4.Name, forkRepo.Name, auth_model.AccessTokenScopeWriteRepository)
|
||||
defer doAPIDeleteRepository(user4APICtx)(t)
|
||||
|
||||
// user4 pushes a change on the fork and opens a PR to base
|
||||
doAPICreateFile(user4APICtx, "user4-fix.txt", &api.CreateFileOptions{
|
||||
@@ -702,6 +700,100 @@ jobs:
|
||||
runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
||||
})
|
||||
|
||||
t.Run("pull_request_target resolves a local reusable workflow at the base commit", func(t *testing.T) {
|
||||
apiBaseRepo := createActionsTestRepo(t, user2Token, "prt-reusable-test", false)
|
||||
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiBaseRepo.ID})
|
||||
|
||||
runner := newMockRunner()
|
||||
runner.registerAsRepoRunner(t, baseRepo.OwnerName, baseRepo.Name, "mock-prt-runner", []string{"ubuntu-latest"}, false)
|
||||
|
||||
reusablePath := ".gitea/workflows/reusable.yaml"
|
||||
// A pull_request_target run's workflow should always come from the base branch.
|
||||
createRepoWorkflowFile(t, user2, user2Token, baseRepo, reusablePath, `name: Reusable
|
||||
on:
|
||||
workflow_call:
|
||||
jobs:
|
||||
trusted:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo trusted
|
||||
`)
|
||||
baseFile := createWorkflowFile(t, user2Token, baseRepo.OwnerName, baseRepo.Name, ".gitea/workflows/prt.yaml",
|
||||
getWorkflowCreateFileOptions(user2, baseRepo.DefaultBranch, "create prt.yaml", `name: PRT
|
||||
on: pull_request_target
|
||||
jobs:
|
||||
call_reusable:
|
||||
uses: ./.gitea/workflows/reusable.yaml
|
||||
secrets: inherit
|
||||
`))
|
||||
baseSHA := baseFile.Commit.SHA
|
||||
|
||||
// user4 forks
|
||||
req := NewRequestWithJSON(t, "POST",
|
||||
fmt.Sprintf("/api/v1/repos/%s/%s/forks", baseRepo.OwnerName, baseRepo.Name),
|
||||
&api.CreateForkOption{Name: new("prt-reusable-test-fork")}).AddTokenAuth(user4Token)
|
||||
resp := MakeRequest(t, req, http.StatusAccepted)
|
||||
apiForkRepo := DecodeJSON(t, resp, &api.Repository{})
|
||||
forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiForkRepo.ID})
|
||||
user4APICtx := NewAPITestContext(t, user4.Name, forkRepo.Name, auth_model.AccessTokenScopeWriteRepository)
|
||||
|
||||
// user4 rewrites the reusable workflow the base branch calls into, and opens a PR
|
||||
req = NewRequest(t, "GET",
|
||||
fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", forkRepo.OwnerName, forkRepo.Name, reusablePath)).AddTokenAuth(user4Token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
forkReusable := DecodeJSON(t, resp, &api.ContentsResponse{})
|
||||
|
||||
req = NewRequestWithJSON(t, "PUT",
|
||||
fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", forkRepo.OwnerName, forkRepo.Name, reusablePath), &api.UpdateFileOptions{
|
||||
FileOptions: api.FileOptions{
|
||||
NewBranchName: "fork-branch",
|
||||
Message: "rewrite the reusable workflow",
|
||||
Author: api.Identity{Name: user4.Name, Email: user4.Email},
|
||||
Committer: api.Identity{Name: user4.Name, Email: user4.Email},
|
||||
Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
|
||||
},
|
||||
SHA: forkReusable.SHA,
|
||||
ContentBase64: base64.StdEncoding.EncodeToString([]byte(`name: Reusable
|
||||
on:
|
||||
workflow_call:
|
||||
jobs:
|
||||
from-fork:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo from-fork
|
||||
`)),
|
||||
}).AddTokenAuth(user4Token)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
forkHeadSHA := DecodeJSON(t, resp, &api.FileResponse{}).Commit.SHA
|
||||
require.NotEqual(t, baseSHA, forkHeadSHA)
|
||||
|
||||
doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":fork-branch")(t)
|
||||
|
||||
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: baseRepo.ID}))
|
||||
prtRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID})
|
||||
assert.Equal(t, actions_module.GithubEventPullRequestTarget, prtRun.TriggerEvent)
|
||||
assert.True(t, prtRun.IsForkPullRequest)
|
||||
assert.False(t, prtRun.NeedApproval)
|
||||
// The run still points at the PR head, but its workflow source must be the base commit.
|
||||
assert.Equal(t, forkHeadSHA, prtRun.CommitSHA)
|
||||
assert.Equal(t, baseSHA, prtRun.WorkflowCommitSHA)
|
||||
|
||||
caller := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: prtRun.ID, JobID: "call_reusable"})
|
||||
assert.Equal(t, baseSHA, caller.WorkflowSourceCommitSHA)
|
||||
assert.NotContains(t, string(caller.ReusableWorkflowContent), "from-fork")
|
||||
|
||||
// The caller has no needs, so it is expanded inline at insert time: the child comes from the base branch.
|
||||
unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{RunID: prtRun.ID, JobID: "from-fork"})
|
||||
child := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: prtRun.ID, JobID: "trusted"})
|
||||
assert.Equal(t, caller.ID, child.ParentJobID)
|
||||
assert.Equal(t, baseSHA, child.WorkflowSourceCommitSHA)
|
||||
|
||||
task := runner.fetchTask(t)
|
||||
_, taskJob, _ := getTaskAndJobAndRunByTaskID(t, task.Id)
|
||||
require.Equal(t, "trusted", taskJob.JobID)
|
||||
runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
||||
})
|
||||
|
||||
t.Run("Caller alternates expanding across attempts", func(t *testing.T) {
|
||||
apiRepo := createActionsTestRepo(t, user2Token, "caller-walkback-test", false)
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
|
||||
|
||||
@@ -583,6 +583,10 @@ jobs:
|
||||
t.Run("testActionRunAttemptArtifactV4", func(t *testing.T) {
|
||||
testActionRunAttemptArtifactV4(t, repo, session, runner)
|
||||
})
|
||||
|
||||
t.Run("testPartialRerunArtifactInheritance", func(t *testing.T) {
|
||||
testPartialRerunArtifactInheritance(t, user2, token, repo, session, runner)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -627,6 +631,107 @@ func testActionRunAttemptArtifactV3(t *testing.T, repo *repo_model.Repository, s
|
||||
assert.Equal(t, strings.Repeat("D", 32), sharedContent2)
|
||||
}
|
||||
|
||||
func testPartialRerunArtifactInheritance(t *testing.T, user *user_model.User, token string, repo *repo_model.Repository, session *TestSession, runner *mockRunner) {
|
||||
wfTreePath := ".gitea/workflows/partial-rerun-artifact.yml"
|
||||
wfFileContent := `name: partial-rerun-artifact
|
||||
on:
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo 'job1'
|
||||
job2:
|
||||
runs-on: ubuntu-latest
|
||||
needs: job1
|
||||
steps:
|
||||
- run: echo 'job2'
|
||||
`
|
||||
opts := getWorkflowCreateFileOptions(user, repo.DefaultBranch, "create "+wfTreePath, wfFileContent)
|
||||
createWorkflowFile(t, token, user.Name, repo.Name, wfTreePath, opts)
|
||||
|
||||
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/actions/run?workflow=%s", repo.OwnerName, repo.Name, "partial-rerun-artifact.yml"), map[string]string{
|
||||
"ref": "refs/heads/main",
|
||||
})
|
||||
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
|
||||
// job1 uploads the artifacts and succeeds
|
||||
task1 := runner.fetchTask(t)
|
||||
_, job1, run := getTaskAndJobAndRunByTaskID(t, task1.Id)
|
||||
taskToken1 := task1.Context.GetFields()["gitea_runtime_token"].GetStringValue()
|
||||
uploadTestArtifactFileV4(t, run.ID, job1.ID, taskToken1, "job1-only", strings.Repeat("A", 32))
|
||||
uploadTestArtifactFileV4(t, run.ID, job1.ID, taskToken1, "job1-shared", strings.Repeat("B", 32))
|
||||
// a v3 artifact is one row per file, so this one spans two rows
|
||||
uploadTestArtifactFile(t, run.ID, taskToken1, "job1-v3", "a.txt", strings.Repeat("D", 32))
|
||||
uploadTestArtifactFile(t, run.ID, taskToken1, "job1-v3", "b.txt", strings.Repeat("E", 32))
|
||||
runner.execTask(t, task1, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
||||
|
||||
// job2 fails
|
||||
task2 := runner.fetchTask(t)
|
||||
_, job2, _ := getTaskAndJobAndRunByTaskID(t, task2.Id)
|
||||
runner.execTask(t, task2, &mockTaskOutcome{result: runnerv1.Result_RESULT_FAILURE})
|
||||
|
||||
// re-run only the failed job, so job1 is passed through and never uploads its artifacts again
|
||||
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun-failed", repo.OwnerName, repo.Name, run.ID))
|
||||
session.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
task3 := runner.fetchTask(t)
|
||||
_, job3, _ := getTaskAndJobAndRunByTaskID(t, task3.Id)
|
||||
require.Equal(t, job2.JobID, job3.JobID)
|
||||
require.NotEqual(t, job2.RunAttemptID, job3.RunAttemptID)
|
||||
taskToken3 := task3.Context.GetFields()["gitea_runtime_token"].GetStringValue()
|
||||
|
||||
// the new attempt inherits what the previous attempt uploaded
|
||||
assert.ElementsMatch(t, []string{"job1-only", "job1-shared"}, listArtifactNamesForRunV4(t, run.ID, job3.ID, taskToken3))
|
||||
assert.Equal(t, strings.Repeat("A", 32), downloadArtifactContentV4ByTask(t, run.ID, job3.ID, taskToken3, "job1-only"))
|
||||
assert.Contains(t, listArtifactNamesForRun(t, run.ID, taskToken3), "job1-v3")
|
||||
|
||||
// a pending upload of this attempt must not shadow the confirmed copy it inherited
|
||||
createTestArtifactV4(t, run.ID, job3.ID, taskToken3, "job1-only")
|
||||
assert.Equal(t, strings.Repeat("A", 32), downloadArtifactContentV4ByTask(t, run.ID, job3.ID, taskToken3, "job1-only"))
|
||||
|
||||
// both rows of the inherited v3 artifact are readable
|
||||
inheritedV3 := getArtifactDownloadItemsForRun(t, run.ID, taskToken3, "job1-v3")
|
||||
require.Len(t, inheritedV3, 2)
|
||||
assert.Equal(t, strings.Repeat("D", 32), downloadArtifactItemContent(t, taskToken3, inheritedV3[0]))
|
||||
|
||||
// uploading an inherited name in this attempt shadows the inherited artifact
|
||||
inheritedSharedID := listArtifactIDForRunV4(t, run.ID, job3.ID, taskToken3, "job1-shared")
|
||||
require.Len(t, listArtifactsByIDV4(t, run.ID, job3.ID, inheritedSharedID, taskToken3), 1)
|
||||
uploadTestArtifactFileV4(t, run.ID, job3.ID, taskToken3, "job1-shared", strings.Repeat("C", 32))
|
||||
assert.ElementsMatch(t, []string{"job1-only", "job1-shared"}, listArtifactNamesForRunV4(t, run.ID, job3.ID, taskToken3))
|
||||
assert.Equal(t, strings.Repeat("C", 32), downloadArtifactContentV4ByTask(t, run.ID, job3.ID, taskToken3, "job1-shared"))
|
||||
|
||||
// a shadowed artifact is not listed by id either: a download resolves by name
|
||||
assert.Empty(t, listArtifactsByIDV4(t, run.ID, job3.ID, inheritedSharedID, taskToken3))
|
||||
|
||||
// the shadowed v3 artifact is dropped as a whole, its b.txt row must not survive next to the new a.txt
|
||||
uploadTestArtifactFile(t, run.ID, taskToken3, "job1-v3", "a.txt", strings.Repeat("F", 32))
|
||||
shadowedV3 := getArtifactDownloadItemsForRun(t, run.ID, taskToken3, "job1-v3")
|
||||
require.Len(t, shadowedV3, 1)
|
||||
assert.Equal(t, strings.Repeat("F", 32), downloadArtifactItemContent(t, taskToken3, shadowedV3[0]))
|
||||
|
||||
runner.execTask(t, task3, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
||||
}
|
||||
|
||||
func getArtifactDownloadItemsForRun(t *testing.T, runID int64, taskToken, artifactName string) []downloadArtifactResponseItem {
|
||||
t.Helper()
|
||||
|
||||
req := NewRequest(t, "GET", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/artifacts/%x/download_url?itemPath=%s", runID, md5.Sum([]byte(artifactName)), artifactName)).
|
||||
AddTokenAuth(taskToken)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
return DecodeJSON(t, resp, &downloadArtifactResponse{}).Value
|
||||
}
|
||||
|
||||
func downloadArtifactItemContent(t *testing.T, taskToken string, item downloadArtifactResponseItem) string {
|
||||
t.Helper()
|
||||
|
||||
idx := strings.Index(item.ContentLocation, "/api/actions_pipeline/_apis/pipelines/")
|
||||
require.NotEqual(t, -1, idx)
|
||||
req := NewRequest(t, "GET", item.ContentLocation[idx:]).AddTokenAuth(taskToken)
|
||||
return MakeRequest(t, req, http.StatusOK).Body.String()
|
||||
}
|
||||
|
||||
func uploadTestArtifactFile(t *testing.T, runID int64, authToken, artifactName, fileName, content string) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -947,7 +947,24 @@ func testActionRunAttemptArtifactV4(t *testing.T, repo *repo_model.Repository, s
|
||||
assert.Equal(t, strings.Repeat("D", 32), downloadRepoArtifactV4Content(t, session, sharedArtifactsResp.Entries[1].ArchiveDownloadURL))
|
||||
}
|
||||
|
||||
func uploadTestArtifactFileV4(t *testing.T, runID, jobID int64, authToken, artifactName, content string) {
|
||||
func downloadArtifactContentV4ByTask(t *testing.T, runID, jobID int64, taskToken, artifactName string) string {
|
||||
t.Helper()
|
||||
|
||||
req := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/GetSignedArtifactURL", toProtoJSON(&actions.GetSignedArtifactURLRequest{
|
||||
Name: artifactName,
|
||||
WorkflowRunBackendId: strconv.FormatInt(runID, 10),
|
||||
WorkflowJobRunBackendId: strconv.FormatInt(jobID, 10),
|
||||
})).AddTokenAuth(taskToken)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
var urlResp actions.GetSignedArtifactURLResponse
|
||||
require.NoError(t, protojson.Unmarshal(resp.Body.Bytes(), &urlResp))
|
||||
require.NotEmpty(t, urlResp.SignedUrl)
|
||||
|
||||
return MakeRequest(t, NewRequest(t, "GET", urlResp.SignedUrl), http.StatusOK).Body.String()
|
||||
}
|
||||
|
||||
// createTestArtifactV4 only creates the artifact record, leaving it pending until it is uploaded and finalized
|
||||
func createTestArtifactV4(t *testing.T, runID, jobID int64, authToken, artifactName string) string {
|
||||
t.Helper()
|
||||
|
||||
req := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact", toProtoJSON(&actions.CreateArtifactRequest{
|
||||
@@ -958,11 +975,17 @@ func uploadTestArtifactFileV4(t *testing.T, runID, jobID int64, authToken, artif
|
||||
MimeType: wrapperspb.String("application/zip"),
|
||||
})).AddTokenAuth(authToken)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
var uploadResp actions.CreateArtifactResponse
|
||||
require.NoError(t, protojson.Unmarshal(resp.Body.Bytes(), &uploadResp))
|
||||
require.True(t, uploadResp.Ok)
|
||||
var createResp actions.CreateArtifactResponse
|
||||
require.NoError(t, protojson.Unmarshal(resp.Body.Bytes(), &createResp))
|
||||
require.True(t, createResp.Ok)
|
||||
return createResp.SignedUploadUrl
|
||||
}
|
||||
|
||||
req = NewRequestWithBody(t, "PUT", uploadResp.SignedUploadUrl+"&comp=appendBlock", strings.NewReader(content))
|
||||
func uploadTestArtifactFileV4(t *testing.T, runID, jobID int64, authToken, artifactName, content string) {
|
||||
t.Helper()
|
||||
|
||||
signedUploadURL := createTestArtifactV4(t, runID, jobID, authToken, artifactName)
|
||||
req := NewRequestWithBody(t, "PUT", signedUploadURL+"&comp=appendBlock", strings.NewReader(content))
|
||||
MakeRequest(t, req, http.StatusCreated)
|
||||
|
||||
sum := sha256.Sum256([]byte(content))
|
||||
@@ -973,30 +996,59 @@ func uploadTestArtifactFileV4(t *testing.T, runID, jobID int64, authToken, artif
|
||||
WorkflowRunBackendId: strconv.FormatInt(runID, 10),
|
||||
WorkflowJobRunBackendId: strconv.FormatInt(jobID, 10),
|
||||
})).AddTokenAuth(authToken)
|
||||
resp = MakeRequest(t, req, http.StatusOK)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
var finalizeResp actions.FinalizeArtifactResponse
|
||||
require.NoError(t, protojson.Unmarshal(resp.Body.Bytes(), &finalizeResp))
|
||||
require.True(t, finalizeResp.Ok)
|
||||
}
|
||||
|
||||
func listArtifactsForRunV4(t *testing.T, taskToken string, req *actions.ListArtifactsRequest) []*actions.ListArtifactsResponse_MonolithArtifact {
|
||||
t.Helper()
|
||||
|
||||
httpReq := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/ListArtifacts", toProtoJSON(req)).AddTokenAuth(taskToken)
|
||||
resp := MakeRequest(t, httpReq, http.StatusOK)
|
||||
var listResp actions.ListArtifactsResponse
|
||||
require.NoError(t, protojson.Unmarshal(resp.Body.Bytes(), &listResp))
|
||||
return listResp.Artifacts
|
||||
}
|
||||
|
||||
func listArtifactNamesForRunV4(t *testing.T, runID, jobID int64, taskToken string) []string {
|
||||
t.Helper()
|
||||
|
||||
req := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/ListArtifacts", toProtoJSON(&actions.ListArtifactsRequest{
|
||||
artifacts := listArtifactsForRunV4(t, taskToken, &actions.ListArtifactsRequest{
|
||||
WorkflowRunBackendId: strconv.FormatInt(runID, 10),
|
||||
WorkflowJobRunBackendId: strconv.FormatInt(jobID, 10),
|
||||
})).AddTokenAuth(taskToken)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
var listResp actions.ListArtifactsResponse
|
||||
require.NoError(t, protojson.Unmarshal(resp.Body.Bytes(), &listResp))
|
||||
})
|
||||
|
||||
names := make([]string, 0, len(listResp.Artifacts))
|
||||
for _, item := range listResp.Artifacts {
|
||||
names := make([]string, 0, len(artifacts))
|
||||
for _, item := range artifacts {
|
||||
names = append(names, item.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func listArtifactIDForRunV4(t *testing.T, runID, jobID int64, taskToken, artifactName string) int64 {
|
||||
t.Helper()
|
||||
|
||||
artifacts := listArtifactsForRunV4(t, taskToken, &actions.ListArtifactsRequest{
|
||||
NameFilter: wrapperspb.String(artifactName),
|
||||
WorkflowRunBackendId: strconv.FormatInt(runID, 10),
|
||||
WorkflowJobRunBackendId: strconv.FormatInt(jobID, 10),
|
||||
})
|
||||
require.Len(t, artifacts, 1)
|
||||
return artifacts[0].DatabaseId
|
||||
}
|
||||
|
||||
func listArtifactsByIDV4(t *testing.T, runID, jobID, artifactID int64, taskToken string) []*actions.ListArtifactsResponse_MonolithArtifact {
|
||||
t.Helper()
|
||||
|
||||
return listArtifactsForRunV4(t, taskToken, &actions.ListArtifactsRequest{
|
||||
IdFilter: wrapperspb.Int64(artifactID),
|
||||
WorkflowRunBackendId: strconv.FormatInt(runID, 10),
|
||||
WorkflowJobRunBackendId: strconv.FormatInt(jobID, 10),
|
||||
})
|
||||
}
|
||||
|
||||
func downloadRepoArtifactV4Content(t *testing.T, session *TestSession, archiveDownloadURL string) string {
|
||||
t.Helper()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user