mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-27 05:49:49 +09:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dac1bb2f8 | ||
|
|
38cf2a2cfb | ||
|
|
3604189b08 | ||
|
|
ba4db8a2d9 | ||
|
|
0acbcc58a7 | ||
|
|
88b56d408d | ||
|
|
1c92062c69 | ||
|
|
51938de973 | ||
|
|
21fda8f5be | ||
|
|
9ab9c18919 | ||
|
|
e2a0a87ae0 | ||
|
|
92044649a0 | ||
|
|
94011d2850 | ||
|
|
fe252be0ae | ||
|
|
cca0c65a6c | ||
|
|
6387c8ba6e | ||
|
|
6eab271921 | ||
|
|
eab225f095 | ||
|
|
00a637295e | ||
|
|
4e64b3a65d | ||
|
|
b71adfe1ad | ||
|
|
e5c6669751 | ||
|
|
e0e10052e0 | ||
|
|
c461575af3 | ||
|
|
7fb9602961 | ||
|
|
a8e80ebc23 | ||
|
|
8ab5d31cf3 | ||
|
|
b2af380d66 | ||
|
|
d2603a8b4a | ||
|
|
9eac9bd032 | ||
|
|
784d88814f |
@@ -4,6 +4,44 @@ This changelog goes through the changes that have been made in each release
|
|||||||
without substantial changes to our git log; to see the highlights of what has
|
without substantial changes to our git log; to see the highlights of what has
|
||||||
been added to each release, please refer to the [blog](https://blog.gitea.com).
|
been added to each release, please refer to the [blog](https://blog.gitea.com).
|
||||||
|
|
||||||
|
## [1.27.2](https://github.com/go-gitea/gitea/releases/tag/v1.27.2) - 2026-08-14
|
||||||
|
|
||||||
|
* SECURITY
|
||||||
|
* Fix: update collaborator access mode and httpsign (#38894, #38862) (#38895)
|
||||||
|
* Refactor: external render (#38885) (#38898)
|
||||||
|
* Fix(actions): resolve pull_request_target reusable workflows at the base commit (#38886) (#38897)
|
||||||
|
* Refactor: markup render (#38864) (#38869)
|
||||||
|
* Fix(deps): update dependency mermaid to v11.16.1 (#38816)
|
||||||
|
* Fix(auth): set WebAuthn user verification per request (#38805) (#38810)
|
||||||
|
* Fix: render highlight language (#38793) (#38795)
|
||||||
|
|
||||||
|
* ENHANCEMENTS
|
||||||
|
* enhance: add missing npm package metadata properties (#38826) (#38831)
|
||||||
|
|
||||||
|
* BUGFIXES
|
||||||
|
* fix(actions): keep github.event.inputs as strings for workflow_dispatch (#38899) (#38908)
|
||||||
|
* fix(actions): let a rerun of selected jobs read the previous attempt's artifacts (#38857) (#38901)
|
||||||
|
* fix(lfs): accept successful transfer responses (#38866) (#38875)
|
||||||
|
* fix(packages): ignore nested Package.swift (#38788) (#38836)
|
||||||
|
* fix: drop newline-bearing member names in arch ParsePackage (#38102) (#38830)
|
||||||
|
* fix(storage): fix Azure Blob dump failing with file does not exist (#38814) (#38828)
|
||||||
|
* fix(migration): migration deletion returned json redirection (#38796) (#38825)
|
||||||
|
* fix(ui): change underlines to default browser style (#38819) (#38823)
|
||||||
|
* fix(actions): allow cancelling runs without running jobs (#35842) (#38812)
|
||||||
|
* fix(actions): evaluate each `${{ }}` part on its own (#38754) (#38797)
|
||||||
|
* fix(actions): write an action task report in one transaction (#38792) (#38794)
|
||||||
|
* fix: markup link (#38764) (#38765)
|
||||||
|
* fix: set a minio part size when the content size is unknown (#38753) (#38755)
|
||||||
|
* fix: bad path escape in subpath archive download (#38749) (#38750)
|
||||||
|
* fix: remove the pull merge box from UI when the refreshed page doesn't contain it (#38742) (#38744)
|
||||||
|
* fix(markdown): fix double strikethough on code (#38707) (#38729)
|
||||||
|
* fix(lfs): failed upload deletes a concurrent upload's meta object (#38693) (#38722)
|
||||||
|
* fix: correct full url when using sub-path (#38712) (#38716)
|
||||||
|
* fix: avoid markup render panic (#38698) (#38703)
|
||||||
|
* fix(ui): too many participants shown in commit avatar stacks (#38689) (#38700)
|
||||||
|
* fix: support HEAD requests on Alpine registry APKINDEX.tar.gz (#38686) (#38688)
|
||||||
|
* fix(migrations): use all configured GitHub tokens (#38841) (#38846)
|
||||||
|
|
||||||
## [1.27.1](https://github.com/go-gitea/gitea/releases/tag/v1.27.1) - 2026-07-27
|
## [1.27.1](https://github.com/go-gitea/gitea/releases/tag/v1.27.1) - 2026-07-27
|
||||||
|
|
||||||
* SECURITY
|
* SECURITY
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ require (
|
|||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/quasoft/websspi v1.1.2
|
github.com/quasoft/websspi v1.1.2
|
||||||
github.com/redis/go-redis/v9 v9.21.0
|
github.com/redis/go-redis/v9 v9.21.0
|
||||||
|
github.com/rhysd/actionlint v1.7.12
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
|
||||||
github.com/sassoftware/go-rpmutils v0.4.0
|
github.com/sassoftware/go-rpmutils v0.4.0
|
||||||
@@ -244,7 +245,6 @@ require (
|
|||||||
github.com/prometheus/common v0.68.1 // indirect
|
github.com/prometheus/common v0.68.1 // indirect
|
||||||
github.com/prometheus/procfs v0.20.1 // indirect
|
github.com/prometheus/procfs v0.20.1 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rhysd/actionlint v1.7.12 // indirect
|
|
||||||
github.com/rs/xid v1.6.0 // indirect
|
github.com/rs/xid v1.6.0 // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
github.com/shopspring/decimal v1.4.0 // indirect
|
github.com/shopspring/decimal v1.4.0 // indirect
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ package actions
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
"gitea.dev/modules/optional"
|
|
||||||
"gitea.dev/modules/timeutil"
|
"gitea.dev/modules/timeutil"
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
|
|
||||||
@@ -147,7 +147,7 @@ type FindArtifactsOptions struct {
|
|||||||
db.ListOptions
|
db.ListOptions
|
||||||
RepoID int64
|
RepoID int64
|
||||||
RunID 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
|
ArtifactName string
|
||||||
Status int
|
Status int
|
||||||
FinalizedArtifactsV4 bool
|
FinalizedArtifactsV4 bool
|
||||||
@@ -167,8 +167,8 @@ func (opts FindArtifactsOptions) ToConds() builder.Cond {
|
|||||||
if opts.RunID > 0 {
|
if opts.RunID > 0 {
|
||||||
cond = cond.And(builder.Eq{"run_id": opts.RunID})
|
cond = cond.And(builder.Eq{"run_id": opts.RunID})
|
||||||
}
|
}
|
||||||
if opts.RunAttemptID.Has() {
|
if len(opts.RunAttemptIDs) > 0 {
|
||||||
cond = cond.And(builder.Eq{"run_attempt_id": opts.RunAttemptID.Value()})
|
cond = cond.And(builder.In("run_attempt_id", opts.RunAttemptIDs))
|
||||||
}
|
}
|
||||||
if opts.ArtifactName != "" {
|
if opts.ArtifactName != "" {
|
||||||
cond = cond.And(builder.Eq{"artifact_name": opts.ArtifactName})
|
cond = cond.And(builder.Eq{"artifact_name": opts.ArtifactName})
|
||||||
@@ -185,6 +185,27 @@ func (opts FindArtifactsOptions) ToConds() builder.Cond {
|
|||||||
return 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
|
// ActionArtifactMeta is the meta-data of an artifact
|
||||||
type ActionArtifactMeta struct {
|
type ActionArtifactMeta struct {
|
||||||
ArtifactName string
|
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"
|
"gitea.dev/models/db"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
|
"gitea.dev/modules/container"
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
"gitea.dev/modules/timeutil"
|
"gitea.dev/modules/timeutil"
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
@@ -96,6 +97,56 @@ func GetRunAttemptByRunIDAndAttemptNum(ctx context.Context, runID, attemptNum in
|
|||||||
return &attempt, nil
|
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.
|
// FindConcurrentRunAttempts returns attempts in the given concurrency group and status set.
|
||||||
// Results are unordered; callers must not depend on any particular row order.
|
// 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) {
|
func FindConcurrentRunAttempts(ctx context.Context, repoID int64, concurrencyGroup string, statuses []Status) ([]*ActionRunAttempt, error) {
|
||||||
|
|||||||
+75
-47
@@ -448,58 +448,74 @@ func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, col
|
|||||||
return affected, RefreshReusableCallerStatus(ctx, parent)
|
return affected, RefreshReusableCallerStatus(ctx, parent)
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
if err := refreshRunStatus(ctx, job.RepoID, job.RunID, job.RunAttemptID, StatusUnknown); err != nil {
|
||||||
// Other goroutines may aggregate the status of the attempt/run and update it too.
|
return 0, err
|
||||||
// So we need to load the current jobs before updating the aggregate state.
|
|
||||||
if job.RunAttemptID > 0 {
|
|
||||||
attempt, err := GetRunAttemptByRepoAndID(ctx, job.RepoID, job.RunAttemptID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
jobs, err := GetRunJobsByRunAndAttemptID(ctx, job.RunID, job.RunAttemptID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
attempt.Status = AggregateJobStatus(jobs)
|
|
||||||
if attempt.Started.IsZero() && attempt.Status.IsRunning() {
|
|
||||||
attempt.Started = timeutil.TimeStampNow()
|
|
||||||
}
|
|
||||||
if attempt.Stopped.IsZero() && attempt.Status.IsDone() {
|
|
||||||
attempt.Stopped = timeutil.TimeStampNow()
|
|
||||||
}
|
|
||||||
if err := UpdateRunAttempt(ctx, attempt, "status", "started", "stopped"); err != nil {
|
|
||||||
return 0, fmt.Errorf("update run attempt %d: %w", attempt.ID, err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// TODO: Remove this fallback in the future.
|
|
||||||
// Legacy fallback: jobs created before migration v331 have RunAttemptID=0 and are NOT backfilled.
|
|
||||||
// This path keeps those runs' status consistent when their jobs finish, including:
|
|
||||||
// - jobs created before migration v331 and complete on the new version starts
|
|
||||||
// - zombie/abandoned cleanup cron tasks that call UpdateRunJob on legacy jobs
|
|
||||||
run, err := GetRunByRepoAndID(ctx, job.RepoID, job.RunID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
jobs, err := GetLatestAttemptJobsByRepoAndRunID(ctx, job.RepoID, job.RunID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
run.Status = AggregateJobStatus(jobs)
|
|
||||||
if run.Started.IsZero() && run.Status.IsRunning() {
|
|
||||||
run.Started = timeutil.TimeStampNow()
|
|
||||||
}
|
|
||||||
if run.Stopped.IsZero() && run.Status.IsDone() {
|
|
||||||
run.Stopped = timeutil.TimeStampNow()
|
|
||||||
}
|
|
||||||
if err := UpdateRun(ctx, run, "status", "started", "stopped"); err != nil {
|
|
||||||
return 0, fmt.Errorf("update run %d: %w", run.ID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return affected, nil
|
return affected, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refreshRunStatus recomputes the status of an attempt from the jobs currently stored and persists it.
|
||||||
|
// The latest attempt propagates its status to its run, an older one only updates itself.
|
||||||
|
// noJobsStatus settles an attempt without any job, which AggregateJobStatus cannot conclude on its own.
|
||||||
|
func refreshRunStatus(ctx context.Context, repoID, runID, runAttemptID int64, noJobsStatus Status) error {
|
||||||
|
// Other goroutines may aggregate the status of the attempt/run and update it too.
|
||||||
|
// So we need to load the current jobs before updating the aggregate state.
|
||||||
|
if runAttemptID > 0 {
|
||||||
|
attempt, err := GetRunAttemptByRepoAndID(ctx, repoID, runAttemptID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
jobs, err := GetRunJobsByRunAndAttemptID(ctx, runID, runAttemptID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
attempt.Status = AggregateJobStatus(jobs)
|
||||||
|
if len(jobs) == 0 {
|
||||||
|
attempt.Status = noJobsStatus
|
||||||
|
}
|
||||||
|
if attempt.Started.IsZero() && attempt.Status.IsRunning() {
|
||||||
|
attempt.Started = timeutil.TimeStampNow()
|
||||||
|
}
|
||||||
|
if attempt.Stopped.IsZero() && attempt.Status.IsDone() {
|
||||||
|
attempt.Stopped = timeutil.TimeStampNow()
|
||||||
|
}
|
||||||
|
if err := UpdateRunAttempt(ctx, attempt, "status", "started", "stopped"); err != nil {
|
||||||
|
return fmt.Errorf("update run attempt %d: %w", attempt.ID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Remove this fallback in the future.
|
||||||
|
// Legacy fallback: jobs created before migration v331 have RunAttemptID=0 and are NOT backfilled.
|
||||||
|
// This path keeps those runs' status consistent when their jobs finish, including:
|
||||||
|
// - jobs created before migration v331 and complete on the new version starts
|
||||||
|
// - zombie/abandoned cleanup cron tasks that call UpdateRunJob on legacy jobs
|
||||||
|
// - cancelling a legacy run whose jobs are all already done
|
||||||
|
run, err := GetRunByRepoAndID(ctx, repoID, runID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
jobs, err := GetLatestAttemptJobsByRepoAndRunID(ctx, repoID, runID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
run.Status = AggregateJobStatus(jobs)
|
||||||
|
if len(jobs) == 0 {
|
||||||
|
run.Status = noJobsStatus
|
||||||
|
}
|
||||||
|
if run.Started.IsZero() && run.Status.IsRunning() {
|
||||||
|
run.Started = timeutil.TimeStampNow()
|
||||||
|
}
|
||||||
|
if run.Stopped.IsZero() && run.Status.IsDone() {
|
||||||
|
run.Stopped = timeutil.TimeStampNow()
|
||||||
|
}
|
||||||
|
if err := UpdateRun(ctx, run, "status", "started", "stopped"); err != nil {
|
||||||
|
return fmt.Errorf("update run %d: %w", run.ID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// RefreshReusableCallerStatus recomputes a reusable workflow caller's Status, Started and Stopped from its current direct children and persists the change.
|
// RefreshReusableCallerStatus recomputes a reusable workflow caller's Status, Started and Stopped from its current direct children and persists the change.
|
||||||
// No-op if caller is not a reusable caller.
|
// No-op if caller is not a reusable caller.
|
||||||
//
|
//
|
||||||
@@ -660,6 +676,8 @@ func CancelPreviousJobsByJobConcurrency(ctx context.Context, job *ActionRunJob)
|
|||||||
return CancelJobs(ctx, jobsToCancel)
|
return CancelJobs(ctx, jobsToCancel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CancelJobs cancels every cancellable job it is given. It leaves the status of a run it
|
||||||
|
// cancelled nothing in untouched, SettleRunAfterCancel is what gives such a run a final one.
|
||||||
func CancelJobs(ctx context.Context, jobs []*ActionRunJob) ([]*ActionRunJob, error) {
|
func CancelJobs(ctx context.Context, jobs []*ActionRunJob) ([]*ActionRunJob, error) {
|
||||||
cancelledJobs := make([]*ActionRunJob, 0, len(jobs))
|
cancelledJobs := make([]*ActionRunJob, 0, len(jobs))
|
||||||
|
|
||||||
@@ -684,6 +702,16 @@ func CancelJobs(ctx context.Context, jobs []*ActionRunJob) ([]*ActionRunJob, err
|
|||||||
return cancelledJobs, nil
|
return cancelledJobs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SettleRunAfterCancel gives a run a final status when cancelling it updated no job at all.
|
||||||
|
// A run's status is otherwise only ever written as a side effect of a job update, so a run whose
|
||||||
|
// jobs are all done already, or that has no job at all, would stay unfinished forever.
|
||||||
|
func SettleRunAfterCancel(ctx context.Context, run *ActionRun) error {
|
||||||
|
if run.Status.IsDone() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return refreshRunStatus(ctx, run.RepoID, run.ID, run.LatestAttemptID, StatusCancelled)
|
||||||
|
}
|
||||||
|
|
||||||
// cancelOneJob cancels a single job and returns the post-cancel row
|
// cancelOneJob cancels a single job and returns the post-cancel row
|
||||||
func cancelOneJob(ctx context.Context, job *ActionRunJob) (*ActionRunJob, error) {
|
func cancelOneJob(ctx context.Context, job *ActionRunJob) (*ActionRunJob, error) {
|
||||||
if job.Status.IsDone() {
|
if job.Status.IsDone() {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
"gitea.dev/models/unittest"
|
"gitea.dev/models/unittest"
|
||||||
|
"gitea.dev/modules/timeutil"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -197,3 +198,91 @@ func TestCancelJobs_NestedBlockedReusableCaller(t *testing.T) {
|
|||||||
gotRun := unittest.AssertExistsAndLoadBean(t, &ActionRun{ID: run.ID})
|
gotRun := unittest.AssertExistsAndLoadBean(t, &ActionRun{ID: run.ID})
|
||||||
assert.Equal(t, StatusCancelled, gotRun.Status, "run must aggregate to Cancelled, not stay Blocked")
|
assert.Equal(t, StatusCancelled, gotRun.Status, "run must aggregate to Cancelled, not stay Blocked")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSettleRunAfterCancel(t *testing.T) {
|
||||||
|
// A run that cancelling updates no job in, because its jobs all reached a final status already
|
||||||
|
// or because it has none at all. Its own row has to be settled explicitly, or the run can never
|
||||||
|
// finish and can never be deleted either.
|
||||||
|
|
||||||
|
newStuckRun := func(t *testing.T, withAttempt, withJob bool) (*ActionRun, []*ActionRunJob) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
run := &ActionRun{
|
||||||
|
Title: "stuck-waiting",
|
||||||
|
RepoID: 4,
|
||||||
|
Index: 9801,
|
||||||
|
OwnerID: 1,
|
||||||
|
WorkflowID: "test.yaml",
|
||||||
|
TriggerUserID: 1,
|
||||||
|
Ref: "refs/heads/master",
|
||||||
|
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||||
|
Event: "push",
|
||||||
|
TriggerEvent: "push",
|
||||||
|
EventPayload: "{}",
|
||||||
|
Status: StatusWaiting,
|
||||||
|
}
|
||||||
|
require.NoError(t, db.Insert(ctx, run))
|
||||||
|
|
||||||
|
var runAttemptID int64
|
||||||
|
if withAttempt {
|
||||||
|
attempt := &ActionRunAttempt{RepoID: run.RepoID, RunID: run.ID, Attempt: 1, TriggerUserID: 1, Status: StatusWaiting}
|
||||||
|
require.NoError(t, db.Insert(ctx, attempt))
|
||||||
|
run.LatestAttemptID = attempt.ID
|
||||||
|
require.NoError(t, UpdateRun(ctx, run, "latest_attempt_id"))
|
||||||
|
runAttemptID = attempt.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
if !withJob {
|
||||||
|
return run, nil
|
||||||
|
}
|
||||||
|
job := &ActionRunJob{
|
||||||
|
RunID: run.ID,
|
||||||
|
RunAttemptID: runAttemptID,
|
||||||
|
RepoID: run.RepoID,
|
||||||
|
OwnerID: run.OwnerID,
|
||||||
|
CommitSHA: run.CommitSHA,
|
||||||
|
Name: "job1",
|
||||||
|
JobID: "job1",
|
||||||
|
Attempt: 1,
|
||||||
|
Status: StatusSuccess,
|
||||||
|
Stopped: timeutil.TimeStampNow(),
|
||||||
|
}
|
||||||
|
require.NoError(t, db.Insert(ctx, job))
|
||||||
|
return run, []*ActionRunJob{job}
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
withAttempt bool
|
||||||
|
withJob bool
|
||||||
|
want Status
|
||||||
|
}{
|
||||||
|
{"done job", true, true, StatusSuccess},
|
||||||
|
// Runs created before migration v331 have no attempt, their status lives on the run row itself.
|
||||||
|
{"done job on a legacy run without attempt", false, true, StatusSuccess},
|
||||||
|
// Aggregation cannot reach a final status without any job, so cancelling has to end the run itself.
|
||||||
|
{"no job at all", true, false, StatusCancelled},
|
||||||
|
{"no job at all on a legacy run without attempt", false, false, StatusCancelled},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
run, jobs := newStuckRun(t, tc.withAttempt, tc.withJob)
|
||||||
|
|
||||||
|
// mirrors what the CancelRun service does
|
||||||
|
cancelled, err := CancelJobs(t.Context(), jobs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, cancelled, "nothing is cancellable, so the run row has to be settled explicitly")
|
||||||
|
require.NoError(t, SettleRunAfterCancel(t.Context(), run))
|
||||||
|
|
||||||
|
if tc.withAttempt {
|
||||||
|
gotAttempt := unittest.AssertExistsAndLoadBean(t, &ActionRunAttempt{ID: run.LatestAttemptID})
|
||||||
|
assert.Equal(t, tc.want, gotAttempt.Status)
|
||||||
|
}
|
||||||
|
gotRun := unittest.AssertExistsAndLoadBean(t, &ActionRun{ID: run.ID})
|
||||||
|
assert.Equal(t, tc.want, gotRun.Status)
|
||||||
|
assert.NotZero(t, gotRun.Stopped)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -478,7 +478,7 @@ func UpdateTaskByState(ctx context.Context, runnerID int64, state *runnerv1.Task
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
task := &ActionTask{}
|
task := &ActionTask{}
|
||||||
err = globallock.LockAndDo(ctx, fmt.Sprintf("UpdateTaskByState-run-%d", runID), func(ctx context.Context) error {
|
applyState := func(ctx context.Context) error {
|
||||||
if has, err := db.GetEngine(ctx).ID(taskID).Get(task); err != nil {
|
if has, err := db.GetEngine(ctx).ID(taskID).Get(task); err != nil {
|
||||||
return err
|
return err
|
||||||
} else if !has {
|
} else if !has {
|
||||||
@@ -543,6 +543,10 @@ func UpdateTaskByState(ctx context.Context, runnerID int64, state *runnerv1.Task
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
}
|
||||||
|
err = globallock.LockAndDo(ctx, fmt.Sprintf("UpdateTaskByState-run-%d", runID), func(ctx context.Context) error {
|
||||||
|
// A half-written report leaves the task done with a running job, which no retry repairs.
|
||||||
|
return db.WithTx(ctx, applyState)
|
||||||
})
|
})
|
||||||
return task, err
|
return task, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@
|
|||||||
package actions
|
package actions
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||||
@@ -17,6 +20,7 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
"xorm.io/xorm/contexts"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestActionTask_GetRunJobLink(t *testing.T) {
|
func TestActionTask_GetRunJobLink(t *testing.T) {
|
||||||
@@ -357,6 +361,35 @@ func TestCreateTaskForRunnerPagination(t *testing.T) {
|
|||||||
assert.Equal(t, task.ID, claimed.TaskID)
|
assert.Equal(t, task.ID, claimed.TaskID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type failFirstStepWrite struct{ fired atomic.Bool }
|
||||||
|
|
||||||
|
func (h *failFirstStepWrite) BeforeProcess(c *contexts.ContextHook) (context.Context, error) {
|
||||||
|
if !h.fired.Load() && strings.HasPrefix(c.SQL, "UPDATE") && strings.Contains(c.SQL, "action_task_step") {
|
||||||
|
h.fired.Store(true)
|
||||||
|
return nil, errors.New("interrupted")
|
||||||
|
}
|
||||||
|
return c.Ctx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*failFirstStepWrite) AfterProcess(*contexts.ContextHook) error { return nil }
|
||||||
|
|
||||||
|
// TestUpdateTaskByStateIsAtomic checks that an interrupted report writes nothing: a surviving task or
|
||||||
|
// job write would hit the "state is final" early return, which no retry or cleanup repairs.
|
||||||
|
func TestUpdateTaskByStateIsAtomic(t *testing.T) {
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
task, job := newRunningTaskForCancelling(t, "atomic-report-job", true)
|
||||||
|
require.NoError(t, db.Insert(t.Context(), &ActionTaskStep{TaskID: task.ID, RepoID: task.RepoID, Status: StatusRunning}))
|
||||||
|
unittest.GetXORMEngine().AddHook(&failFirstStepWrite{})
|
||||||
|
finalState := &runnerv1.TaskState{Id: task.ID, Result: runnerv1.Result_RESULT_SUCCESS, StoppedAt: timestamppb.Now()}
|
||||||
|
_, err := UpdateTaskByState(t.Context(), task.RunnerID, finalState)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Equal(t, StatusRunning, unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID}).Status)
|
||||||
|
assert.Equal(t, StatusRunning, unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID}).Status)
|
||||||
|
_, err = UpdateTaskByState(t.Context(), task.RunnerID, finalState)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, StatusSuccess, unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID}).Status)
|
||||||
|
}
|
||||||
|
|
||||||
// newRunningTaskForCancelling inserts a running run/job/task assigned to a fresh runner,
|
// newRunningTaskForCancelling inserts a running run/job/task assigned to a fresh runner,
|
||||||
// which is the state every cancellation test starts from.
|
// which is the state every cancellation test starts from.
|
||||||
func newRunningTaskForCancelling(t *testing.T, name string, hasCancellingSupport bool) (*ActionTask, *ActionRunJob) {
|
func newRunningTaskForCancelling(t *testing.T, name string, hasCancellingSupport bool) (*ActionTask, *ActionRunJob) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
"gitea.dev/models/user"
|
"gitea.dev/models/user"
|
||||||
|
"gitea.dev/modules/container"
|
||||||
"gitea.dev/modules/git"
|
"gitea.dev/modules/git"
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
)
|
)
|
||||||
@@ -33,11 +34,18 @@ func BuildAvatarStackData(ctx context.Context, allParticipants []*git.CommitIden
|
|||||||
ret := &AvatarStackData{
|
ret := &AvatarStackData{
|
||||||
Participants: make([]*CommitParticipant, 0, len(allParticipants)),
|
Participants: make([]*CommitParticipant, 0, len(allParticipants)),
|
||||||
}
|
}
|
||||||
|
uniqueUserIDs := make(container.Set[int64])
|
||||||
for _, p := range allParticipants {
|
for _, p := range allParticipants {
|
||||||
var giteaUser *user.User
|
var giteaUser *user.User
|
||||||
if emailUserMap != nil {
|
if emailUserMap != nil {
|
||||||
giteaUser = emailUserMap.GetByEmail(p.Email)
|
giteaUser = emailUserMap.GetByEmail(p.Email)
|
||||||
}
|
}
|
||||||
|
if giteaUser != nil {
|
||||||
|
// identities without a Gitea account can only be compared by their git identity
|
||||||
|
if !uniqueUserIDs.Add(giteaUser.ID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
ret.Participants = append(ret.Participants, &CommitParticipant{GiteaUser: giteaUser, GitIdentity: p})
|
ret.Participants = append(ret.Participants, &CommitParticipant{GiteaUser: giteaUser, GitIdentity: p})
|
||||||
}
|
}
|
||||||
return ret
|
return ret
|
||||||
|
|||||||
@@ -39,8 +39,7 @@ func GetUserCommitsByGitCommits(ctx context.Context, gitCommits []*git.Commit, r
|
|||||||
emailSet := make(container.Set[string])
|
emailSet := make(container.Set[string])
|
||||||
for _, c := range gitCommits {
|
for _, c := range gitCommits {
|
||||||
emailSet.Add(c.Author.Email)
|
emailSet.Add(c.Author.Email)
|
||||||
emailSet.Add(c.Committer.Email)
|
for _, p := range c.AllAuthorIdentities() {
|
||||||
for _, p := range c.AllParticipantIdentities() {
|
|
||||||
emailSet.Add(p.Email)
|
emailSet.Add(p.Email)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,7 +54,7 @@ func GetUserCommitsByGitCommits(ctx context.Context, gitCommits []*git.Commit, r
|
|||||||
uc := &UserCommit{
|
uc := &UserCommit{
|
||||||
AuthorUser: emailUserMap.GetByEmail(c.Author.Email), // FIXME: why GetUserCommitsByGitCommits uses "Author", but ParseCommitsWithSignature uses "Committer"?
|
AuthorUser: emailUserMap.GetByEmail(c.Author.Email), // FIXME: why GetUserCommitsByGitCommits uses "Author", but ParseCommitsWithSignature uses "Committer"?
|
||||||
GitCommit: c,
|
GitCommit: c,
|
||||||
AvatarStackData: BuildAvatarStackData(ctx, c.AllParticipantIdentities(), emailUserMap),
|
AvatarStackData: BuildAvatarStackData(ctx, c.AllAuthorIdentities(), emailUserMap),
|
||||||
}
|
}
|
||||||
uc.AvatarStackData.SearchByEmailLink = searchByEmailLink
|
uc.AvatarStackData.SearchByEmailLink = searchByEmailLink
|
||||||
userCommits = append(userCommits, uc)
|
userCommits = append(userCommits, uc)
|
||||||
|
|||||||
@@ -7,41 +7,45 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
|
repo_model "gitea.dev/models/repo"
|
||||||
"gitea.dev/modules/git"
|
"gitea.dev/modules/git"
|
||||||
"gitea.dev/modules/gitrepo"
|
"gitea.dev/modules/gitrepo"
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
type commitChecker struct {
|
type commitChecker struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
commitCache map[string]bool
|
commitCache map[string]bool
|
||||||
gitRepoFacade gitrepo.Repository
|
repoOptional *repo_model.Repository
|
||||||
|
|
||||||
gitRepo *git.Repository
|
gitRepo *git.Repository
|
||||||
gitRepoCloser io.Closer
|
gitRepoCloser io.Closer
|
||||||
}
|
}
|
||||||
|
|
||||||
func newCommitChecker(ctx context.Context, gitRepo gitrepo.Repository) *commitChecker {
|
func newCommitChecker(ctx context.Context, repo *repo_model.Repository) *commitChecker {
|
||||||
return &commitChecker{ctx: ctx, commitCache: make(map[string]bool), gitRepoFacade: gitRepo}
|
return &commitChecker{ctx: ctx, commitCache: make(map[string]bool), repoOptional: repo}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *commitChecker) Close() error {
|
func (c *commitChecker) Close() error {
|
||||||
if c != nil && c.gitRepoCloser != nil {
|
if c.gitRepoCloser != nil {
|
||||||
return c.gitRepoCloser.Close()
|
return c.gitRepoCloser.Close()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *commitChecker) IsCommitIDExisting(commitID string) bool {
|
func (c *commitChecker) IsCommitIDExisting(commitID string) bool {
|
||||||
|
if c.repoOptional == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
exist, inCache := c.commitCache[commitID]
|
exist, inCache := c.commitCache[commitID]
|
||||||
if inCache {
|
if inCache {
|
||||||
return exist
|
return exist
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.gitRepo == nil {
|
if c.gitRepo == nil {
|
||||||
r, closer, err := gitrepo.RepositoryFromContextOrOpen(c.ctx, c.gitRepoFacade)
|
r, closer, err := gitrepo.RepositoryFromContextOrOpen(c.ctx, c.repoOptional)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("unable to open repository: %s Error: %v", gitrepo.RepoGitURL(c.gitRepoFacade), err)
|
log.Error("unable to open repository: %s Error: %v", gitrepo.RepoGitURL(c.repoOptional), err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
c.gitRepo, c.gitRepoCloser = r, closer
|
c.gitRepo, c.gitRepoCloser = r, closer
|
||||||
|
|||||||
@@ -51,10 +51,10 @@ func NewRenderContextRepoComment(ctx context.Context, repo *repo_model.Repositor
|
|||||||
helper := &RepoComment{opts: util.OptionalArg(opts)}
|
helper := &RepoComment{opts: util.OptionalArg(opts)}
|
||||||
rctx := markup.NewRenderContext(ctx)
|
rctx := markup.NewRenderContext(ctx)
|
||||||
helper.ctx = rctx
|
helper.ctx = rctx
|
||||||
|
helper.commitChecker = newCommitChecker(ctx, repo)
|
||||||
var metas map[string]string
|
var metas map[string]string
|
||||||
if repo != nil {
|
if repo != nil {
|
||||||
helper.repoLink = repo.Link()
|
helper.repoLink = repo.Link()
|
||||||
helper.commitChecker = newCommitChecker(ctx, repo)
|
|
||||||
metas = repo.ComposeCommentMetas(ctx)
|
metas = repo.ComposeCommentMetas(ctx)
|
||||||
} else {
|
} else {
|
||||||
// repo can be nil when rendering a commit message in user's dashboard feedback whose repository has been deleted
|
// repo can be nil when rendering a commit message in user's dashboard feedback whose repository has been deleted
|
||||||
|
|||||||
@@ -35,11 +35,11 @@ func (r *RepoFile) ResolveLink(link, preferLinkType string) (finalLink string) {
|
|||||||
case markup.LinkTypeRoot:
|
case markup.LinkTypeRoot:
|
||||||
finalLink = r.ctx.ResolveLinkRoot(link)
|
finalLink = r.ctx.ResolveLinkRoot(link)
|
||||||
case markup.LinkTypeRaw:
|
case markup.LinkTypeRaw:
|
||||||
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "raw", r.opts.CurrentRefSubURL), r.opts.CurrentTreePath, link)
|
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "raw", r.opts.CurrentRefSubURL), util.PathEscapeSegments(r.opts.CurrentTreePath), link)
|
||||||
case markup.LinkTypeMedia:
|
case markup.LinkTypeMedia:
|
||||||
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "media", r.opts.CurrentRefSubURL), r.opts.CurrentTreePath, link)
|
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "media", r.opts.CurrentRefSubURL), util.PathEscapeSegments(r.opts.CurrentTreePath), link)
|
||||||
default:
|
default:
|
||||||
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "src", r.opts.CurrentRefSubURL), r.opts.CurrentTreePath, link)
|
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "src", r.opts.CurrentRefSubURL), util.PathEscapeSegments(r.opts.CurrentTreePath), link)
|
||||||
}
|
}
|
||||||
return finalLink
|
return finalLink
|
||||||
}
|
}
|
||||||
@@ -58,9 +58,9 @@ func NewRenderContextRepoFile(ctx context.Context, repo *repo_model.Repository,
|
|||||||
helper := &RepoFile{opts: util.OptionalArg(opts)}
|
helper := &RepoFile{opts: util.OptionalArg(opts)}
|
||||||
rctx := markup.NewRenderContext(ctx)
|
rctx := markup.NewRenderContext(ctx)
|
||||||
helper.ctx = rctx
|
helper.ctx = rctx
|
||||||
|
helper.commitChecker = newCommitChecker(ctx, repo)
|
||||||
if repo != nil {
|
if repo != nil {
|
||||||
helper.repoLink = repo.Link()
|
helper.repoLink = repo.Link()
|
||||||
helper.commitChecker = newCommitChecker(ctx, repo)
|
|
||||||
rctx = rctx.WithMetas(repo.ComposeRepoFileMetas(ctx))
|
rctx = rctx.WithMetas(repo.ComposeRepoFileMetas(ctx))
|
||||||
} else {
|
} else {
|
||||||
// this is almost dead code, only to pass the incorrect tests
|
// this is almost dead code, only to pass the incorrect tests
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ func TestRepoFile(t *testing.T) {
|
|||||||
t.Run("WithCurrentRefSubURLByTag", func(t *testing.T) {
|
t.Run("WithCurrentRefSubURLByTag", func(t *testing.T) {
|
||||||
rctx := NewRenderContextRepoFile(t.Context(), repo1, RepoFileOptions{
|
rctx := NewRenderContextRepoFile(t.Context(), repo1, RepoFileOptions{
|
||||||
CurrentRefSubURL: "/commit/1234",
|
CurrentRefSubURL: "/commit/1234",
|
||||||
CurrentTreePath: "my-dir",
|
CurrentTreePath: "my dir",
|
||||||
}).
|
}).
|
||||||
WithMarkupType(markdown.MarkupName)
|
WithMarkupType(markdown.MarkupName)
|
||||||
rendered, err := testRenderString(rctx, `
|
rendered, err := testRenderString(rctx, `
|
||||||
@@ -76,8 +76,8 @@ func TestRepoFile(t *testing.T) {
|
|||||||
<video src="LINK">
|
<video src="LINK">
|
||||||
`)
|
`)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, `<a href="/user2/repo1/src/commit/1234/my-dir/LINK" target="_blank" rel="nofollow noopener"><img src="/user2/repo1/media/commit/1234/my-dir/LINK"/></a>
|
assert.Equal(t, `<a href="/user2/repo1/src/commit/1234/my%20dir/LINK" target="_blank" rel="nofollow noopener"><img src="/user2/repo1/media/commit/1234/my%20dir/LINK"/></a>
|
||||||
<video src="/user2/repo1/media/commit/1234/my-dir/LINK">
|
<video src="/user2/repo1/media/commit/1234/my%20dir/LINK">
|
||||||
</video>`, rendered)
|
</video>`, rendered)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -89,8 +89,8 @@ func TestRepoFileOrgMode(t *testing.T) {
|
|||||||
t.Run("Links", func(t *testing.T) {
|
t.Run("Links", func(t *testing.T) {
|
||||||
rctx := NewRenderContextRepoFile(t.Context(), repo1, RepoFileOptions{
|
rctx := NewRenderContextRepoFile(t.Context(), repo1, RepoFileOptions{
|
||||||
CurrentRefSubURL: "/commit/1234",
|
CurrentRefSubURL: "/commit/1234",
|
||||||
CurrentTreePath: "my-dir",
|
CurrentTreePath: "my dir",
|
||||||
}).WithRelativePath("my-dir/a.org")
|
}).WithRelativePath("my dir/a.org")
|
||||||
|
|
||||||
rendered, err := testRenderString(rctx, `
|
rendered, err := testRenderString(rctx, `
|
||||||
[[https://google.com/]]
|
[[https://google.com/]]
|
||||||
@@ -99,7 +99,7 @@ func TestRepoFileOrgMode(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, `<p>
|
assert.Equal(t, `<p>
|
||||||
<a href="https://google.com/" rel="nofollow">https://google.com/</a>
|
<a href="https://google.com/" rel="nofollow">https://google.com/</a>
|
||||||
<a href="/user2/repo1/src/commit/1234/my-dir/ImageLink.svg" rel="nofollow">The Image Desc</a></p>
|
<a href="/user2/repo1/src/commit/1234/my%20dir/ImageLink.svg" rel="nofollow">The Image Desc</a></p>
|
||||||
`, rendered)
|
`, rendered)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ func (r *RepoWiki) ResolveLink(link, preferLinkType string) (finalLink string) {
|
|||||||
case markup.LinkTypeRoot:
|
case markup.LinkTypeRoot:
|
||||||
finalLink = r.ctx.ResolveLinkRoot(link)
|
finalLink = r.ctx.ResolveLinkRoot(link)
|
||||||
case markup.LinkTypeMedia, markup.LinkTypeRaw:
|
case markup.LinkTypeMedia, markup.LinkTypeRaw:
|
||||||
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "wiki/raw", r.opts.currentRefSubURL), r.opts.currentTreePath, link)
|
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "wiki/raw", r.opts.currentRefSubURL), util.PathEscapeSegments(r.opts.currentTreePath), link)
|
||||||
default:
|
default:
|
||||||
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "wiki", r.opts.currentRefSubURL), r.opts.currentTreePath, link)
|
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "wiki", r.opts.currentRefSubURL), util.PathEscapeSegments(r.opts.currentTreePath), link)
|
||||||
}
|
}
|
||||||
return finalLink
|
return finalLink
|
||||||
}
|
}
|
||||||
@@ -57,9 +57,9 @@ type RepoWikiOptions struct {
|
|||||||
func NewRenderContextRepoWiki(ctx context.Context, repo *repo_model.Repository, opts ...RepoWikiOptions) *markup.RenderContext {
|
func NewRenderContextRepoWiki(ctx context.Context, repo *repo_model.Repository, opts ...RepoWikiOptions) *markup.RenderContext {
|
||||||
helper := &RepoWiki{opts: util.OptionalArg(opts)}
|
helper := &RepoWiki{opts: util.OptionalArg(opts)}
|
||||||
rctx := markup.NewRenderContext(ctx).WithMarkupType(markdown.MarkupName)
|
rctx := markup.NewRenderContext(ctx).WithMarkupType(markdown.MarkupName)
|
||||||
|
helper.commitChecker = newCommitChecker(ctx, repo)
|
||||||
if repo != nil {
|
if repo != nil {
|
||||||
helper.repoLink = repo.Link()
|
helper.repoLink = repo.Link()
|
||||||
helper.commitChecker = newCommitChecker(ctx, repo)
|
|
||||||
rctx = rctx.WithMetas(repo.ComposeWikiMetas(ctx))
|
rctx = rctx.WithMetas(repo.ComposeWikiMetas(ctx))
|
||||||
} else {
|
} else {
|
||||||
// this is almost dead code, only to pass the incorrect tests
|
// this is almost dead code, only to pass the incorrect tests
|
||||||
|
|||||||
@@ -50,14 +50,16 @@ func TestRepoWiki(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("PathInTag", func(t *testing.T) {
|
t.Run("PathInTag", func(t *testing.T) {
|
||||||
rctx := NewRenderContextRepoWiki(t.Context(), repo1).WithMarkupType(markdown.MarkupName)
|
rctx := NewRenderContextRepoWiki(t.Context(), repo1, RepoWikiOptions{
|
||||||
|
currentTreePath: "my dir",
|
||||||
|
}).WithMarkupType(markdown.MarkupName)
|
||||||
rendered, err := testRenderString(rctx, `
|
rendered, err := testRenderString(rctx, `
|
||||||
<img src="LINK">
|
<img src="LINK">
|
||||||
<video src="LINK">
|
<video src="LINK">
|
||||||
`)
|
`)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, `<a href="/user2/repo1/wiki/LINK" target="_blank" rel="nofollow noopener"><img src="/user2/repo1/wiki/raw/LINK"/></a>
|
assert.Equal(t, `<a href="/user2/repo1/wiki/my%20dir/LINK" target="_blank" rel="nofollow noopener"><img src="/user2/repo1/wiki/raw/my%20dir/LINK"/></a>
|
||||||
<video src="/user2/repo1/wiki/raw/LINK">
|
<video src="/user2/repo1/wiki/raw/my%20dir/LINK">
|
||||||
</video>`, rendered)
|
</video>`, rendered)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,39 +111,6 @@ func IsCollaborator(ctx context.Context, repoID, userID int64) (bool, error) {
|
|||||||
return db.Exist[Collaboration](ctx, builder.Eq{"repo_id": repoID, "user_id": userID})
|
return db.Exist[Collaboration](ctx, builder.Eq{"repo_id": repoID, "user_id": userID})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChangeCollaborationAccessMode sets new access mode for the collaboration.
|
|
||||||
func ChangeCollaborationAccessMode(ctx context.Context, repo *Repository, uid int64, mode perm.AccessMode) error {
|
|
||||||
// Discard invalid input
|
|
||||||
if mode <= perm.AccessModeNone || mode > perm.AccessModeOwner {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
|
||||||
collaboration, has, err := db.Get[Collaboration](ctx, builder.Eq{"repo_id": repo.ID, "user_id": uid})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("get collaboration: %w", err)
|
|
||||||
} else if !has {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if collaboration.Mode == mode {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
collaboration.Mode = mode
|
|
||||||
|
|
||||||
if _, err = db.GetEngine(ctx).
|
|
||||||
ID(collaboration.ID).
|
|
||||||
Cols("mode").
|
|
||||||
Update(collaboration); err != nil {
|
|
||||||
return fmt.Errorf("update collaboration: %w", err)
|
|
||||||
} else if _, err = db.Exec(ctx, "UPDATE access SET mode = ? WHERE user_id = ? AND repo_id = ?", mode, uid, repo.ID); err != nil {
|
|
||||||
return fmt.Errorf("update access table: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsOwnerMemberCollaborator checks if a provided user is the owner, a collaborator or a member of a team in a repository
|
// IsOwnerMemberCollaborator checks if a provided user is the owner, a collaborator or a member of a team in a repository
|
||||||
func IsOwnerMemberCollaborator(ctx context.Context, repo *Repository, userID int64) (bool, error) {
|
func IsOwnerMemberCollaborator(ctx context.Context, repo *Repository, userID int64) (bool, error) {
|
||||||
if repo.OwnerID == userID {
|
if repo.OwnerID == userID {
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
"gitea.dev/models/perm"
|
|
||||||
access_model "gitea.dev/models/perm/access"
|
|
||||||
repo_model "gitea.dev/models/repo"
|
repo_model "gitea.dev/models/repo"
|
||||||
"gitea.dev/models/unittest"
|
"gitea.dev/models/unittest"
|
||||||
|
|
||||||
@@ -69,28 +67,6 @@ func TestRepository_IsCollaborator(t *testing.T) {
|
|||||||
test(4, 4, true)
|
test(4, 4, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRepository_ChangeCollaborationAccessMode(t *testing.T) {
|
|
||||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
|
||||||
|
|
||||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
|
||||||
assert.NoError(t, repo_model.ChangeCollaborationAccessMode(t.Context(), repo, 4, perm.AccessModeAdmin))
|
|
||||||
|
|
||||||
collaboration := unittest.AssertExistsAndLoadBean(t, &repo_model.Collaboration{RepoID: repo.ID, UserID: 4})
|
|
||||||
assert.Equal(t, perm.AccessModeAdmin, collaboration.Mode)
|
|
||||||
|
|
||||||
access := unittest.AssertExistsAndLoadBean(t, &access_model.Access{UserID: 4, RepoID: repo.ID})
|
|
||||||
assert.Equal(t, perm.AccessModeAdmin, access.Mode)
|
|
||||||
|
|
||||||
assert.NoError(t, repo_model.ChangeCollaborationAccessMode(t.Context(), repo, 4, perm.AccessModeAdmin))
|
|
||||||
|
|
||||||
assert.NoError(t, repo_model.ChangeCollaborationAccessMode(t.Context(), repo, unittest.NonexistentID, perm.AccessModeAdmin))
|
|
||||||
|
|
||||||
// Discard invalid input.
|
|
||||||
assert.NoError(t, repo_model.ChangeCollaborationAccessMode(t.Context(), repo, 4, perm.AccessMode(-1)))
|
|
||||||
|
|
||||||
unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repo.ID})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRepository_IsOwnerMemberCollaborator(t *testing.T) {
|
func TestRepository_IsOwnerMemberCollaborator(t *testing.T) {
|
||||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ package jobparser
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"reflect"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/exprparser"
|
"gitea.com/gitea/runner/act/exprparser"
|
||||||
@@ -23,12 +26,6 @@ func NewExpressionEvaluator(interpreter exprparser.Interpreter) *ExpressionEvalu
|
|||||||
return &ExpressionEvaluator{interpreter: interpreter}
|
return &ExpressionEvaluator{interpreter: interpreter}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ee ExpressionEvaluator) evaluate(in string, defaultStatusCheck exprparser.DefaultStatusCheck) (any, error) {
|
|
||||||
evaluated, err := ee.interpreter.Evaluate(in, defaultStatusCheck)
|
|
||||||
|
|
||||||
return evaluated, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ee ExpressionEvaluator) evaluateScalarYamlNode(node *yaml.Node) error {
|
func (ee ExpressionEvaluator) evaluateScalarYamlNode(node *yaml.Node) error {
|
||||||
var in string
|
var in string
|
||||||
if err := node.Decode(&in); err != nil {
|
if err := node.Decode(&in); err != nil {
|
||||||
@@ -37,17 +34,17 @@ func (ee ExpressionEvaluator) evaluateScalarYamlNode(node *yaml.Node) error {
|
|||||||
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
expr, _ := rewriteSubExpression(in, false)
|
res, err := ee.evaluateScalar(in)
|
||||||
res, err := ee.evaluate(expr, exprparser.DefaultStatusCheckNone)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return node.Encode(res)
|
return node.Encode(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GitHub has this undocumented feature to merge maps, called insert directive
|
||||||
|
var insertDirective = regexp.MustCompile(`\${{\s*insert\s*}}`)
|
||||||
|
|
||||||
func (ee ExpressionEvaluator) evaluateMappingYamlNode(node *yaml.Node) error {
|
func (ee ExpressionEvaluator) evaluateMappingYamlNode(node *yaml.Node) error {
|
||||||
// GitHub has this undocumented feature to merge maps, called insert directive
|
|
||||||
insertDirective := regexp.MustCompile(`\${{\s*insert\s*}}`)
|
|
||||||
for i := 0; i < len(node.Content)/2; {
|
for i := 0; i < len(node.Content)/2; {
|
||||||
k := node.Content[i*2]
|
k := node.Content[i*2]
|
||||||
v := node.Content[i*2+1]
|
v := node.Content[i*2+1]
|
||||||
@@ -102,88 +99,170 @@ func (ee ExpressionEvaluator) EvaluateYamlNode(node *yaml.Node) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ee ExpressionEvaluator) Interpolate(in string) string {
|
// interpolate evaluates every part on its own, so a malformed one cannot restructure its neighbours
|
||||||
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
func (ee ExpressionEvaluator) interpolate(in string) (string, error) {
|
||||||
return in
|
parts, err := splitSubExpressions(in)
|
||||||
}
|
|
||||||
|
|
||||||
expr, _ := rewriteSubExpression(in, true)
|
|
||||||
evaluated, err := ee.evaluate(expr, exprparser.DefaultStatusCheckNone)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return "", err
|
||||||
}
|
}
|
||||||
|
if len(parts) == 1 && !parts[0].isExpr {
|
||||||
value, ok := evaluated.(string)
|
|
||||||
if !ok {
|
|
||||||
panic(fmt.Sprintf("Expression %s did not evaluate to a string", expr))
|
|
||||||
}
|
|
||||||
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func escapeFormatString(in string) string {
|
|
||||||
return strings.ReplaceAll(strings.ReplaceAll(in, "{", "{{"), "}", "}}")
|
|
||||||
}
|
|
||||||
|
|
||||||
func rewriteSubExpression(in string, forceFormat bool) (string, error) {
|
|
||||||
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
|
||||||
return in, nil
|
return in, nil
|
||||||
}
|
}
|
||||||
|
var out strings.Builder
|
||||||
|
out.Grow(len(in))
|
||||||
|
for _, part := range parts {
|
||||||
|
if !part.isExpr {
|
||||||
|
out.WriteString(part.text)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
evaluated, err := ee.interpreter.Evaluate(part.text, exprparser.DefaultStatusCheckNone)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
out.WriteString(coerceToString(evaluated))
|
||||||
|
}
|
||||||
|
return out.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
strPattern := regexp.MustCompile("(?:''|[^'])*'")
|
// evaluateScalar keeps the type of a lone expression, so `${{ fromJSON('[1,2]') }}` stays an array
|
||||||
pos := 0
|
func (ee ExpressionEvaluator) evaluateScalar(in string) (any, error) {
|
||||||
exprStart := -1
|
parts, err := splitSubExpressions(in)
|
||||||
strStart := -1
|
if err != nil {
|
||||||
var results []string
|
return nil, err
|
||||||
var formatOut strings.Builder
|
}
|
||||||
for pos < len(in) {
|
if len(parts) == 1 && parts[0].isExpr {
|
||||||
if strStart > -1 {
|
return ee.interpreter.Evaluate(parts[0].text, exprparser.DefaultStatusCheckNone)
|
||||||
matches := strPattern.FindStringIndex(in[pos:])
|
}
|
||||||
if matches == nil {
|
return ee.interpolate(in)
|
||||||
return "", errors.New("unclosed string")
|
}
|
||||||
}
|
|
||||||
|
|
||||||
strStart = -1
|
// evaluateCondition evaluates an `if:`, an expression even without `${{ }}`. Mixed content
|
||||||
pos += matches[1]
|
// interpolates to a string, so the success() default applies to it separately.
|
||||||
} else if exprStart > -1 {
|
func (ee ExpressionEvaluator) evaluateCondition(in string) (bool, error) {
|
||||||
exprEnd := strings.Index(in[pos:], "}}")
|
parts, err := splitSubExpressions(in)
|
||||||
strStart = strings.Index(in[pos:], "'")
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if len(parts) == 1 {
|
||||||
|
evaluated, err := ee.interpreter.Evaluate(parts[0].text, exprparser.DefaultStatusCheckSuccess)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return exprparser.IsTruthy(evaluated), nil
|
||||||
|
}
|
||||||
|
|
||||||
if exprEnd > -1 && strStart > -1 {
|
// mixed content is a string, so the success() default applies to it separately
|
||||||
if exprEnd < strStart {
|
if !expressionCallsFunction(in, "success", "always", "failure", "cancelled") {
|
||||||
strStart = -1
|
status, err := ee.interpreter.Evaluate("success()", exprparser.DefaultStatusCheckNone)
|
||||||
} else {
|
if err != nil {
|
||||||
exprEnd = -1
|
return false, err
|
||||||
}
|
}
|
||||||
}
|
if !exprparser.IsTruthy(status) {
|
||||||
|
return false, nil
|
||||||
if exprEnd > -1 {
|
|
||||||
fmt.Fprintf(&formatOut, "{%d}", len(results))
|
|
||||||
results = append(results, strings.TrimSpace(in[exprStart:pos+exprEnd]))
|
|
||||||
pos += exprEnd + 2
|
|
||||||
exprStart = -1
|
|
||||||
} else if strStart > -1 {
|
|
||||||
pos += strStart + 1
|
|
||||||
} else {
|
|
||||||
panic("unclosed expression.")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
exprStart = strings.Index(in[pos:], "${{")
|
|
||||||
if exprStart != -1 {
|
|
||||||
formatOut.WriteString(escapeFormatString(in[pos : pos+exprStart]))
|
|
||||||
exprStart = pos + exprStart + 3
|
|
||||||
pos = exprStart
|
|
||||||
} else {
|
|
||||||
formatOut.WriteString(escapeFormatString(in[pos:]))
|
|
||||||
pos = len(in)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
interpolated, err := ee.interpolate(in)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return exprparser.IsTruthy(interpolated), nil
|
||||||
|
}
|
||||||
|
|
||||||
if len(results) == 1 && formatOut.String() == "{0}" && !forceFormat {
|
// coerceToString converts an evaluated expression value to a string the way GitHub does,
|
||||||
return in, nil
|
// see https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#operators
|
||||||
|
// An already reflected value is accepted as-is, since Interface() would panic on an invalid one.
|
||||||
|
func coerceToString(v any) string {
|
||||||
|
value, ok := v.(reflect.Value)
|
||||||
|
if !ok {
|
||||||
|
value = reflect.ValueOf(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
out := fmt.Sprintf("format('%s', %s)", strings.ReplaceAll(formatOut.String(), "'", "''"), strings.Join(results, ", "))
|
switch value.Kind() {
|
||||||
return out, nil
|
case reflect.Invalid:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
case reflect.Bool:
|
||||||
|
return strconv.FormatBool(value.Bool())
|
||||||
|
|
||||||
|
case reflect.String:
|
||||||
|
return value.String()
|
||||||
|
|
||||||
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||||
|
return strconv.FormatInt(value.Int(), 10)
|
||||||
|
|
||||||
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||||
|
return strconv.FormatUint(value.Uint(), 10)
|
||||||
|
|
||||||
|
case reflect.Float32, reflect.Float64:
|
||||||
|
if math.IsInf(value.Float(), 1) {
|
||||||
|
return "Infinity"
|
||||||
|
} else if math.IsInf(value.Float(), -1) {
|
||||||
|
return "-Infinity"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.15G", value.Float())
|
||||||
|
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
return "Array"
|
||||||
|
|
||||||
|
// contexts such as `github` are pointers to structs, so they stringify as objects too
|
||||||
|
case reflect.Map, reflect.Struct:
|
||||||
|
return "Object"
|
||||||
|
|
||||||
|
case reflect.Interface, reflect.Pointer:
|
||||||
|
if value.IsNil() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return coerceToString(value.Elem())
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%v", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
type exprPart struct {
|
||||||
|
text string
|
||||||
|
isExpr bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitSubExpressions splits in the way GitHub's template reader does, leaving a value without a
|
||||||
|
// complete expression literal.
|
||||||
|
func splitSubExpressions(in string) ([]exprPart, error) {
|
||||||
|
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
||||||
|
return []exprPart{{text: in}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := make([]exprPart, 0, 2*strings.Count(in, "${{")+1)
|
||||||
|
for {
|
||||||
|
start := strings.Index(in, "${{")
|
||||||
|
if start < 0 {
|
||||||
|
if in != "" {
|
||||||
|
parts = append(parts, exprPart{text: in})
|
||||||
|
}
|
||||||
|
return parts, nil
|
||||||
|
}
|
||||||
|
if start > 0 {
|
||||||
|
parts = append(parts, exprPart{text: in[:start]})
|
||||||
|
}
|
||||||
|
rest := in[start+len("${{"):]
|
||||||
|
end := indexExprEnd(rest)
|
||||||
|
if end < 0 {
|
||||||
|
return nil, errors.New("unclosed expression")
|
||||||
|
}
|
||||||
|
parts = append(parts, exprPart{text: strings.TrimSpace(rest[:end]), isExpr: true})
|
||||||
|
in = rest[end+len("}}"):]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexExprEnd returns the offset of the `}}` ending an expression, or -1. A quote toggles string
|
||||||
|
// state, so a `}}` inside a string does not end it.
|
||||||
|
func indexExprEnd(in string) int {
|
||||||
|
inString := false
|
||||||
|
for i := range len(in) {
|
||||||
|
switch {
|
||||||
|
case in[i] == '\'':
|
||||||
|
inString = !inString
|
||||||
|
case !inString && in[i] == '}' && i+1 < len(in) && in[i+1] == '}':
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ package jobparser
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/exprparser"
|
"gitea.com/gitea/runner/act/exprparser"
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
"github.com/rhysd/actionlint"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,7 +50,9 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
evaluator := NewExpressionEvaluator(exprparser.NewInterpeter(&exprparser.EvaluationEnvironment{Github: pc.gitContext, Vars: pc.vars, Inputs: pc.inputs}, exprparser.Config{}))
|
evaluator := NewExpressionEvaluator(exprparser.NewInterpeter(&exprparser.EvaluationEnvironment{Github: pc.gitContext, Vars: pc.vars, Inputs: pc.inputs}, exprparser.Config{}))
|
||||||
workflow.RunName = evaluator.Interpolate(workflow.RunName)
|
if workflow.RunName, err = evaluator.interpolate(workflow.RunName); err != nil {
|
||||||
|
return nil, fmt.Errorf("interpolate run-name: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
for i, id := range ids {
|
for i, id := range ids {
|
||||||
job := jobs[i]
|
job := jobs[i]
|
||||||
@@ -63,10 +67,14 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
|
|||||||
}
|
}
|
||||||
job.Strategy.RawMatrix = encodeMatrix(matrix)
|
job.Strategy.RawMatrix = encodeMatrix(matrix)
|
||||||
evaluator := NewExpressionEvaluator(NewInterpeter(id, origin.GetJob(id), matrix, pc.gitContext, results, pc.vars, pc.inputs))
|
evaluator := NewExpressionEvaluator(NewInterpeter(id, origin.GetJob(id), matrix, pc.gitContext, results, pc.vars, pc.inputs))
|
||||||
job.Name = nameWithMatrix(job.Name, matrix, evaluator)
|
if job.Name, err = nameWithMatrix(job.Name, matrix, evaluator); err != nil {
|
||||||
|
return nil, fmt.Errorf("interpolate name for job %q: %w", id, err)
|
||||||
|
}
|
||||||
runsOn := origin.GetJob(id).RunsOn()
|
runsOn := origin.GetJob(id).RunsOn()
|
||||||
for i, v := range runsOn {
|
for i, v := range runsOn {
|
||||||
runsOn[i] = evaluator.Interpolate(v)
|
if runsOn[i], err = evaluator.interpolate(v); err != nil {
|
||||||
|
return nil, fmt.Errorf("interpolate runs-on for job %q: %w", id, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
job.RawRunsOn = encodeRunsOn(runsOn)
|
job.RawRunsOn = encodeRunsOn(runsOn)
|
||||||
if err := evaluator.EvaluateYamlNode(&job.RawContinueOnError); err != nil {
|
if err := evaluator.EvaluateYamlNode(&job.RawContinueOnError); err != nil {
|
||||||
@@ -150,16 +158,45 @@ func encodeRunsOn(runsOn []string) yaml.Node {
|
|||||||
return node
|
return node
|
||||||
}
|
}
|
||||||
|
|
||||||
func nameWithMatrix(name string, m map[string]any, evaluator *ExpressionEvaluator) string {
|
func nameWithMatrix(name string, m map[string]any, evaluator *ExpressionEvaluator) (string, error) {
|
||||||
if len(m) == 0 {
|
if len(m) == 0 {
|
||||||
return name
|
return name, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.Contains(name, "${{") || !strings.Contains(name, "}}") {
|
if !strings.Contains(name, "${{") || !strings.Contains(name, "}}") {
|
||||||
return name + " " + matrixName(m)
|
return name + " " + matrixName(m), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return evaluator.Interpolate(name)
|
return evaluator.interpolate(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// expressionCallsFunction reports whether any ${{ }} expression in value calls one of the functions.
|
||||||
|
func expressionCallsFunction(value string, names ...string) bool {
|
||||||
|
parts, err := splitSubExpressions(value)
|
||||||
|
if err != nil {
|
||||||
|
return true // unparseable here, let the expansion report it against the real values
|
||||||
|
}
|
||||||
|
for _, part := range parts {
|
||||||
|
if !part.isExpr {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// The lexer needs the closing `}}` that the scanner strips.
|
||||||
|
expr, err := actionlint.NewExprParser().Parse(actionlint.NewExprLexer(part.text + "}}"))
|
||||||
|
if err != nil {
|
||||||
|
return true // unparseable here, let the expansion report it against the real values
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
actionlint.VisitExprNode(expr, func(node, _ actionlint.ExprNode, entering bool) {
|
||||||
|
call, ok := node.(*actionlint.FuncCallNode)
|
||||||
|
if entering && ok && slices.Contains(names, strings.ToLower(call.Callee)) {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if found {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func matrixName(m map[string]any) string {
|
func matrixName(m map[string]any) string {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
@@ -107,3 +108,42 @@ func TestParse(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseInterpolatesRunName(t *testing.T) {
|
||||||
|
workflow := func(runName string) []byte {
|
||||||
|
return []byte("name: t\nrun-name: \"" + runName + "\"\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps: [{run: echo}]\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range []struct{ name, runName, want string }{
|
||||||
|
{"bool", "${{ true }}", "true"},
|
||||||
|
{"int", "${{ 1 }}", "1"},
|
||||||
|
{"float", "${{ 1.0 }}", "1"},
|
||||||
|
{"null", "${{ null }}", ""},
|
||||||
|
{"object", `${{ fromJSON('{\"a\":1}') }}`, "Object"},
|
||||||
|
{"array", "${{ fromJSON('[1,2]') }}", "Array"},
|
||||||
|
{"context", "${{ github }}", "Object"},
|
||||||
|
{"surrounding literals", "run ${{ 1 }} now", "run 1 now"},
|
||||||
|
{"two expressions", "${{ 1 }}-${{ true }}", "1-true"},
|
||||||
|
{"closing brace inside a string", "${{ 'a}}b' }}", "a}}b"},
|
||||||
|
{"incomplete expression stays literal", "${{ 1", "${{ 1"},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result, err := Parse(workflow(tt.runName), WithGitContext(&model.GithubContext{EventName: "push"}))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, result, 1)
|
||||||
|
assert.Equal(t, tt.want, result[0].RunName)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// a malformed part must not restructure the surrounding expression
|
||||||
|
for _, runName := range []string{"${{ 1) && (2 }}", "run ${{ 1) && (2 }} now", "${{ 'a' }} ${{ b", "${{ 'a }}"} {
|
||||||
|
_, err := Parse(workflow(runName), WithGitContext(&model.GithubContext{EventName: "push"}))
|
||||||
|
assert.ErrorContains(t, err, "interpolate run-name")
|
||||||
|
}
|
||||||
|
|
||||||
|
// callers such as commit status parse without a git context, leaving `github` a nil pointer
|
||||||
|
result, err := Parse(workflow("${{ github }}"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, result, 1)
|
||||||
|
assert.Empty(t, result[0].RunName)
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/exprparser"
|
"gitea.dev/modules/util"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
@@ -32,6 +33,11 @@ func (w *SingleWorkflow) Job() (string, *Job) {
|
|||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WorkflowDispatchConfig returns the `on: workflow_dispatch` declaration, nil if there is none.
|
||||||
|
func (w *SingleWorkflow) WorkflowDispatchConfig() *model.WorkflowDispatch {
|
||||||
|
return (&model.Workflow{RawOn: w.RawOn}).WorkflowDispatchConfig()
|
||||||
|
}
|
||||||
|
|
||||||
func (w *SingleWorkflow) jobs() ([]string, []*Job, error) {
|
func (w *SingleWorkflow) jobs() ([]string, []*Job, error) {
|
||||||
ids, jobs, err := parseMappingNode[*Job](&w.RawJobs)
|
ids, jobs, err := parseMappingNode[*Job](&w.RawJobs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -292,7 +298,7 @@ func EvaluateConcurrency(rc *model.RawConcurrency, jobID string, job *Job, gitCt
|
|||||||
if evaluated.RawExpression != "" {
|
if evaluated.RawExpression != "" {
|
||||||
return evaluated.RawExpression, false, nil
|
return evaluated.RawExpression, false, nil
|
||||||
}
|
}
|
||||||
return evaluated.Group, evaluated.CancelInProgress == "true", nil
|
return evaluated.Group, util.ParseYamlBool(evaluated.CancelInProgress), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func toGitContext(input map[string]any) *model.GithubContext {
|
func toGitContext(input map[string]any) *model.GithubContext {
|
||||||
@@ -518,15 +524,7 @@ func EvaluateJobIfExpression(jobID string, job *Job, gitCtx map[string]any, resu
|
|||||||
matrix = matrixes[0]
|
matrix = matrixes[0]
|
||||||
}
|
}
|
||||||
evaluator := NewExpressionEvaluator(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs))
|
evaluator := NewExpressionEvaluator(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs))
|
||||||
expr, err := rewriteSubExpression(job.If.Value, false)
|
return evaluator.evaluateCondition(job.If.Value)
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
result, err := evaluator.evaluate(expr, exprparser.DefaultStatusCheckSuccess)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return exprparser.IsTruthy(result), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseMappingNode parse a mapping node and preserve order.
|
// parseMappingNode parse a mapping node and preserve order.
|
||||||
|
|||||||
@@ -527,6 +527,10 @@ func TestEvaluateJobIfExpression(t *testing.T) {
|
|||||||
{name: "cancelled", ifCond: "${{ cancelled() }}", needResult: "success", expected: false},
|
{name: "cancelled", ifCond: "${{ cancelled() }}", needResult: "success", expected: false},
|
||||||
{name: "not cancelled or failure", ifCond: "${{ !(cancelled() || failure()) }}", needResult: "success", expected: true},
|
{name: "not cancelled or failure", ifCond: "${{ !(cancelled() || failure()) }}", needResult: "success", expected: true},
|
||||||
{name: "not cancelled or failure, need failed", ifCond: "${{ !(cancelled() || failure()) }}", needResult: "failure", expected: false},
|
{name: "not cancelled or failure, need failed", ifCond: "${{ !(cancelled() || failure()) }}", needResult: "failure", expected: false},
|
||||||
|
// a condition is an expression with or without `${{ }}`, literal text around one makes it a string
|
||||||
|
{name: "bare expression", ifCond: "always()", needResult: "failure", expected: true},
|
||||||
|
{name: "literal text keeps the success() default", ifCond: "x ${{ 1 }}", needResult: "failure", expected: false},
|
||||||
|
{name: "literal text around a status function drops it", ifCond: "x ${{ always() }}", needResult: "failure", expected: true},
|
||||||
}
|
}
|
||||||
for _, kase := range kases {
|
for _, kase := range kases {
|
||||||
t.Run(kase.name, func(t *testing.T) {
|
t.Run(kase.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -260,7 +260,7 @@ func MatchCallerInputsAgainstSpec(spec *WorkflowCallSpec, evaluated map[string]a
|
|||||||
func parseWorkflowCallInput(name string, typ InputType, v any) (any, error) {
|
func parseWorkflowCallInput(name string, typ InputType, v any) (any, error) {
|
||||||
switch typ {
|
switch typ {
|
||||||
case InputTypeString:
|
case InputTypeString:
|
||||||
return toString(v), nil
|
return coerceToString(v), nil
|
||||||
case InputTypeBoolean:
|
case InputTypeBoolean:
|
||||||
// strict type matching: a boolean input only accepts a native bool, not a "true"/"false" string
|
// strict type matching: a boolean input only accepts a native bool, not a "true"/"false" string
|
||||||
if b, ok := v.(bool); ok {
|
if b, ok := v.(bool); ok {
|
||||||
@@ -361,11 +361,11 @@ func EvaluateWorkflowCallOutputs(spec *WorkflowCallSpec, gitCtx *model.GithubCon
|
|||||||
Vars: vars,
|
Vars: vars,
|
||||||
Inputs: inputs,
|
Inputs: inputs,
|
||||||
}
|
}
|
||||||
interpreter := exprparser.NewInterpeter(env, exprparser.Config{})
|
evaluator := NewExpressionEvaluator(exprparser.NewInterpeter(env, exprparser.Config{}))
|
||||||
|
|
||||||
out := make(map[string]string, len(spec.Outputs))
|
out := make(map[string]string, len(spec.Outputs))
|
||||||
for name, o := range spec.Outputs {
|
for name, o := range spec.Outputs {
|
||||||
v, err := evaluateWorkflowCallOutputValue(interpreter, o.Value)
|
v, err := evaluator.interpolate(o.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("workflow_call output %q: %w", name, err)
|
return nil, fmt.Errorf("workflow_call output %q: %w", name, err)
|
||||||
}
|
}
|
||||||
@@ -373,29 +373,3 @@ func EvaluateWorkflowCallOutputs(spec *WorkflowCallSpec, gitCtx *model.GithubCon
|
|||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func evaluateWorkflowCallOutputValue(interpreter exprparser.Interpreter, value string) (string, error) {
|
|
||||||
if !strings.Contains(value, "${{") || !strings.Contains(value, "}}") {
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
expr, err := rewriteSubExpression(value, true)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
evaluated, err := interpreter.Evaluate(expr, exprparser.DefaultStatusCheckNone)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return toString(evaluated), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func toString(v any) string {
|
|
||||||
switch s := v.(type) {
|
|
||||||
case string:
|
|
||||||
return s
|
|
||||||
case nil:
|
|
||||||
return ""
|
|
||||||
default:
|
|
||||||
return fmt.Sprintf("%v", s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ func ParseScopedWorkflows(sourceCommit *git.Commit) ([]*ParsedScopedWorkflow, er
|
|||||||
// It returns the workflows whose `on:` matches, and those that matched the event but were excluded by a branch/paths filter (filtered).
|
// It returns the workflows whose `on:` matches, and those that matched the event but were excluded by a branch/paths filter (filtered).
|
||||||
func MatchScopedWorkflows(
|
func MatchScopedWorkflows(
|
||||||
parsed []*ParsedScopedWorkflow,
|
parsed []*ParsedScopedWorkflow,
|
||||||
|
sourceCommitSHA string,
|
||||||
consumerGitRepo *git.Repository,
|
consumerGitRepo *git.Repository,
|
||||||
consumerCommit *git.Commit,
|
consumerCommit *git.Commit,
|
||||||
triggedEvent webhook_module.HookEventType,
|
triggedEvent webhook_module.HookEventType,
|
||||||
@@ -71,9 +72,10 @@ func MatchScopedWorkflows(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
dwf := &DetectedWorkflow{
|
dwf := &DetectedWorkflow{
|
||||||
EntryName: p.EntryName,
|
EntryName: p.EntryName,
|
||||||
TriggerEvent: evt,
|
TriggerEvent: evt,
|
||||||
Content: p.Content,
|
Content: p.Content,
|
||||||
|
SourceCommitSHA: sourceCommitSHA,
|
||||||
}
|
}
|
||||||
switch detectWorkflowMatch(consumerGitRepo, consumerCommit, triggedEvent, payload, evt) {
|
switch detectWorkflowMatch(consumerGitRepo, consumerCommit, triggedEvent, payload, evt) {
|
||||||
case detectMatched:
|
case detectMatched:
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ type DetectedWorkflow struct {
|
|||||||
EntryName string
|
EntryName string
|
||||||
TriggerEvent *jobparser.Event
|
TriggerEvent *jobparser.Event
|
||||||
Content []byte
|
Content []byte
|
||||||
|
// SourceCommitSHA is the commit Content was read from, and must always be filled in together with Content.
|
||||||
|
SourceCommitSHA string
|
||||||
}
|
}
|
||||||
|
|
||||||
type detectResult int
|
type detectResult int
|
||||||
@@ -203,17 +205,19 @@ func DetectWorkflows(
|
|||||||
if evt.IsSchedule() {
|
if evt.IsSchedule() {
|
||||||
if detectSchedule {
|
if detectSchedule {
|
||||||
dwf := &DetectedWorkflow{
|
dwf := &DetectedWorkflow{
|
||||||
EntryName: entry.Name(),
|
EntryName: entry.Name(),
|
||||||
TriggerEvent: evt,
|
TriggerEvent: evt,
|
||||||
Content: content,
|
Content: content,
|
||||||
|
SourceCommitSHA: commit.ID.String(),
|
||||||
}
|
}
|
||||||
schedules = append(schedules, dwf)
|
schedules = append(schedules, dwf)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
dwf := &DetectedWorkflow{
|
dwf := &DetectedWorkflow{
|
||||||
EntryName: entry.Name(),
|
EntryName: entry.Name(),
|
||||||
TriggerEvent: evt,
|
TriggerEvent: evt,
|
||||||
Content: content,
|
Content: content,
|
||||||
|
SourceCommitSHA: commit.ID.String(),
|
||||||
}
|
}
|
||||||
switch detectWorkflowMatch(gitRepo, commit, triggedEvent, payload, evt) {
|
switch detectWorkflowMatch(gitRepo, commit, triggedEvent, payload, evt) {
|
||||||
case detectMatched:
|
case detectMatched:
|
||||||
@@ -252,9 +256,10 @@ func DetectScheduledWorkflows(gitRepo *git.Repository, commit *git.Commit) ([]*D
|
|||||||
if evt.IsSchedule() {
|
if evt.IsSchedule() {
|
||||||
log.Trace("detect scheduled workflow: %q", entry.Name())
|
log.Trace("detect scheduled workflow: %q", entry.Name())
|
||||||
dwf := &DetectedWorkflow{
|
dwf := &DetectedWorkflow{
|
||||||
EntryName: entry.Name(),
|
EntryName: entry.Name(),
|
||||||
TriggerEvent: evt,
|
TriggerEvent: evt,
|
||||||
Content: content,
|
Content: content,
|
||||||
|
SourceCommitSHA: commit.ID.String(),
|
||||||
}
|
}
|
||||||
wfs = append(wfs, dwf)
|
wfs = append(wfs, dwf)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,13 +28,10 @@ func Init() {
|
|||||||
|
|
||||||
WebAuthn = &webauthn.WebAuthn{
|
WebAuthn = &webauthn.WebAuthn{
|
||||||
Config: &webauthn.Config{
|
Config: &webauthn.Config{
|
||||||
RPDisplayName: setting.AppName,
|
RPDisplayName: setting.AppName,
|
||||||
RPID: setting.Domain,
|
RPID: setting.Domain,
|
||||||
RPOrigins: []string{appURL},
|
RPOrigins: []string{appURL},
|
||||||
AuthenticatorSelection: protocol.AuthenticatorSelection{
|
AttestationPreference: protocol.PreferNoAttestation, // Gitea never verifies attestation
|
||||||
UserVerification: protocol.VerificationDiscouraged,
|
|
||||||
},
|
|
||||||
AttestationPreference: protocol.PreferDirectAttestation,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,9 +39,7 @@ type CommitMessage struct {
|
|||||||
|
|
||||||
trailerValues CommitMessageTrailerValues
|
trailerValues CommitMessageTrailerValues
|
||||||
|
|
||||||
allParticipants []*CommitIdentity
|
allAuthors []*CommitIdentity
|
||||||
committerCoAuthorIdx int
|
|
||||||
committerCoAuthor *CommitIdentity
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *CommitMessage) MessageUTF8() string {
|
func (c *CommitMessage) MessageUTF8() string {
|
||||||
@@ -146,63 +144,50 @@ func CommitMessageParseTrailer(s string) CommitMessageTrailerValues {
|
|||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
// AllParticipantIdentities returns all the participants in the commit, the first one is the commit's author
|
// AllAuthorIdentities returns all the author and co-authors in the commit. Committer is not included:
|
||||||
func (c *Commit) AllParticipantIdentities() []*CommitIdentity {
|
// * Author & Co-author: they changed the code (attribution)
|
||||||
if c.allParticipants != nil {
|
// * Committer: they submitted the commit but didn't change the code (e.g.: maintainer signed a commit)
|
||||||
return c.allParticipants
|
func (c *Commit) AllAuthorIdentities() []*CommitIdentity {
|
||||||
|
if c.allAuthors != nil {
|
||||||
|
return c.allAuthors
|
||||||
}
|
}
|
||||||
|
trailerCoAuthors := c.MessageTrailer()["co-authored-by"]
|
||||||
|
c.allAuthors = make([]*CommitIdentity, 0, 1+len(trailerCoAuthors))
|
||||||
exclude := map[string]int{}
|
exclude := map[string]int{}
|
||||||
addParticipant := func(name, email string, role int) (existingRole int) {
|
addAuthor := func(name, email string, role int) {
|
||||||
if name == "" && email == "" {
|
if name == "" && email == "" {
|
||||||
return 0
|
return
|
||||||
}
|
}
|
||||||
emailLower := strings.ToLower(email)
|
key := strings.ToLower(email)
|
||||||
if existingRole = exclude[emailLower]; emailLower != "" && existingRole != 0 {
|
if key == "" {
|
||||||
return existingRole
|
key = strings.ToLower(name)
|
||||||
}
|
}
|
||||||
c.allParticipants = append(c.allParticipants, &CommitIdentity{Name: name, Email: email, role: role})
|
if existingRole := exclude[key]; key != "" && existingRole != 0 {
|
||||||
exclude[emailLower] = role
|
return
|
||||||
return 0
|
}
|
||||||
|
c.allAuthors = append(c.allAuthors, &CommitIdentity{Name: name, Email: email, role: role})
|
||||||
|
exclude[key] = role
|
||||||
}
|
}
|
||||||
|
|
||||||
c.committerCoAuthorIdx = -1
|
addAuthor(c.Author.Name, c.Author.Email, commitIdentityRoleAuthor)
|
||||||
addParticipant(c.Author.Name, c.Author.Email, commitIdentityRoleAuthor)
|
for _, coAuthorValue := range trailerCoAuthors {
|
||||||
addParticipant(c.Committer.Name, c.Committer.Email, commitIdentityRoleCommitter)
|
|
||||||
for _, coAuthorValue := range c.MessageTrailer()["co-authored-by"] {
|
|
||||||
addr, err := mail.ParseAddress(coAuthorValue)
|
addr, err := mail.ParseAddress(coAuthorValue)
|
||||||
coAuthorName, coAuthorEmail := coAuthorValue, ""
|
coAuthorName, coAuthorEmail := coAuthorValue, ""
|
||||||
if err == nil {
|
if err == nil {
|
||||||
coAuthorName, coAuthorEmail = addr.Name, addr.Address
|
coAuthorName, coAuthorEmail = addr.Name, addr.Address
|
||||||
}
|
}
|
||||||
existingRole := addParticipant(coAuthorName, coAuthorEmail, commitIdentityRoleCoAuthor)
|
addAuthor(coAuthorName, coAuthorEmail, commitIdentityRoleCoAuthor)
|
||||||
if existingRole == commitIdentityRoleCommitter && c.committerCoAuthorIdx == -1 {
|
|
||||||
c.committerCoAuthorIdx = len(c.allParticipants)
|
|
||||||
c.committerCoAuthor = &CommitIdentity{coAuthorName, coAuthorEmail, commitIdentityRoleCoAuthor}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return c.allParticipants
|
return c.allAuthors
|
||||||
}
|
}
|
||||||
|
|
||||||
// CoAuthorIdentities returns co-author identities defined by "Co-authored-by:" in the git message trailer
|
|
||||||
// Only the commit's author is excluded. If committer is declared as co-author, it will be included in the result.
|
|
||||||
// * Author & Co-author: they changed the code (attribution)
|
|
||||||
// * Committer: they submitted the commit but didn't change the code (e.g.: maintainer signed a commit)
|
|
||||||
// So, a committer can also be a co-author if they changed the code.
|
|
||||||
func (c *Commit) CoAuthorIdentities() (coAuthors []*CommitIdentity) {
|
func (c *Commit) CoAuthorIdentities() (coAuthors []*CommitIdentity) {
|
||||||
all := c.AllParticipantIdentities()
|
all := c.AllAuthorIdentities()
|
||||||
if len(all) <= 1 {
|
if len(all) == 0 {
|
||||||
return nil // no co-author list
|
return nil
|
||||||
}
|
}
|
||||||
if all[1].role != commitIdentityRoleCommitter {
|
if all[0].role == commitIdentityRoleAuthor {
|
||||||
return all[1:] // no committer, so all after author are co-authors
|
return all[1:]
|
||||||
}
|
}
|
||||||
if c.committerCoAuthorIdx == -1 {
|
return all
|
||||||
return all[2:] // the committer is not in the co-author list, so just return the co-author list
|
|
||||||
}
|
|
||||||
// the committer is in the co-author list but de-duplicated, so include them as co-author again
|
|
||||||
coAuthors = append(coAuthors, all[2:c.committerCoAuthorIdx]...)
|
|
||||||
coAuthors = append(coAuthors, c.committerCoAuthor)
|
|
||||||
coAuthors = append(coAuthors, all[c.committerCoAuthorIdx:]...)
|
|
||||||
return coAuthors
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,41 +52,44 @@ func TestCommitMessageTrailer(t *testing.T) {
|
|||||||
func TestCommitMessageParticipants(t *testing.T) {
|
func TestCommitMessageParticipants(t *testing.T) {
|
||||||
sig := func(n, e string) *Signature { return &Signature{Name: n, Email: e} }
|
sig := func(n, e string) *Signature { return &Signature{Name: n, Email: e} }
|
||||||
idt := func(n, e string, r int) *CommitIdentity { return &CommitIdentity{n, e, r} }
|
idt := func(n, e string, r int) *CommitIdentity { return &CommitIdentity{n, e, r} }
|
||||||
roleAuthor, roleCommitter, roleCoAuthor := commitIdentityRoleAuthor, commitIdentityRoleCommitter, commitIdentityRoleCoAuthor
|
roleAuthor, _, roleCoAuthor := commitIdentityRoleAuthor, commitIdentityRoleCommitter, commitIdentityRoleCoAuthor
|
||||||
type testCase struct {
|
type testCase struct {
|
||||||
name string
|
name string
|
||||||
commit *Commit
|
commit *Commit
|
||||||
identities []*CommitIdentity
|
identities []*CommitIdentity
|
||||||
}
|
}
|
||||||
t.Run("AllParticipants", func(t *testing.T) {
|
|
||||||
|
t.Run("AllAuthors", func(t *testing.T) {
|
||||||
cases := []testCase{
|
cases := []testCase{
|
||||||
{
|
{
|
||||||
"DifferentUsers",
|
"CommitterExcluded",
|
||||||
&Commit{
|
&Commit{
|
||||||
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
|
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
|
||||||
CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: x@m.com"},
|
CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: Full Name <x@m.com>"},
|
||||||
},
|
},
|
||||||
[]*CommitIdentity{idt("a", "a@m.com", roleAuthor), idt("c", "c@m.com", roleCommitter), idt("", "x@m.com", roleCoAuthor)},
|
[]*CommitIdentity{idt("a", "a@m.com", roleAuthor), idt("Full Name", "x@m.com", roleCoAuthor)},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"SameUser",
|
"AuthorIsCoAuthor",
|
||||||
&Commit{
|
&Commit{
|
||||||
Author: sig("a", "a@m.com"), Committer: sig("a", "A@M.com"),
|
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
|
||||||
CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: a@m.com"},
|
CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: other-name <a@m.com>"},
|
||||||
},
|
},
|
||||||
[]*CommitIdentity{idt("a", "a@m.com", roleAuthor)},
|
[]*CommitIdentity{idt("a", "a@m.com", roleAuthor)},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"NoCommitter",
|
"EmptyAuthor", // synthesized commits (push feed) may have no author signature at all
|
||||||
&Commit{
|
&Commit{
|
||||||
Author: sig("a", "a@m.com"), Committer: sig("", ""),
|
Author: sig("", ""), Committer: sig("", ""),
|
||||||
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: Full Name <X@M.com>"},
|
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: c <c@m.com>"},
|
||||||
},
|
},
|
||||||
[]*CommitIdentity{idt("a", "a@m.com", roleAuthor), idt("Full Name", "X@M.com", roleCoAuthor)},
|
// but if the commit message contains co-authors, the co-authors are still parsed for "all authors"
|
||||||
|
// if it is a problem, the caller should fix the problem (provide correct "author")
|
||||||
|
[]*CommitIdentity{idt("c", "c@m.com", roleCoAuthor)},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
assert.Equal(t, c.identities, c.commit.AllParticipantIdentities(), "case: %s", c.name)
|
assert.Equal(t, c.identities, c.commit.AllAuthorIdentities(), "case: %s", c.name)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
t.Run("CoAuthors", func(t *testing.T) {
|
t.Run("CoAuthors", func(t *testing.T) {
|
||||||
@@ -116,12 +119,12 @@ func TestCommitMessageParticipants(t *testing.T) {
|
|||||||
[]*CommitIdentity{},
|
[]*CommitIdentity{},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"CoAuthorCommitterNameWithIndex", // restore the committer co-author to the co-author list by the index with correct name
|
"CoAuthorNameOnlyAndDuplicate",
|
||||||
&Commit{
|
&Commit{
|
||||||
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
|
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
|
||||||
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: x <x@m.com>\nCo-authored-by: c-other <c@m.com>\nCo-authored-by: y <y@m.com>"},
|
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: b\nCo-authored-by: b\nCo-authored-by: c"},
|
||||||
},
|
},
|
||||||
[]*CommitIdentity{idt("x", "x@m.com", roleCoAuthor), idt("c-other", "c@m.com", roleCoAuthor), idt("y", "y@m.com", roleCoAuthor)},
|
[]*CommitIdentity{idt("b", "", roleCoAuthor), idt("c", "", roleCoAuthor)},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ func createRequest(ctx context.Context, method, url string, headers map[string]s
|
|||||||
}
|
}
|
||||||
|
|
||||||
// performRequest sends a request, optionally performs a callback on the request and returns the response.
|
// performRequest sends a request, optionally performs a callback on the request and returns the response.
|
||||||
// If the status code is 200, the response is returned, and it will contain a non-nil Body.
|
// If the status code is in the 2xx range, the response is returned, and it will contain a non-nil Body.
|
||||||
// Otherwise, it will return an error, and the Body will be nil or closed.
|
// Otherwise, it will return an error, and the Body will be nil or closed.
|
||||||
func performRequest(ctx context.Context, client *http.Client, req *http.Request) (*http.Response, error) {
|
func performRequest(ctx context.Context, client *http.Client, req *http.Request) (*http.Response, error) {
|
||||||
log.Trace("performRequest: %s", req.URL)
|
log.Trace("performRequest: %s", req.URL)
|
||||||
@@ -264,7 +264,7 @@ func performRequest(ctx context.Context, client *http.Client, req *http.Request)
|
|||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if res.StatusCode != http.StatusOK {
|
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||||
defer res.Body.Close()
|
defer res.Body.Close()
|
||||||
return res, handleErrorResponse(res)
|
return res, handleErrorResponse(res)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,15 @@ func TestBasicTransferAdapter(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("Upload created", func(t *testing.T) {
|
||||||
|
client := &http.Client{Transport: RoundTripFunc(func(req *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusCreated, Body: io.NopCloser(strings.NewReader(""))}
|
||||||
|
})}
|
||||||
|
adapter := &BasicTransferAdapter{client: client}
|
||||||
|
err := adapter.Upload(t.Context(), &Link{Href: "https://upload-created-request.io"}, p, strings.NewReader("dummy"))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("Verify", func(t *testing.T) {
|
t.Run("Verify", func(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
link *Link
|
link *Link
|
||||||
|
|||||||
Vendored
+33
-22
@@ -5,11 +5,11 @@ package external
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.dev/modules/markup"
|
"gitea.dev/modules/markup"
|
||||||
@@ -91,52 +91,63 @@ func (p *Renderer) GetExternalRendererOptions() (ret markup.ExternalRendererOpti
|
|||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
func envMark(envName string) string {
|
func (p *Renderer) prepareExternalCommand(vars map[string]string) (string, []string, error) {
|
||||||
if runtime.GOOS == "windows" {
|
fields, err := shellquote.Split(strings.TrimSpace(p.Command))
|
||||||
return "%" + envName + "%"
|
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.
|
// 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 {
|
func (p *Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
|
||||||
baseLinkSrc := ctx.RenderHelper.ResolveLink("", markup.LinkTypeDefault)
|
baseLinkSrc := ctx.RenderHelper.ResolveLink("", markup.LinkTypeDefault)
|
||||||
baseLinkRaw := ctx.RenderHelper.ResolveLink("", markup.LinkTypeRaw)
|
baseLinkRaw := ctx.RenderHelper.ResolveLink("", markup.LinkTypeRaw)
|
||||||
command := strings.NewReplacer(
|
cmdVars := map[string]string{
|
||||||
envMark("GITEA_PREFIX_SRC"), baseLinkSrc,
|
"GITEA_PREFIX_SRC": baseLinkSrc,
|
||||||
envMark("GITEA_PREFIX_RAW"), baseLinkRaw,
|
"GITEA_PREFIX_RAW": baseLinkRaw,
|
||||||
).Replace(p.Command)
|
}
|
||||||
commands, err := shellquote.Split(command)
|
cmdProg, cmdArgs, err := p.prepareExternalCommand(cmdVars)
|
||||||
if err != nil || len(commands) == 0 {
|
if err != nil {
|
||||||
return fmt.Errorf("%s invalid command %q: %w", p.Name(), p.Command, err)
|
return fmt.Errorf("invalid external render (%s) command %q: %w", p.Name(), p.Command, err)
|
||||||
}
|
}
|
||||||
args := commands[1:]
|
|
||||||
|
|
||||||
if p.IsInputFile {
|
if p.IsInputFile {
|
||||||
// write to temp file
|
// 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 {
|
if err != nil {
|
||||||
return fmt.Errorf("%s create temp file when rendering %s failed: %w", p.Name(), p.Command, err)
|
return fmt.Errorf("%s create temp file when rendering %s failed: %w", p.Name(), p.Command, err)
|
||||||
}
|
}
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
_, err = io.Copy(f, input)
|
_, err = io.Copy(tmpFile, input)
|
||||||
if err != nil {
|
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)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("%s close temp file when rendering %s failed: %w", p.Name(), p.Command, err)
|
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()
|
defer finished()
|
||||||
|
|
||||||
cmd := exec.CommandContext(processCtx, commands[0], args...)
|
cmd := exec.CommandContext(processCtx, cmdProg, cmdArgs...)
|
||||||
cmd.Env = append(
|
cmd.Env = append(
|
||||||
os.Environ(),
|
os.Environ(),
|
||||||
"GITEA_PREFIX_SRC="+baseLinkSrc,
|
"GITEA_PREFIX_SRC="+baseLinkSrc,
|
||||||
@@ -151,7 +162,7 @@ func (p *Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.
|
|||||||
process.SetSysProcAttribute(cmd)
|
process.SetSysProcAttribute(cmd)
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
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
|
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)
|
||||||
|
}
|
||||||
Vendored
+1
-8
@@ -21,19 +21,12 @@ type frontendRenderer struct {
|
|||||||
patterns []string
|
patterns []string
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var _ markup.ExternalRenderer = (*frontendRenderer)(nil)
|
||||||
_ markup.PostProcessRenderer = (*frontendRenderer)(nil)
|
|
||||||
_ markup.ExternalRenderer = (*frontendRenderer)(nil)
|
|
||||||
)
|
|
||||||
|
|
||||||
func (p *frontendRenderer) Name() string {
|
func (p *frontendRenderer) Name() string {
|
||||||
return p.name
|
return p.name
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *frontendRenderer) NeedPostProcess() bool {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *frontendRenderer) FileNamePatterns() []string {
|
func (p *frontendRenderer) FileNamePatterns() []string {
|
||||||
// TODO: the file extensions are ambiguous, even if the file name matches, it doesn't mean that the file is a 3D model
|
// TODO: the file extensions are ambiguous, even if the file name matches, it doesn't mean that the file is a 3D model
|
||||||
// There are some approaches to make it more accurate, but they are all complicated:
|
// There are some approaches to make it more accurate, but they are all complicated:
|
||||||
|
|||||||
@@ -29,9 +29,8 @@ func init() {
|
|||||||
type renderer struct{}
|
type renderer struct{}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
_ markup.Renderer = (*renderer)(nil)
|
_ markup.Renderer = (*renderer)(nil)
|
||||||
_ markup.PostProcessRenderer = (*renderer)(nil)
|
_ markup.ExternalRenderer = (*renderer)(nil) // FIXME: this is not an external render, need to refactor the framework in the future
|
||||||
_ markup.ExternalRenderer = (*renderer)(nil) // FIXME: this is not an external render, need to refactor the framework in the future
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type mimeHandler struct {
|
type mimeHandler struct {
|
||||||
@@ -96,8 +95,6 @@ func (renderer) Name() string {
|
|||||||
return "jupyter-render"
|
return "jupyter-render"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (renderer) NeedPostProcess() bool { return true }
|
|
||||||
|
|
||||||
func (renderer) GetExternalRendererOptions() markup.ExternalRendererOptions {
|
func (renderer) GetExternalRendererOptions() markup.ExternalRendererOptions {
|
||||||
return markup.ExternalRendererOptions{
|
return markup.ExternalRendererOptions{
|
||||||
// HINT: no need to let markup render sanitize the output because there are many special CSS class names, inline attributes.
|
// HINT: no need to let markup render sanitize the output because there are many special CSS class names, inline attributes.
|
||||||
@@ -215,7 +212,7 @@ func renderCellCode(output htmlutil.HTMLWriter, cell Cell, language string) erro
|
|||||||
|
|
||||||
// Highlight code
|
// Highlight code
|
||||||
lexer := highlight.DetectChromaLexerByFileName("", language)
|
lexer := highlight.DetectChromaLexerByFileName("", language)
|
||||||
output.WriteFormat(`<div class="cell-right cell-input"><pre><code class="chroma language-%s">`, strings.ToLower(language))
|
output.WriteFormat(`<div class="cell-right cell-input"><pre><code class="chroma language-%s">`, strings.ToLower(lexer.Config().Name))
|
||||||
output.WriteHTML(highlight.RenderCodeByLexer(lexer, source))
|
output.WriteHTML(highlight.RenderCodeByLexer(lexer, source))
|
||||||
output.WriteHTML("</code></pre></div>")
|
output.WriteHTML("</code></pre></div>")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ func TestIntegrationAndSanitization(t *testing.T) {
|
|||||||
maliciousNotebook := `{
|
maliciousNotebook := `{
|
||||||
"nbformat": 4,
|
"nbformat": 4,
|
||||||
"nbformat_minor": 2,
|
"nbformat_minor": 2,
|
||||||
"metadata": {},
|
"metadata": {"language_info":{"name":"any lang"}},
|
||||||
"cells": [
|
"cells": [
|
||||||
{
|
{
|
||||||
"cell_type": "code",
|
"cell_type": "code",
|
||||||
@@ -274,7 +274,7 @@ func TestIntegrationAndSanitization(t *testing.T) {
|
|||||||
"execution_count": 1,
|
"execution_count": 1,
|
||||||
"data": {
|
"data": {
|
||||||
"text/html": [
|
"text/html": [
|
||||||
"<div><script>alert('XSS Vector')</script><table class=\"dataframe\"><tr><td>Safe Content</td></tr></table></div>"
|
"<div><script>foo</script><table class=other><tr><td>[[name=no-post-process|link=/link]]</td></tr></table></div>"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"metadata": {}
|
"metadata": {}
|
||||||
@@ -295,8 +295,8 @@ func TestIntegrationAndSanitization(t *testing.T) {
|
|||||||
<div class="cell-line">
|
<div class="cell-line">
|
||||||
<div class="cell-left cell-prompt">In [1]:</div>
|
<div class="cell-left cell-prompt">In [1]:</div>
|
||||||
<div class="cell-right cell-input">
|
<div class="cell-right cell-input">
|
||||||
<pre><code class="chroma language-python">
|
<pre><code class="chroma language-fallback">
|
||||||
<span class="n">a</span><span class="o">=</span><span class="mi">1</span>
|
a=1
|
||||||
</code></pre>
|
</code></pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -304,7 +304,7 @@ func TestIntegrationAndSanitization(t *testing.T) {
|
|||||||
<div class="cell-left cell-prompt">Out [1]:</div>
|
<div class="cell-left cell-prompt">Out [1]:</div>
|
||||||
<div class="cell-right cell-output">
|
<div class="cell-right cell-output">
|
||||||
<div class="cell-output-html">
|
<div class="cell-output-html">
|
||||||
<div><table><tbody><tr><td>Safe Content</td></tr></tbody></table></div>
|
<div><table><tr><td>[[name=no-post-process|link=/link]]</td></tr></table></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import (
|
|||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
setting.IsInTesting = true
|
setting.IsInTesting = true
|
||||||
markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true
|
markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true
|
||||||
setting.Markdown.FileNamePatterns = []string{"*.md"}
|
|
||||||
markup.RefreshFileNamePatterns()
|
markup.RefreshFileNamePatterns()
|
||||||
os.Exit(m.Run())
|
os.Exit(m.Run())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,6 +153,9 @@ func ParsePackage(r io.Reader) (*Package, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
} else if !strings.HasPrefix(filename, ".") {
|
} else if !strings.HasPrefix(filename, ".") {
|
||||||
|
if strings.ContainsAny(hd.Name, "\n\r") {
|
||||||
|
continue // a newline would forge extra lines in the pacman index
|
||||||
|
}
|
||||||
if err := files.Add(hd.Name); err != nil {
|
if err := files.Add(hd.Name); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ func TestParsePackage(t *testing.T) {
|
|||||||
data := createPackage(c, map[string][]byte{
|
data := createPackage(c, map[string][]byte{
|
||||||
".PKGINFO": createPKGINFOContent(packageName, packageVersion),
|
".PKGINFO": createPKGINFOContent(packageName, packageVersion),
|
||||||
"/test/dummy.txt": {},
|
"/test/dummy.txt": {},
|
||||||
|
"usr/lib/legit\n\n%FILES%\n/etc/cron.d/x": {}, // must not reach the file list
|
||||||
})
|
})
|
||||||
|
|
||||||
p, err := ParsePackage(data)
|
p, err := ParsePackage(data)
|
||||||
|
|||||||
@@ -123,11 +123,26 @@ func ParsePackage(sr io.ReaderAt, size int64, mr io.Reader) (*Package, error) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nested packages (test fixtures, examples, benchmarks) ship their own manifests, which must not
|
||||||
|
// replace the package manifest. The package sits at the archive root or in a single top level
|
||||||
|
// directory, so keep only the shallowest manifest directory, breaking ties by name for stability.
|
||||||
|
var manifestFiles []*zip.File
|
||||||
|
manifestDir, manifestDepth := "", 0
|
||||||
for _, file := range zr.File {
|
for _, file := range zr.File {
|
||||||
manifestMatch := manifestPattern.FindStringSubmatch(path.Base(file.Name))
|
if strings.HasSuffix(file.Name, "/") || !manifestPattern.MatchString(path.Base(file.Name)) {
|
||||||
if len(manifestMatch) == 0 {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
dir, depth := path.Dir(file.Name), strings.Count(file.Name, "/")
|
||||||
|
switch {
|
||||||
|
case manifestFiles == nil || depth < manifestDepth || (depth == manifestDepth && dir < manifestDir):
|
||||||
|
manifestDir, manifestDepth, manifestFiles = dir, depth, []*zip.File{file}
|
||||||
|
case dir == manifestDir:
|
||||||
|
manifestFiles = append(manifestFiles, file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range manifestFiles {
|
||||||
|
manifestMatch := manifestPattern.FindStringSubmatch(path.Base(file.Name))
|
||||||
|
|
||||||
if file.UncompressedSize64 > maxManifestFileSize {
|
if file.UncompressedSize64 > maxManifestFileSize {
|
||||||
return nil, ErrManifestFileTooLarge
|
return nil, ErrManifestFileTooLarge
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package swift
|
package swift
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
"bytes"
|
"bytes"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -24,6 +25,18 @@ const (
|
|||||||
packageLicense = "MIT"
|
packageLicense = "MIT"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// writeOrderedZipArchive writes name/content pairs in the given order, which map based test.WriteZipArchive cannot do
|
||||||
|
func writeOrderedZipArchive(entries [][2]string) *bytes.Buffer {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
zw := zip.NewWriter(buf)
|
||||||
|
for _, entry := range entries {
|
||||||
|
w, _ := zw.Create(entry[0])
|
||||||
|
_, _ = w.Write([]byte(entry[1]))
|
||||||
|
}
|
||||||
|
_ = zw.Close()
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
func TestParsePackage(t *testing.T) {
|
func TestParsePackage(t *testing.T) {
|
||||||
t.Run("MissingManifestFile", func(t *testing.T) {
|
t.Run("MissingManifestFile", func(t *testing.T) {
|
||||||
data := test.WriteZipArchive(map[string]string{"dummy.txt": ""})
|
data := test.WriteZipArchive(map[string]string{"dummy.txt": ""})
|
||||||
@@ -65,6 +78,77 @@ func TestParsePackage(t *testing.T) {
|
|||||||
assert.Equal(t, content2, m.Content)
|
assert.Equal(t, content2, m.Content)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("IgnoresNestedManifests", func(t *testing.T) {
|
||||||
|
rootManifest := "// swift-tools-version:5.7\n//\n// Package.swift"
|
||||||
|
rootAltManifest := "// swift-tools-version:5.5\n//\n// Package@swift-5.5.swift"
|
||||||
|
rootPatchAltManifest := "// swift-tools-version:5.7.1\n//\n// Package@swift-5.7.1.swift"
|
||||||
|
nestedManifest := "// swift-tools-version:6.3\n//\n// nested fixture package"
|
||||||
|
|
||||||
|
data := writeOrderedZipArchive([][2]string{
|
||||||
|
{"Package.swift", rootManifest},
|
||||||
|
{"Package@swift-5.5.swift", rootAltManifest},
|
||||||
|
{"Package@swift-5.7.1.swift", rootPatchAltManifest},
|
||||||
|
{"Benchmarks/Package.swift", nestedManifest},
|
||||||
|
{"Utils/Fixtures/PlainPackage/Package.swift", nestedManifest},
|
||||||
|
})
|
||||||
|
|
||||||
|
p, err := ParsePackage(bytes.NewReader(data.Bytes()), int64(data.Len()), nil)
|
||||||
|
assert.NotNil(t, p)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Len(t, p.Metadata.Manifests, 3)
|
||||||
|
assert.Equal(t, rootManifest, p.Metadata.Manifests[""].Content)
|
||||||
|
assert.Equal(t, "5.7", p.Metadata.Manifests[""].ToolsVersion)
|
||||||
|
assert.Equal(t, rootAltManifest, p.Metadata.Manifests["5.5"].Content)
|
||||||
|
assert.Equal(t, rootPatchAltManifest, p.Metadata.Manifests["5.7.1"].Content)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("IgnoresNestedManifestsInPrefixedArchive", func(t *testing.T) {
|
||||||
|
rootManifest := "// swift-tools-version:5.7\n//\n// Package.swift"
|
||||||
|
|
||||||
|
// `swift package archive-source` produces archives with a single top level directory
|
||||||
|
data := writeOrderedZipArchive([][2]string{
|
||||||
|
{"gitea-1.0.1/Package.swift", rootManifest},
|
||||||
|
{"gitea-1.0.1/Tests/Fixtures/Package.swift", "// swift-tools-version:6.3"},
|
||||||
|
})
|
||||||
|
|
||||||
|
p, err := ParsePackage(bytes.NewReader(data.Bytes()), int64(data.Len()), nil)
|
||||||
|
assert.NotNil(t, p)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Len(t, p.Metadata.Manifests, 1)
|
||||||
|
assert.Equal(t, rootManifest, p.Metadata.Manifests[""].Content)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AltManifestOnlyInRootDirectory", func(t *testing.T) {
|
||||||
|
// a deeper Package.swift belongs to a nested package and must not stand in for the missing root manifest
|
||||||
|
data := test.WriteZipArchive(map[string]string{
|
||||||
|
"Package@swift-5.5.swift": "// swift-tools-version:5.5",
|
||||||
|
"Sub/Package.swift": "// swift-tools-version:5.7",
|
||||||
|
})
|
||||||
|
|
||||||
|
p, err := ParsePackage(bytes.NewReader(data.Bytes()), int64(data.Len()), nil)
|
||||||
|
assert.Nil(t, p)
|
||||||
|
assert.ErrorIs(t, err, ErrMissingManifestFile)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ManifestDirectoryTieBreak", func(t *testing.T) {
|
||||||
|
contentA := "// swift-tools-version:5.7\n// A"
|
||||||
|
contentB := "// swift-tools-version:5.7\n// B"
|
||||||
|
|
||||||
|
// at equal depth the name decides, never the archive order
|
||||||
|
data := writeOrderedZipArchive([][2]string{
|
||||||
|
{"a/Package.swift", contentA},
|
||||||
|
{"b/Package.swift", contentB},
|
||||||
|
})
|
||||||
|
|
||||||
|
p, err := ParsePackage(bytes.NewReader(data.Bytes()), int64(data.Len()), nil)
|
||||||
|
assert.NotNil(t, p)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, p.Metadata.Manifests, 1)
|
||||||
|
assert.Equal(t, contentA, p.Metadata.Manifests[""].Content)
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("WithMetadata", func(t *testing.T) {
|
t.Run("WithMetadata", func(t *testing.T) {
|
||||||
data := test.WriteZipArchive(map[string]string{
|
data := test.WriteZipArchive(map[string]string{
|
||||||
"Package.swift": "// swift-tools-version:5.7\n//\n// Package.swift",
|
"Package.swift": "// swift-tools-version:5.7\n//\n// Package.swift",
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ var Markdown = struct {
|
|||||||
MathCodeBlockDetection []string
|
MathCodeBlockDetection []string
|
||||||
MathCodeBlockOptions MarkdownMathCodeBlockOptions `ini:"-"`
|
MathCodeBlockOptions MarkdownMathCodeBlockOptions `ini:"-"`
|
||||||
}{
|
}{
|
||||||
EnableMath: true,
|
EnableMath: true,
|
||||||
|
FileNamePatterns: []string{"*.md"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkupRenderer defines the external parser configured in ini
|
// MarkupRenderer defines the external parser configured in ini
|
||||||
|
|||||||
@@ -304,12 +304,10 @@ func (a *AzureBlobStorage) ServeDirectURL(storePath, name, method string, reqPar
|
|||||||
|
|
||||||
// IterateObjects iterates across the objects in the azureblobstorage
|
// IterateObjects iterates across the objects in the azureblobstorage
|
||||||
func (a *AzureBlobStorage) IterateObjects(dirName string, fn func(path string, obj Object) error) error {
|
func (a *AzureBlobStorage) IterateObjects(dirName string, fn func(path string, obj Object) error) error {
|
||||||
dirName = a.buildAzureBlobPath(dirName)
|
basePrefix := buildObjectStorePathPrefix(a.cfg.BasePath, "")
|
||||||
if dirName != "" {
|
dirPrefix := buildObjectStorePathPrefix(a.cfg.BasePath, dirName)
|
||||||
dirName += "/"
|
|
||||||
}
|
|
||||||
pager := a.client.NewListBlobsFlatPager(a.cfg.Container, &container.ListBlobsFlatOptions{
|
pager := a.client.NewListBlobsFlatPager(a.cfg.Container, &container.ListBlobsFlatOptions{
|
||||||
Prefix: &dirName,
|
Prefix: &dirPrefix,
|
||||||
})
|
})
|
||||||
for pager.More() {
|
for pager.More() {
|
||||||
resp, err := pager.NextPage(a.ctx)
|
resp, err := pager.NextPage(a.ctx)
|
||||||
@@ -317,7 +315,8 @@ func (a *AzureBlobStorage) IterateObjects(dirName string, fn func(path string, o
|
|||||||
return convertAzureBlobErr(err)
|
return convertAzureBlobErr(err)
|
||||||
}
|
}
|
||||||
for _, object := range resp.Segment.BlobItems {
|
for _, object := range resp.Segment.BlobItems {
|
||||||
blobClient := a.getBlobClient(*object.Name)
|
objPath := strings.TrimPrefix(*object.Name, basePrefix)
|
||||||
|
blobClient := a.getBlobClient(objPath)
|
||||||
object := &azureBlobObject{
|
object := &azureBlobObject{
|
||||||
Context: a.ctx,
|
Context: a.ctx,
|
||||||
blobClient: blobClient,
|
blobClient: blobClient,
|
||||||
@@ -327,7 +326,7 @@ func (a *AzureBlobStorage) IterateObjects(dirName string, fn func(path string, o
|
|||||||
}
|
}
|
||||||
if err := func(object *azureBlobObject, fn func(path string, obj Object) error) error {
|
if err := func(object *azureBlobObject, fn func(path string, obj Object) error) error {
|
||||||
defer object.Close()
|
defer object.Close()
|
||||||
return fn(strings.TrimPrefix(object.Name, a.cfg.BasePath), object)
|
return fn(objPath, object)
|
||||||
}(object, fn); err != nil {
|
}(object, fn); err != nil {
|
||||||
return convertAzureBlobErr(err)
|
return convertAzureBlobErr(err)
|
||||||
}
|
}
|
||||||
@@ -336,7 +335,6 @@ func (a *AzureBlobStorage) IterateObjects(dirName string, fn func(path string, o
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete delete a file
|
|
||||||
func (a *AzureBlobStorage) getBlobClient(path string) *blob.Client {
|
func (a *AzureBlobStorage) getBlobClient(path string) *blob.Client {
|
||||||
return a.client.ServiceClient().NewContainerClient(a.cfg.Container).NewBlobClient(a.buildAzureBlobPath(path))
|
return a.client.ServiceClient().NewContainerClient(a.cfg.Container).NewBlobClient(a.buildAzureBlobPath(path))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,24 +27,16 @@ func TestAzureBlobStorage(t *testing.T) {
|
|||||||
Container: "test",
|
Container: "test",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
table := []struct {
|
t.Run("Iterator", func(t *testing.T) {
|
||||||
name string
|
testStorageIterator(t, storageType, config)
|
||||||
test func(t *testing.T, typStr Type, cfg *setting.Storage)
|
})
|
||||||
}{
|
t.Run("BlobStorageURLContentTypeAndDisposition", func(t *testing.T) {
|
||||||
{
|
testBlobStorageURLContentTypeAndDisposition(t, storageType, config)
|
||||||
name: "iterator",
|
})
|
||||||
test: testStorageIterator,
|
t.Run("IteratorWithBasePath", func(t *testing.T) {
|
||||||
},
|
config.AzureBlobConfig.BasePath = "test-base-path"
|
||||||
{
|
testStorageIterator(t, storageType, config)
|
||||||
name: "testBlobStorageURLContentTypeAndDisposition",
|
})
|
||||||
test: testBlobStorageURLContentTypeAndDisposition,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, entry := range table {
|
|
||||||
t.Run(entry.name, func(t *testing.T) {
|
|
||||||
entry.test(t, storageType, config)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAzureBlobStoragePath(t *testing.T) {
|
func TestAzureBlobStoragePath(t *testing.T) {
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import (
|
|||||||
|
|
||||||
var _ ObjectStorage = &MinioStorage{}
|
var _ ObjectStorage = &MinioStorage{}
|
||||||
|
|
||||||
|
const unknownSizePartSize = 1024 * 1024 * 16 // same as minio-go's minPartSize
|
||||||
|
|
||||||
type minioObject struct {
|
type minioObject struct {
|
||||||
*minio.Object
|
*minio.Object
|
||||||
}
|
}
|
||||||
@@ -211,6 +213,10 @@ func (m *MinioStorage) Save(path string, r io.Reader, size int64) (int64, error)
|
|||||||
// * https://www.backblaze.com/b2/docs/s3_compatible_api.html
|
// * https://www.backblaze.com/b2/docs/s3_compatible_api.html
|
||||||
// do not support "x-amz-checksum-algorithm" header, so use legacy MD5 checksum
|
// do not support "x-amz-checksum-algorithm" header, so use legacy MD5 checksum
|
||||||
SendContentMd5: m.cfg.ChecksumAlgorithm == "md5",
|
SendContentMd5: m.cfg.ChecksumAlgorithm == "md5",
|
||||||
|
|
||||||
|
// with an unknown size (-1) minio-go assumes a 5TiB object and buffers a 528MiB part for it, even
|
||||||
|
// for a payload of a few KiB, so pin the part size there, a known size derives its own
|
||||||
|
PartSize: util.Iif[uint64](size < 0, unknownSizePartSize, 0),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.dev/modules/httplib"
|
"gitea.dev/modules/httplib"
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
"gitea.dev/modules/public"
|
"gitea.dev/modules/public"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
|
"gitea.dev/modules/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrURLNotSupported represents url is not supported
|
// ErrURLNotSupported represents url is not supported
|
||||||
@@ -139,6 +141,23 @@ func SaveFrom(objStorage ObjectStorage, path string, callback func(w io.Writer)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildObjectStorePath(base, p string) string {
|
||||||
|
p = strings.TrimPrefix(util.PathJoinRelX(base, p), "/") // object store doesn't use slash for root path
|
||||||
|
if p == "." {
|
||||||
|
p = "" // object store doesn't use dot as relative path
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildObjectStorePathPrefix(base, p string) string {
|
||||||
|
// ending slash is required for avoiding matching like "foo/" and "foobar/" with prefix "foo"
|
||||||
|
p = buildObjectStorePath(base, p) + "/"
|
||||||
|
if p == "/" {
|
||||||
|
p = "" // object store doesn't use slash for root path
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// Attachments represents attachments storage
|
// Attachments represents attachments storage
|
||||||
Attachments ObjectStorage = uninitializedStorage
|
Attachments ObjectStorage = uninitializedStorage
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package storage
|
package storage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -31,6 +32,11 @@ func testStorageIterator(t *testing.T, typStr Type, cfg *setting.Storage) {
|
|||||||
_, err = l.Save(f[0], strings.NewReader(f[1]), -1)
|
_, err = l.Save(f[0], strings.NewReader(f[1]), -1)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
defer func() {
|
||||||
|
for _, f := range testFiles {
|
||||||
|
_ = l.Delete(f[0])
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
expectedList := map[string][]string{
|
expectedList := map[string][]string{
|
||||||
"a": {"a/1.txt"},
|
"a": {"a/1.txt"},
|
||||||
@@ -43,7 +49,9 @@ func testStorageIterator(t *testing.T, typStr Type, cfg *setting.Storage) {
|
|||||||
for dir, expected := range expectedList {
|
for dir, expected := range expectedList {
|
||||||
count := 0
|
count := 0
|
||||||
err = l.IterateObjects(dir, func(path string, f Object) error {
|
err = l.IterateObjects(dir, func(path string, f Object) error {
|
||||||
defer f.Close()
|
content, err := io.ReadAll(f)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, content)
|
||||||
assert.Contains(t, expected, path)
|
assert.Contains(t, expected, path)
|
||||||
count++
|
count++
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -370,7 +370,7 @@ func (ut *RenderUtils) AvatarStackPushCommit(pushCommit *repository.PushCommit)
|
|||||||
// there is no way to know the real committer, but the field can't be nil
|
// there is no way to know the real committer, but the field can't be nil
|
||||||
Committer: &git.Signature{Name: pushCommit.AuthorName, Email: pushCommit.AuthorEmail},
|
Committer: &git.Signature{Name: pushCommit.AuthorName, Email: pushCommit.AuthorEmail},
|
||||||
}
|
}
|
||||||
data := user_model.BuildAvatarStackData(ut.ctx, fakeGitCommit.AllParticipantIdentities(), nil)
|
data := user_model.BuildAvatarStackData(ut.ctx, fakeGitCommit.AllAuthorIdentities(), nil)
|
||||||
return ut.AvatarStack(data)
|
return ut.AvatarStack(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ func IsEmptyString(s string) bool {
|
|||||||
return len(strings.TrimSpace(s)) == 0
|
return len(strings.TrimSpace(s)) == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParseYamlBool parses YAML 1.2 boolean values into bool
|
||||||
|
func ParseYamlBool(s string) bool {
|
||||||
|
return s == "true" || s == "True" || s == "TRUE"
|
||||||
|
}
|
||||||
|
|
||||||
// NormalizeEOL will convert Windows (CRLF) and Mac (CR) EOLs to UNIX (LF)
|
// NormalizeEOL will convert Windows (CRLF) and Mac (CR) EOLs to UNIX (LF)
|
||||||
func NormalizeEOL(input []byte) []byte {
|
func NormalizeEOL(input []byte) []byte {
|
||||||
var right, left, pos int
|
var right, left, pos int
|
||||||
|
|||||||
+1
-1
@@ -52,7 +52,7 @@
|
|||||||
"jquery": "4.0.0",
|
"jquery": "4.0.0",
|
||||||
"js-yaml": "4.2.0",
|
"js-yaml": "4.2.0",
|
||||||
"katex": "0.17.0",
|
"katex": "0.17.0",
|
||||||
"mermaid": "11.15.0",
|
"mermaid": "11.16.1",
|
||||||
"online-3d-viewer": "0.18.0",
|
"online-3d-viewer": "0.18.0",
|
||||||
"pdfobject": "2.3.1",
|
"pdfobject": "2.3.1",
|
||||||
"perfect-debounce": "2.1.0",
|
"perfect-debounce": "2.1.0",
|
||||||
|
|||||||
Generated
+12
-12
@@ -73,7 +73,7 @@ importers:
|
|||||||
version: 0.1.0-rc2
|
version: 0.1.0-rc2
|
||||||
'@mermaid-js/layout-elk':
|
'@mermaid-js/layout-elk':
|
||||||
specifier: 0.2.1
|
specifier: 0.2.1
|
||||||
version: 0.2.1(mermaid@11.15.0)
|
version: 0.2.1(mermaid@11.16.1)
|
||||||
'@primer/octicons':
|
'@primer/octicons':
|
||||||
specifier: 19.28.1
|
specifier: 19.28.1
|
||||||
version: 19.28.1
|
version: 19.28.1
|
||||||
@@ -147,8 +147,8 @@ importers:
|
|||||||
specifier: 0.17.0
|
specifier: 0.17.0
|
||||||
version: 0.17.0
|
version: 0.17.0
|
||||||
mermaid:
|
mermaid:
|
||||||
specifier: 11.15.0
|
specifier: 11.16.1
|
||||||
version: 11.15.0
|
version: 11.16.1
|
||||||
online-3d-viewer:
|
online-3d-viewer:
|
||||||
specifier: 0.18.0
|
specifier: 0.18.0
|
||||||
version: 0.18.0
|
version: 0.18.0
|
||||||
@@ -942,8 +942,8 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
mermaid: ^11.0.2
|
mermaid: ^11.0.2
|
||||||
|
|
||||||
'@mermaid-js/parser@1.1.1':
|
'@mermaid-js/parser@1.2.0':
|
||||||
resolution: {integrity: sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==}
|
resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==}
|
||||||
|
|
||||||
'@napi-rs/wasm-runtime@1.1.6':
|
'@napi-rs/wasm-runtime@1.1.6':
|
||||||
resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
|
resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
|
||||||
@@ -3301,8 +3301,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
mermaid@11.15.0:
|
mermaid@11.16.1:
|
||||||
resolution: {integrity: sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==}
|
resolution: {integrity: sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g==}
|
||||||
|
|
||||||
micromark-core-commonmark@2.0.3:
|
micromark-core-commonmark@2.0.3:
|
||||||
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
|
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
|
||||||
@@ -5179,13 +5179,13 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- utf-8-validate
|
- utf-8-validate
|
||||||
|
|
||||||
'@mermaid-js/layout-elk@0.2.1(mermaid@11.15.0)':
|
'@mermaid-js/layout-elk@0.2.1(mermaid@11.16.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
d3: 7.9.0
|
d3: 7.9.0
|
||||||
elkjs: 0.9.3
|
elkjs: 0.9.3
|
||||||
mermaid: 11.15.0
|
mermaid: 11.16.1
|
||||||
|
|
||||||
'@mermaid-js/parser@1.1.1':
|
'@mermaid-js/parser@1.2.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@chevrotain/types': 11.1.2
|
'@chevrotain/types': 11.1.2
|
||||||
|
|
||||||
@@ -7764,11 +7764,11 @@ snapshots:
|
|||||||
|
|
||||||
merge2@1.4.1: {}
|
merge2@1.4.1: {}
|
||||||
|
|
||||||
mermaid@11.15.0:
|
mermaid@11.16.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@braintree/sanitize-url': 7.1.2
|
'@braintree/sanitize-url': 7.1.2
|
||||||
'@iconify/utils': 3.1.3
|
'@iconify/utils': 3.1.3
|
||||||
'@mermaid-js/parser': 1.1.1
|
'@mermaid-js/parser': 1.2.0
|
||||||
'@types/d3': 7.4.3
|
'@types/d3': 7.4.3
|
||||||
'@upsetjs/venn.js': 2.0.0
|
'@upsetjs/venn.js': 2.0.0
|
||||||
cytoscape: 3.33.4
|
cytoscape: 3.33.4
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -74,7 +75,6 @@ import (
|
|||||||
"gitea.dev/modules/httplib"
|
"gitea.dev/modules/httplib"
|
||||||
"gitea.dev/modules/json"
|
"gitea.dev/modules/json"
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
"gitea.dev/modules/optional"
|
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/storage"
|
"gitea.dev/modules/storage"
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
@@ -336,15 +336,19 @@ type (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (ar artifactRoutes) listArtifacts(ctx *ArtifactContext) {
|
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 {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{
|
artifacts, err := actions.FindReadableArtifacts(ctx, actions.FindArtifactsOptions{
|
||||||
RunID: runID,
|
RunID: runID,
|
||||||
RunAttemptID: optional.Some(ctx.ActionTask.Job.RunAttemptID),
|
RunAttemptIDs: attemptIDs,
|
||||||
Status: int(actions.ArtifactStatusUploadConfirmed),
|
Status: int(actions.ArtifactStatusUploadConfirmed),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Error getting artifacts: %v", err)
|
log.Error("Error getting artifacts: %v", err)
|
||||||
@@ -397,7 +401,7 @@ type (
|
|||||||
|
|
||||||
// getDownloadArtifactURL generates download url for each artifact
|
// getDownloadArtifactURL generates download url for each artifact
|
||||||
func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) {
|
func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) {
|
||||||
_, runID, ok := validateRunID(ctx)
|
task, runID, ok := validateRunID(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -407,11 +411,16 @@ func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{
|
attemptIDs, ok := readableArtifactAttemptIDs(ctx, task)
|
||||||
RunID: runID,
|
if !ok {
|
||||||
RunAttemptID: optional.Some(ctx.ActionTask.Job.RunAttemptID),
|
return
|
||||||
ArtifactName: itemPath,
|
}
|
||||||
Status: int(actions.ArtifactStatusUploadConfirmed),
|
|
||||||
|
artifacts, err := actions.FindReadableArtifacts(ctx, actions.FindArtifactsOptions{
|
||||||
|
RunID: runID,
|
||||||
|
RunAttemptIDs: attemptIDs,
|
||||||
|
ArtifactName: itemPath,
|
||||||
|
Status: int(actions.ArtifactStatusUploadConfirmed),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Error getting artifacts: %v", err)
|
log.Error("Error getting artifacts: %v", err)
|
||||||
@@ -461,7 +470,7 @@ func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) {
|
|||||||
|
|
||||||
// downloadArtifact downloads artifact content
|
// downloadArtifact downloads artifact content
|
||||||
func (ar artifactRoutes) downloadArtifact(ctx *ArtifactContext) {
|
func (ar artifactRoutes) downloadArtifact(ctx *ArtifactContext) {
|
||||||
_, runID, ok := validateRunID(ctx)
|
task, runID, ok := validateRunID(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -483,10 +492,17 @@ func (ar artifactRoutes) downloadArtifact(ctx *ArtifactContext) {
|
|||||||
ctx.HTTPError(http.StatusBadRequest)
|
ctx.HTTPError(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if ctx.ActionTask.Job.RunAttemptID > 0 && artifact.RunAttemptID != ctx.ActionTask.Job.RunAttemptID {
|
// resolving the readable attempts costs a query, and an artifact of the task's own attempt never needs it
|
||||||
log.Error("Error mismatch runAttemptID and artifactID, task: %v, artifact: %v", ctx.ActionTask.Job.RunAttemptID, artifactID)
|
if artifact.RunAttemptID != task.Job.RunAttemptID {
|
||||||
ctx.HTTPError(http.StatusBadRequest)
|
attemptIDs, ok := readableArtifactAttemptIDs(ctx, task)
|
||||||
return
|
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 {
|
if artifact.Status != actions.ArtifactStatusUploadConfirmed {
|
||||||
log.Error("Error artifact not found: %s", artifact.Status.ToString())
|
log.Error("Error artifact not found: %s", artifact.Status.ToString())
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import (
|
|||||||
"gitea.dev/models/actions"
|
"gitea.dev/models/actions"
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
"gitea.dev/modules/optional"
|
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/storage"
|
"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 {
|
func mergeChunksForRun(ctx *ArtifactContext, st storage.ObjectStorage, runID, runAttemptID int64, artifactName string) error {
|
||||||
// read all db artifacts by name
|
// read all db artifacts by name
|
||||||
artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{
|
artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{
|
||||||
RunID: runID,
|
RunID: runID,
|
||||||
RunAttemptID: optional.Some(runAttemptID),
|
RunAttemptIDs: []int64{runAttemptID},
|
||||||
ArtifactName: artifactName,
|
ArtifactName: artifactName,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func validateRunID(ctx *ArtifactContext) (*actions.ActionTask, int64, bool) {
|
|||||||
return task, runID, true
|
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
|
task := ctx.ActionTask
|
||||||
runID, err := strconv.ParseInt(rawRunID, 10, 64)
|
runID, err := strconv.ParseInt(rawRunID, 10, 64)
|
||||||
if err != nil || task.Job.RunID != runID {
|
if err != nil || task.Job.RunID != runID {
|
||||||
@@ -54,6 +54,18 @@ func validateRunIDV4(ctx *ArtifactContext, rawRunID string) (*actions.ActionTask
|
|||||||
return task, runID, true
|
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 {
|
func validateArtifactHash(ctx *ArtifactContext, artifactName string) bool {
|
||||||
paramHash := ctx.PathParam("artifact_hash")
|
paramHash := ctx.PathParam("artifact_hash")
|
||||||
// use artifact name to create upload url
|
// use artifact name to create upload url
|
||||||
|
|||||||
@@ -107,7 +107,6 @@ import (
|
|||||||
actions_module "gitea.dev/modules/actions"
|
actions_module "gitea.dev/modules/actions"
|
||||||
"gitea.dev/modules/httplib"
|
"gitea.dev/modules/httplib"
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
"gitea.dev/modules/optional"
|
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/storage"
|
"gitea.dev/modules/storage"
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
@@ -262,9 +261,28 @@ func (r *artifactV4Routes) verifySignature(ctx *ArtifactContext, endp string) (*
|
|||||||
return task, artifactName, true
|
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
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
} else if !has {
|
} else if !has {
|
||||||
@@ -384,7 +402,7 @@ func (r *artifactV4Routes) uploadArtifact(ctx *ArtifactContext) {
|
|||||||
switch comp {
|
switch comp {
|
||||||
case "block", "appendBlock":
|
case "block", "appendBlock":
|
||||||
// get artifact by name
|
// 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 {
|
if err != nil {
|
||||||
log.Error("Error artifact not found: %v", err)
|
log.Error("Error artifact not found: %v", err)
|
||||||
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
||||||
@@ -471,7 +489,7 @@ func (r *artifactV4Routes) finalizeArtifact(ctx *ArtifactContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// get artifact by name
|
// 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 {
|
if err != nil {
|
||||||
log.Error("Error artifact not found: %v", err)
|
log.Error("Error artifact not found: %v", err)
|
||||||
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
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 {
|
if ok := r.parseProtobufBody(ctx, &req); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
|
task, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
attemptIDs, ok := readableArtifactAttemptIDs(ctx, task)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{
|
artifacts, err := actions_model.FindReadableArtifacts(ctx, actions_model.FindArtifactsOptions{
|
||||||
RunID: runID,
|
RunID: runID,
|
||||||
RunAttemptID: optional.Some(ctx.ActionTask.Job.RunAttemptID),
|
RunAttemptIDs: attemptIDs,
|
||||||
Status: int(actions_model.ArtifactStatusUploadConfirmed),
|
Status: int(actions_model.ArtifactStatusUploadConfirmed),
|
||||||
FinalizedArtifactsV4: true,
|
FinalizedArtifactsV4: true,
|
||||||
})
|
})
|
||||||
@@ -597,6 +619,8 @@ func (r *artifactV4Routes) listArtifacts(ctx *ArtifactContext) {
|
|||||||
|
|
||||||
list := []*ListArtifactsResponse_MonolithArtifact{}
|
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{}
|
table := map[string]*ListArtifactsResponse_MonolithArtifact{}
|
||||||
for _, artifact := range artifacts {
|
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 {
|
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 {
|
if ok := r.parseProtobufBody(ctx, &req); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
|
task, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
attemptIDs, ok := readableArtifactAttemptIDs(ctx, task)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -639,17 +667,12 @@ func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) {
|
|||||||
artifactName := req.Name
|
artifactName := req.Name
|
||||||
|
|
||||||
// get artifact by 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 {
|
if err != nil {
|
||||||
log.Error("Error artifact not found: %v", err)
|
log.Error("Error artifact not found: %v", err)
|
||||||
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
||||||
return
|
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{}
|
respData := GetSignedArtifactURLResponse{}
|
||||||
|
|
||||||
@@ -671,16 +694,15 @@ func (r *artifactV4Routes) downloadArtifact(ctx *ArtifactContext) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
attemptIDs, ok := readableArtifactAttemptIDs(ctx, task)
|
||||||
// get artifact by name
|
if !ok {
|
||||||
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")
|
|
||||||
return
|
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")
|
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -704,7 +726,7 @@ func (r *artifactV4Routes) deleteArtifact(ctx *ArtifactContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// get artifact by name
|
// 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 {
|
if err != nil {
|
||||||
log.Error("Error artifact not found: %v", err)
|
log.Error("Error artifact not found: %v", err)
|
||||||
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ func CommonRoutes() *web.Router {
|
|||||||
r.Group("/{branch}/{repository}", func() {
|
r.Group("/{branch}/{repository}", func() {
|
||||||
r.Put("", reqPackageAccess(perm.AccessModeWrite), alpine.UploadPackageFile)
|
r.Put("", reqPackageAccess(perm.AccessModeWrite), alpine.UploadPackageFile)
|
||||||
r.Group("/{architecture}", func() {
|
r.Group("/{architecture}", func() {
|
||||||
r.Get("/APKINDEX.tar.gz", alpine.GetRepositoryFile)
|
r.Methods("HEAD,GET", "/APKINDEX.tar.gz", alpine.GetRepositoryFile)
|
||||||
r.Group("/{filename}", func() {
|
r.Group("/{filename}", func() {
|
||||||
r.Get("", alpine.DownloadPackageFile)
|
r.Get("", alpine.DownloadPackageFile)
|
||||||
r.Delete("", reqPackageAccess(perm.AccessModeWrite), alpine.DeletePackageFile)
|
r.Delete("", reqPackageAccess(perm.AccessModeWrite), alpine.DeletePackageFile)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"sort"
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
packages_model "gitea.dev/models/packages"
|
packages_model "gitea.dev/models/packages"
|
||||||
npm_module "gitea.dev/modules/packages/npm"
|
npm_module "gitea.dev/modules/packages/npm"
|
||||||
@@ -22,8 +23,14 @@ func createPackageMetadataResponse(registryURL string, pds []*packages_model.Pac
|
|||||||
|
|
||||||
versions := make(map[string]*npm_module.PackageMetadataVersion)
|
versions := make(map[string]*npm_module.PackageMetadataVersion)
|
||||||
distTags := make(map[string]string)
|
distTags := make(map[string]string)
|
||||||
|
times := make(map[string]time.Time)
|
||||||
|
firstPublished, lastPublished := pds[0].Version.CreatedUnix, pds[0].Version.CreatedUnix
|
||||||
for _, pd := range pds {
|
for _, pd := range pds {
|
||||||
versions[pd.SemVer.String()] = createPackageMetadataVersion(registryURL, pd)
|
semVer := pd.SemVer.String()
|
||||||
|
versions[semVer] = createPackageMetadataVersion(registryURL, pd)
|
||||||
|
times[semVer] = pd.Version.CreatedUnix.AsTimeInLocation(time.UTC)
|
||||||
|
firstPublished = min(firstPublished, pd.Version.CreatedUnix)
|
||||||
|
lastPublished = max(lastPublished, pd.Version.CreatedUnix)
|
||||||
|
|
||||||
for _, pvp := range pd.VersionProperties {
|
for _, pvp := range pd.VersionProperties {
|
||||||
if pvp.Name == npm_module.TagProperty {
|
if pvp.Name == npm_module.TagProperty {
|
||||||
@@ -32,6 +39,10 @@ func createPackageMetadataResponse(registryURL string, pds []*packages_model.Pac
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// npm derives both from the versions currently served, so a deletion moves them
|
||||||
|
times["created"] = firstPublished.AsTimeInLocation(time.UTC)
|
||||||
|
times["modified"] = lastPublished.AsTimeInLocation(time.UTC)
|
||||||
|
|
||||||
latest := pds[len(pds)-1]
|
latest := pds[len(pds)-1]
|
||||||
|
|
||||||
metadata := latest.Metadata.(*npm_module.Metadata)
|
metadata := latest.Metadata.(*npm_module.Metadata)
|
||||||
@@ -42,7 +53,10 @@ func createPackageMetadataResponse(registryURL string, pds []*packages_model.Pac
|
|||||||
DistTags: distTags,
|
DistTags: distTags,
|
||||||
Description: metadata.Description,
|
Description: metadata.Description,
|
||||||
Readme: metadata.Readme,
|
Readme: metadata.Readme,
|
||||||
|
Maintainers: []npm_module.User{{Name: latest.Owner.Name}},
|
||||||
|
Time: times,
|
||||||
Homepage: metadata.ProjectURL,
|
Homepage: metadata.ProjectURL,
|
||||||
|
Keywords: metadata.Keywords,
|
||||||
Author: npm_module.User{Name: metadata.Author},
|
Author: npm_module.User{Name: metadata.Author},
|
||||||
License: metadata.License,
|
License: metadata.License,
|
||||||
Versions: versions,
|
Versions: versions,
|
||||||
@@ -61,8 +75,10 @@ func createPackageMetadataVersion(registryURL string, pd *packages_model.Package
|
|||||||
Version: pd.Version.Version,
|
Version: pd.Version.Version,
|
||||||
Description: metadata.Description,
|
Description: metadata.Description,
|
||||||
Author: npm_module.User{Name: metadata.Author},
|
Author: npm_module.User{Name: metadata.Author},
|
||||||
|
Maintainers: []npm_module.User{{Name: pd.Owner.Name}},
|
||||||
Homepage: metadata.ProjectURL,
|
Homepage: metadata.ProjectURL,
|
||||||
License: metadata.License,
|
License: metadata.License,
|
||||||
|
Keywords: metadata.Keywords,
|
||||||
Dependencies: metadata.Dependencies,
|
Dependencies: metadata.Dependencies,
|
||||||
BundleDependencies: metadata.BundleDependencies,
|
BundleDependencies: metadata.BundleDependencies,
|
||||||
DevDependencies: metadata.DevelopmentDependencies,
|
DevDependencies: metadata.DevelopmentDependencies,
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package npm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
packages_model "gitea.dev/models/packages"
|
||||||
|
user_model "gitea.dev/models/user"
|
||||||
|
npm_module "gitea.dev/modules/packages/npm"
|
||||||
|
"gitea.dev/modules/timeutil"
|
||||||
|
|
||||||
|
"github.com/hashicorp/go-version"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreatePackageMetadataResponse(t *testing.T) {
|
||||||
|
descriptor := func(v string, publishedUnix int64) *packages_model.PackageDescriptor {
|
||||||
|
return &packages_model.PackageDescriptor{
|
||||||
|
Package: &packages_model.Package{Name: "test"},
|
||||||
|
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"}},
|
||||||
|
Files: []*packages_model.PackageFileDescriptor{{File: &packages_model.PackageFile{}, Blob: &packages_model.PackageBlob{}}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := createPackageMetadataResponse("https://gitea.dev/api/packages/alice/npm", []*packages_model.PackageDescriptor{
|
||||||
|
descriptor("1.1.0", 1000),
|
||||||
|
descriptor("1.0.0", 2000),
|
||||||
|
})
|
||||||
|
|
||||||
|
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(),
|
||||||
|
}, result.Time)
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -4,8 +4,9 @@
|
|||||||
package misc
|
package misc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"gitea.dev/modules/markup"
|
"io"
|
||||||
"gitea.dev/modules/markup/markdown"
|
|
||||||
|
"gitea.dev/modules/setting"
|
||||||
api "gitea.dev/modules/structs"
|
api "gitea.dev/modules/structs"
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
@@ -84,9 +85,6 @@ func MarkdownRaw(ctx *context.APIContext) {
|
|||||||
// "$ref": "#/responses/MarkdownRender"
|
// "$ref": "#/responses/MarkdownRender"
|
||||||
// "422":
|
// "422":
|
||||||
// "$ref": "#/responses/validationError"
|
// "$ref": "#/responses/validationError"
|
||||||
defer ctx.Req.Body.Close()
|
textBytes, _ := io.ReadAll(io.LimitReader(ctx.Req.Body, setting.UI.MaxDisplayFileSize))
|
||||||
if err := markdown.RenderRaw(markup.NewRenderContext(ctx), ctx.Req.Body, ctx.Resp); err != nil {
|
common.RenderMarkup(ctx.Base, ctx.Repo, "markdown", util.UnsafeBytesToString(textBytes), "", "")
|
||||||
ctx.APIErrorInternal(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,19 +7,15 @@ import (
|
|||||||
go_context "context"
|
go_context "context"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
repo_model "gitea.dev/models/repo"
|
|
||||||
"gitea.dev/models/unittest"
|
|
||||||
"gitea.dev/modules/markup"
|
"gitea.dev/modules/markup"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
api "gitea.dev/modules/structs"
|
api "gitea.dev/modules/structs"
|
||||||
"gitea.dev/modules/test"
|
"gitea.dev/modules/test"
|
||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
context_service "gitea.dev/services/context"
|
|
||||||
"gitea.dev/services/contexttest"
|
"gitea.dev/services/contexttest"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -27,13 +23,6 @@ import (
|
|||||||
|
|
||||||
const AppURL = "http://localhost:3000/"
|
const AppURL = "http://localhost:3000/"
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
|
||||||
unittest.MainTest(m, &unittest.TestOptions{
|
|
||||||
FixtureFiles: []string{"repository.yml", "user.yml"},
|
|
||||||
})
|
|
||||||
os.Exit(m.Run())
|
|
||||||
}
|
|
||||||
|
|
||||||
func testRenderMarkup(t *testing.T, mode string, wiki bool, filePath, text, expectedBody string, expectedCode int) {
|
func testRenderMarkup(t *testing.T, mode string, wiki bool, filePath, text, expectedBody string, expectedCode int) {
|
||||||
setting.AppURL = AppURL
|
setting.AppURL = AppURL
|
||||||
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
||||||
@@ -49,13 +38,11 @@ func testRenderMarkup(t *testing.T, mode string, wiki bool, filePath, text, expe
|
|||||||
FilePath: filePath,
|
FilePath: filePath,
|
||||||
}
|
}
|
||||||
ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markup")
|
ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markup")
|
||||||
ctx.Repo = &context_service.Repository{}
|
|
||||||
ctx.Repo.Repository = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
|
||||||
web.SetForm(ctx, &options)
|
web.SetForm(ctx, &options)
|
||||||
Markup(ctx)
|
Markup(ctx)
|
||||||
assert.Equal(t, expectedBody, resp.Body.String())
|
assert.Equal(t, expectedBody, resp.Body.String())
|
||||||
assert.Equal(t, expectedCode, resp.Code)
|
assert.Equal(t, expectedCode, resp.Code)
|
||||||
resp.Body.Reset()
|
assert.Contains(t, resp.Header().Get("Content-Security-Policy"), "script-src * 'nonce-")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testRenderMarkdown(t *testing.T, mode string, wiki bool, text, responseBody string, responseCode int) {
|
func testRenderMarkdown(t *testing.T, mode string, wiki bool, text, responseBody string, responseCode int) {
|
||||||
@@ -76,11 +63,10 @@ func testRenderMarkdown(t *testing.T, mode string, wiki bool, text, responseBody
|
|||||||
Markdown(ctx)
|
Markdown(ctx)
|
||||||
assert.Equal(t, responseBody, resp.Body.String())
|
assert.Equal(t, responseBody, resp.Body.String())
|
||||||
assert.Equal(t, responseCode, resp.Code)
|
assert.Equal(t, responseCode, resp.Code)
|
||||||
resp.Body.Reset()
|
assert.Contains(t, resp.Header().Get("Content-Security-Policy"), "script-src * 'nonce-")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAPI_RenderGFM(t *testing.T) {
|
func TestAPI_RenderGFM(t *testing.T) {
|
||||||
unittest.PrepareTestEnv(t)
|
|
||||||
markup.Init(&markup.RenderHelperFuncs{
|
markup.Init(&markup.RenderHelperFuncs{
|
||||||
IsUsernameMentionable: func(ctx go_context.Context, username string) bool {
|
IsUsernameMentionable: func(ctx go_context.Context, username string) bool {
|
||||||
return username == "r-lyeh"
|
return username == "r-lyeh"
|
||||||
@@ -177,49 +163,34 @@ Here are some links to the most important topics. You can find the full list of
|
|||||||
testRenderMarkup(t, "unknown", false, "", "## Test", "unsupported render mode: unknown\n", http.StatusUnprocessableEntity)
|
testRenderMarkup(t, "unknown", false, "", "## Test", "unsupported render mode: unknown\n", http.StatusUnprocessableEntity)
|
||||||
}
|
}
|
||||||
|
|
||||||
var simpleCases = []string{
|
|
||||||
// Guard wiki sidebar: special syntax
|
|
||||||
`[[Guardfile-DSL / Configuring-Guard|Guardfile-DSL---Configuring-Guard]]`,
|
|
||||||
// rendered
|
|
||||||
`<p>[[Guardfile-DSL / Configuring-Guard|Guardfile-DSL---Configuring-Guard]]</p>
|
|
||||||
`,
|
|
||||||
// special syntax
|
|
||||||
`[[Name|Link]]`,
|
|
||||||
// rendered
|
|
||||||
`<p>[[Name|Link]]</p>
|
|
||||||
`,
|
|
||||||
// empty
|
|
||||||
``,
|
|
||||||
// rendered
|
|
||||||
``,
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPI_RenderSimple(t *testing.T) {
|
func TestAPI_RenderSimple(t *testing.T) {
|
||||||
setting.AppURL = AppURL
|
setting.AppURL = AppURL
|
||||||
markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true
|
markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true
|
||||||
options := api.MarkdownOption{
|
|
||||||
Mode: "markdown",
|
|
||||||
Text: "",
|
|
||||||
Context: "/user2/repo1",
|
|
||||||
}
|
|
||||||
ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markdown")
|
|
||||||
for i := 0; i < len(simpleCases); i += 2 {
|
|
||||||
options.Text = simpleCases[i]
|
|
||||||
web.SetForm(ctx, &options)
|
|
||||||
Markdown(ctx)
|
|
||||||
assert.Equal(t, simpleCases[i+1], resp.Body.String())
|
|
||||||
resp.Body.Reset()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPI_RenderRaw(t *testing.T) {
|
testCases := []struct {
|
||||||
setting.AppURL = AppURL
|
in, out string
|
||||||
markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true
|
mode string
|
||||||
ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markdown")
|
}{
|
||||||
for i := 0; i < len(simpleCases); i += 2 {
|
{in: "", out: ""},
|
||||||
ctx.Req.Body = io.NopCloser(strings.NewReader(simpleCases[i]))
|
{in: "[[special-syntax]]", out: "<p>[[special-syntax]]</p>\n", mode: "markdown"},
|
||||||
MarkdownRaw(ctx)
|
{in: "[[special|syntax]]", out: "<p>[[special|syntax]]</p>\n", mode: "markdown"},
|
||||||
assert.Equal(t, simpleCases[i+1], resp.Body.String())
|
{in: "01234567890123456789", out: "<p>01234567890123456789</p>\n", mode: "gfm"}, // commit-like content should not crash the render
|
||||||
resp.Body.Reset()
|
|
||||||
}
|
}
|
||||||
|
t.Run("markdown", func(t *testing.T) {
|
||||||
|
for _, c := range testCases {
|
||||||
|
options := api.MarkdownOption{Mode: c.mode, Text: c.in, Context: "/user2/repo1"}
|
||||||
|
ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markdown")
|
||||||
|
web.SetForm(ctx, &options)
|
||||||
|
Markdown(ctx)
|
||||||
|
assert.Equal(t, c.out, resp.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("markdown-raw", func(t *testing.T) {
|
||||||
|
for _, c := range testCases {
|
||||||
|
ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markdown")
|
||||||
|
ctx.Req.Body = io.NopCloser(strings.NewReader(c.in))
|
||||||
|
MarkdownRaw(ctx)
|
||||||
|
assert.Equal(t, c.out, resp.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ func renderServerErrorPage(w http.ResponseWriter, req *http.Request, respCode in
|
|||||||
if acceptsHTML {
|
if acceptsHTML {
|
||||||
err := templates.PageRenderer().HTML(outBuf, respCode, tmpl, ctxData, tmplCtx)
|
err := templates.PageRenderer().HTML(outBuf, respCode, tmpl, ctxData, tmplCtx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Error("Failed to render error page template %s: %v", tmpl, err)
|
||||||
_, _ = w.Write([]byte("Internal server error but failed to render error page template, please collect error logs and report to Gitea issue tracker"))
|
_, _ = w.Write([]byte("Internal server error but failed to render error page template, please collect error logs and report to Gitea issue tracker"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ func RenderMarkup(ctx *context.Base, ctxRepo *context.Repository, mode, text, ur
|
|||||||
// for example, when previewing file "/gitea/owner/repo/src/branch/features/feat-123/doc/CHANGE.md", then filePath is "doc/CHANGE.md"
|
// for example, when previewing file "/gitea/owner/repo/src/branch/features/feat-123/doc/CHANGE.md", then filePath is "doc/CHANGE.md"
|
||||||
// and the urlPathContext is "/gitea/owner/repo/src/branch/features/feat-123/doc"
|
// and the urlPathContext is "/gitea/owner/repo/src/branch/features/feat-123/doc"
|
||||||
|
|
||||||
|
ctx.SetHeaderContentSecurityPolicyGeneral()
|
||||||
|
|
||||||
if mode == "" || mode == "markdown" {
|
if mode == "" || mode == "markdown" {
|
||||||
// raw Markdown doesn't do any special handling
|
// raw Markdown doesn't do any special handling
|
||||||
// TODO: raw markdown doesn't do any link processing, so "urlPathContext" doesn't take effect
|
// TODO: raw markdown doesn't do any link processing, so "urlPathContext" doesn't take effect
|
||||||
@@ -62,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"
|
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.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 = 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 {
|
} else if fields = strings.SplitN(repoLinkPath, "/", 3); len(fields) == 2 {
|
||||||
repoOwnerName, repoName = fields[0], fields[1]
|
repoOwnerName, repoName = fields[0], fields[1]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,12 +73,9 @@ func TwoFactorPost(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if ctx.Session.Get("linkAccount") != nil {
|
if err = completePendingLinks(ctx, u); err != nil {
|
||||||
err = linkAccountFromContext(ctx, u)
|
ctx.ServerError("completePendingLinks", err)
|
||||||
if err != nil {
|
return
|
||||||
ctx.ServerError("UserSignIn", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true)
|
_ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true)
|
||||||
@@ -145,6 +142,11 @@ func TwoFactorScratchPost(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err = completePendingLinks(ctx, u); err != nil {
|
||||||
|
ctx.ServerError("completePendingLinks", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
handleSignInFull(ctx, u, remember)
|
handleSignInFull(ctx, u, remember)
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return
|
return
|
||||||
|
|||||||
+18
-27
@@ -8,6 +8,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -329,46 +330,35 @@ func SignInPost(ctx *context.Context) {
|
|||||||
|
|
||||||
// If this user is enrolled in 2FA TOTP, we can't sign the user in just yet.
|
// If this user is enrolled in 2FA TOTP, we can't sign the user in just yet.
|
||||||
// Instead, redirect them to the 2FA authentication page.
|
// Instead, redirect them to the 2FA authentication page.
|
||||||
hasTOTPtwofa, err := auth.HasTwoFactorByUID(ctx, u.ID)
|
hasTwoFactor, err := auth.HasTwoFactorOrWebAuthn(ctx, u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("UserSignIn", err)
|
ctx.ServerError("HasTwoFactorOrWebAuthn", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !hasTwoFactor {
|
||||||
// Check if the user has webauthn registration
|
|
||||||
hasWebAuthnTwofa, err := auth.HasWebAuthnRegistrationsByUID(ctx, u.ID)
|
|
||||||
if err != nil {
|
|
||||||
ctx.ServerError("UserSignIn", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !hasTOTPtwofa && !hasWebAuthnTwofa {
|
|
||||||
// No two-factor auth configured we can sign in the user
|
|
||||||
handleSignIn(ctx, u, form.Remember)
|
handleSignIn(ctx, u, form.Remember)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
updates := map[string]any{
|
handleTwoFactorRequired(ctx, u, form.Remember, nil)
|
||||||
// User will need to use 2FA TOTP or WebAuthn, save data
|
}
|
||||||
"twofaUid": u.ID,
|
|
||||||
"twofaRemember": form.Remember,
|
func handleTwoFactorRequired(ctx *context.Context, u *user_model.User, remember bool, extra map[string]any) {
|
||||||
}
|
updates := map[string]any{"twofaUid": u.ID, "twofaRemember": remember}
|
||||||
if hasTOTPtwofa {
|
maps.Copy(updates, extra)
|
||||||
// User will need to use WebAuthn, save data
|
|
||||||
updates["totpEnrolled"] = u.ID
|
|
||||||
}
|
|
||||||
if err := regenerateSession(ctx, nil, updates); err != nil {
|
if err := regenerateSession(ctx, nil, updates); err != nil {
|
||||||
ctx.ServerError("UserSignIn: Unable to update session", err)
|
ctx.ServerError("RegenerateSession", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
hasWebAuthn, err := auth.HasWebAuthnRegistrationsByUID(ctx, u.ID)
|
||||||
// If we have WebAuthn redirect there first
|
if err != nil {
|
||||||
if hasWebAuthnTwofa {
|
ctx.ServerError("HasWebAuthnRegistrationsByUID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hasWebAuthn {
|
||||||
ctx.Redirect(setting.AppSubURL + "/user/webauthn")
|
ctx.Redirect(setting.AppSubURL + "/user/webauthn")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to 2FA
|
|
||||||
ctx.Redirect(setting.AppSubURL + "/user/two_factor")
|
ctx.Redirect(setting.AppSubURL + "/user/two_factor")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,6 +398,7 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember bool) {
|
|||||||
"twofaRemember",
|
"twofaRemember",
|
||||||
"linkAccount",
|
"linkAccount",
|
||||||
"linkAccountData",
|
"linkAccountData",
|
||||||
|
"openidPendingURI",
|
||||||
}, map[string]any{
|
}, map[string]any{
|
||||||
session.KeyUID: u.ID,
|
session.KeyUID: u.ID,
|
||||||
session.KeyUname: u.Name,
|
session.KeyUname: u.Name,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
auth_model "gitea.dev/models/auth"
|
auth_model "gitea.dev/models/auth"
|
||||||
|
"gitea.dev/models/unittest"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/session"
|
"gitea.dev/modules/session"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
@@ -182,3 +183,19 @@ func TestWebAuthOAuth2(t *testing.T) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOpenIDRequireTwoFactor(t *testing.T) {
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("dummy-sid-openid")}
|
||||||
|
|
||||||
|
user32 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 32}) // has a webauthn credential
|
||||||
|
ctx, resp := contexttest.MockContext(t, "/user/openid/connect", mockOpt)
|
||||||
|
openIDRequireTwoFactor(ctx, user32, false, "https://example.com/id")
|
||||||
|
assert.Equal(t, "/user/webauthn", test.RedirectURL(resp))
|
||||||
|
unittest.AssertNotExistsBean(t, &user_model.UserOpenID{UID: user32.ID}) // not attached before the key answered
|
||||||
|
|
||||||
|
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||||
|
ctx, _ = contexttest.MockContext(t, "/user/openid/connect", mockOpt)
|
||||||
|
openIDRequireTwoFactor(ctx, user2, false, "https://example.com/id")
|
||||||
|
assert.False(t, ctx.Written())
|
||||||
|
}
|
||||||
|
|||||||
@@ -148,15 +148,13 @@ func oauth2LinkAccount(ctx *context.Context, u *user_model.User, linkAccountData
|
|||||||
// If this user is enrolled in 2FA, we can't sign the user in just yet.
|
// If this user is enrolled in 2FA, we can't sign the user in just yet.
|
||||||
// Instead, redirect them to the 2FA authentication page.
|
// Instead, redirect them to the 2FA authentication page.
|
||||||
// We deliberately ignore the skip local 2fa setting here because we are linking to a previous user here
|
// We deliberately ignore the skip local 2fa setting here because we are linking to a previous user here
|
||||||
_, err := auth.GetTwoFactorByUID(ctx, u.ID)
|
hasTwoFactor, err := auth.HasTwoFactorOrWebAuthn(ctx, u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !auth.IsErrTwoFactorNotEnrolled(err) {
|
ctx.ServerError("UserLinkAccount", err)
|
||||||
ctx.ServerError("UserLinkAccount", err)
|
return
|
||||||
return
|
}
|
||||||
}
|
if !hasTwoFactor {
|
||||||
|
if err := externalaccount.LinkAccountToUser(ctx, linkAccountData.AuthSourceID, u, linkAccountData.GothUser); err != nil {
|
||||||
err = externalaccount.LinkAccountToUser(ctx, linkAccountData.AuthSourceID, u, linkAccountData.GothUser)
|
|
||||||
if err != nil {
|
|
||||||
ctx.ServerError("UserLinkAccount", err)
|
ctx.ServerError("UserLinkAccount", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -170,25 +168,10 @@ func oauth2LinkAccount(ctx *context.Context, u *user_model.User, linkAccountData
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := regenerateSession(ctx, nil, map[string]any{
|
handleTwoFactorRequired(ctx, u, remember, map[string]any{
|
||||||
// User needs to use 2FA, save data and redirect to 2FA page.
|
|
||||||
"twofaUid": u.ID,
|
|
||||||
"twofaRemember": remember,
|
|
||||||
"linkAccount": true,
|
"linkAccount": true,
|
||||||
session.KeySignInMethod: session.SignInMethodOAuth2,
|
session.KeySignInMethod: session.SignInMethodOAuth2,
|
||||||
}); err != nil {
|
})
|
||||||
ctx.ServerError("RegenerateSession", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// If WebAuthn is enrolled -> Redirect to WebAuthn instead
|
|
||||||
regs, err := auth.GetWebAuthnCredentialsByUID(ctx, u.ID)
|
|
||||||
if err == nil && len(regs) > 0 {
|
|
||||||
ctx.Redirect(setting.AppSubURL + "/user/webauthn")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.Redirect(setting.AppSubURL + "/user/two_factor")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// LinkAccountPostRegister handle the creation of a new account for an external account using signUp
|
// LinkAccountPostRegister handle the creation of a new account for an external account using signUp
|
||||||
@@ -279,6 +262,15 @@ func LinkAccountPostRegister(ctx *context.Context) {
|
|||||||
handleSignIn(ctx, u, false)
|
handleSignIn(ctx, u, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func completePendingLinks(ctx *context.Context, user *user_model.User) error {
|
||||||
|
if ctx.Session.Get("linkAccount") != nil {
|
||||||
|
if err := linkAccountFromContext(ctx, user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return openIDConnectFromContext(ctx, user)
|
||||||
|
}
|
||||||
|
|
||||||
func linkAccountFromContext(ctx *context.Context, user *user_model.User) error {
|
func linkAccountFromContext(ctx *context.Context, user *user_model.User) error {
|
||||||
linkAccountData := oauth2GetLinkAccountData(ctx)
|
linkAccountData := oauth2GetLinkAccountData(ctx)
|
||||||
if linkAccountData == nil {
|
if linkAccountData == nil {
|
||||||
|
|||||||
@@ -361,12 +361,11 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
|
|||||||
|
|
||||||
needs2FA := false
|
needs2FA := false
|
||||||
if !authSource.TwoFactorShouldSkip() {
|
if !authSource.TwoFactorShouldSkip() {
|
||||||
_, err := auth.GetTwoFactorByUID(ctx, u.ID)
|
var err error
|
||||||
if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) {
|
if needs2FA, err = auth.HasTwoFactorOrWebAuthn(ctx, u.ID); err != nil {
|
||||||
ctx.ServerError("UserSignIn", err)
|
ctx.ServerError("UserSignIn", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
needs2FA = err == nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
oauth2Source := authSource.Cfg.(*oauth2.Source)
|
oauth2Source := authSource.Cfg.(*oauth2.Source)
|
||||||
@@ -454,24 +453,7 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := regenerateSession(ctx, nil, map[string]any{
|
handleTwoFactorRequired(ctx, u, false, map[string]any{session.KeySignInMethod: session.SignInMethodOAuth2})
|
||||||
// User needs to use 2FA, save data and redirect to 2FA page.
|
|
||||||
"twofaUid": u.ID,
|
|
||||||
"twofaRemember": false,
|
|
||||||
session.KeySignInMethod: session.SignInMethodOAuth2,
|
|
||||||
}); err != nil {
|
|
||||||
ctx.ServerError("updateSession", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// If WebAuthn is enrolled -> Redirect to WebAuthn instead
|
|
||||||
regs, err := auth.GetWebAuthnCredentialsByUID(ctx, u.ID)
|
|
||||||
if err == nil && len(regs) > 0 {
|
|
||||||
ctx.Redirect(setting.AppSubURL + "/user/webauthn")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.Redirect(setting.AppSubURL + "/user/two_factor")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuth2UserLoginCallback attempts to handle the callback from the OAuth2 provider and if successful
|
// OAuth2UserLoginCallback attempts to handle the callback from the OAuth2 provider and if successful
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
|
auth_model "gitea.dev/models/auth"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/auth/openid"
|
"gitea.dev/modules/auth/openid"
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
@@ -26,6 +27,36 @@ const (
|
|||||||
tplSignUpOID templates.TplName = "user/auth/signup_openid_register"
|
tplSignUpOID templates.TplName = "user/auth/signup_openid_register"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// the OpenID is attached only after the second factor passed, so a stolen password cannot leave one behind
|
||||||
|
func openIDRequireTwoFactor(ctx *context.Context, u *user_model.User, remember bool, pendingURI string) {
|
||||||
|
hasTwoFactor, err := auth_model.HasTwoFactorOrWebAuthn(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("HasTwoFactorOrWebAuthn", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !hasTwoFactor {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleTwoFactorRequired(ctx, u, remember, map[string]any{"openidPendingURI": pendingURI})
|
||||||
|
}
|
||||||
|
|
||||||
|
func openIDConnectFromContext(ctx *context.Context, u *user_model.User) error {
|
||||||
|
uri, _ := ctx.Session.Get("openidPendingURI").(string)
|
||||||
|
if uri == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := ctx.Session.Delete("openidPendingURI"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := user_model.AddUserOpenID(ctx, &user_model.UserOpenID{UID: u.ID, URI: uri}); err != nil {
|
||||||
|
if !user_model.IsErrOpenIDAlreadyUsed(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx.Flash.Error(ctx.Tr("form.openid_been_used", uri))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// SignInOpenID render sign in page
|
// SignInOpenID render sign in page
|
||||||
func SignInOpenID(ctx *context.Context) {
|
func SignInOpenID(ctx *context.Context) {
|
||||||
ctx.Data["Title"] = ctx.Tr("sign_in")
|
ctx.Data["Title"] = ctx.Tr("sign_in")
|
||||||
@@ -154,6 +185,10 @@ func signInOpenIDVerify(ctx *context.Context) {
|
|||||||
log.Trace("User exists, logging in")
|
log.Trace("User exists, logging in")
|
||||||
remember, _ := ctx.Session.Get("openid_signin_remember").(bool)
|
remember, _ := ctx.Session.Get("openid_signin_remember").(bool)
|
||||||
log.Trace("Session stored openid-remember: %t", remember)
|
log.Trace("Session stored openid-remember: %t", remember)
|
||||||
|
openIDRequireTwoFactor(ctx, u, remember, "")
|
||||||
|
if ctx.Written() {
|
||||||
|
return
|
||||||
|
}
|
||||||
handleSignIn(ctx, u, remember)
|
handleSignIn(ctx, u, remember)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -270,7 +305,12 @@ func ConnectOpenIDPost(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// add OpenID for the user
|
remember, _ := ctx.Session.Get("openid_signin_remember").(bool)
|
||||||
|
openIDRequireTwoFactor(ctx, u, remember, oid)
|
||||||
|
if ctx.Written() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
userOID := &user_model.UserOpenID{UID: u.ID, URI: oid}
|
userOID := &user_model.UserOpenID{UID: u.ID, URI: oid}
|
||||||
if err := user_model.AddUserOpenID(ctx, userOID); err != nil {
|
if err := user_model.AddUserOpenID(ctx, userOID); err != nil {
|
||||||
if user_model.IsErrOpenIDAlreadyUsed(err) {
|
if user_model.IsErrOpenIDAlreadyUsed(err) {
|
||||||
@@ -282,9 +322,6 @@ func ConnectOpenIDPost(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("settings.add_openid_success"))
|
ctx.Flash.Success(ctx.Tr("settings.add_openid_success"))
|
||||||
|
|
||||||
remember, _ := ctx.Session.Get("openid_signin_remember").(bool)
|
|
||||||
log.Trace("Session stored openid-remember: %t", remember)
|
|
||||||
handleSignIn(ctx, u, remember)
|
handleSignIn(ctx, u, remember)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -238,6 +238,19 @@ func ResetPasswdPost(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// the reset form only carries a TOTP field, so a WebAuthn-only user finishes on its own page
|
||||||
|
if twofa == nil {
|
||||||
|
hasWebAuthn, err := auth.HasWebAuthnRegistrationsByUID(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("HasWebAuthnRegistrationsByUID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hasWebAuthn {
|
||||||
|
handleTwoFactorRequired(ctx, u, remember, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleSignIn(ctx, u, remember)
|
handleSignIn(ctx, u, remember)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ func WebAuthnPasskeyAssertion(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
assertion, sessionData, err := wa.WebAuthn.BeginDiscoverableLogin()
|
// a passkey is the only factor here
|
||||||
|
assertion, sessionData, err := wa.WebAuthn.BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("webauthn.BeginDiscoverableLogin", err)
|
ctx.ServerError("webauthn.BeginDiscoverableLogin", err)
|
||||||
return
|
return
|
||||||
@@ -91,7 +92,7 @@ func WebAuthnPasskeyLogin(ctx *context.Context) {
|
|||||||
parsedResponse, err := protocol.ParseCredentialRequestResponse(ctx.Req)
|
parsedResponse, err := protocol.ParseCredentialRequestResponse(ctx.Req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Failed authentication attempt.
|
// Failed authentication attempt.
|
||||||
log.Info("Failed authentication attempt for %s from %s: %v", user.Name, ctx.RemoteAddr(), err)
|
log.Info("Failed authentication attempt from %s: %v", ctx.RemoteAddr(), err)
|
||||||
ctx.Status(http.StatusForbidden)
|
ctx.Status(http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -147,12 +148,9 @@ func WebAuthnPasskeyLogin(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now handle account linking if that's requested
|
if err := completePendingLinks(ctx, user); err != nil {
|
||||||
if ctx.Session.Get("linkAccount") != nil {
|
ctx.ServerError("completePendingLinks", err)
|
||||||
if err := linkAccountFromContext(ctx, user); err != nil {
|
return
|
||||||
ctx.ServerError("LinkAccountFromStore", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
remember := false // TODO: implement remember me
|
remember := false // TODO: implement remember me
|
||||||
@@ -186,7 +184,8 @@ func WebAuthnLoginAssertion(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
webAuthnUser := wa.NewWebAuthnUser(ctx, user)
|
webAuthnUser := wa.NewWebAuthnUser(ctx, user)
|
||||||
assertion, sessionData, err := wa.WebAuthn.BeginLogin(webAuthnUser)
|
// "discouraged" would hide credProtect protected credentials
|
||||||
|
assertion, sessionData, err := wa.WebAuthn.BeginLogin(webAuthnUser, webauthn.WithUserVerification(protocol.VerificationPreferred))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("webauthn.BeginLogin", err)
|
ctx.ServerError("webauthn.BeginLogin", err)
|
||||||
return
|
return
|
||||||
@@ -261,12 +260,9 @@ func WebAuthnLoginAssertionPost(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now handle account linking if that's requested
|
if err := completePendingLinks(ctx, user); err != nil {
|
||||||
if ctx.Session.Get("linkAccount") != nil {
|
ctx.ServerError("completePendingLinks", err)
|
||||||
if err := linkAccountFromContext(ctx, user); err != nil {
|
return
|
||||||
ctx.ServerError("LinkAccountFromStore", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
remember := ctx.Session.Get("twofaRemember").(bool)
|
remember := ctx.Session.Get("twofaRemember").(bool)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ package org
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
@@ -201,7 +200,7 @@ func prepareOrgProfileReadme(ctx *context.Context, prepareResult *shared_user.Pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
rctx := renderhelper.NewRenderContextRepoFile(ctx, profileRepo, renderhelper.RepoFileOptions{
|
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)
|
ctx.Data["ProfileReadmeContent"], err = markdown.RenderString(rctx, readmeBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -662,6 +662,10 @@ type WorkflowDispatchInput struct {
|
|||||||
Options []string `yaml:"options"`
|
Options []string `yaml:"options"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (i WorkflowDispatchInput) IsDefaultTrue() bool {
|
||||||
|
return util.ParseYamlBool(i.Default)
|
||||||
|
}
|
||||||
|
|
||||||
type WorkflowDispatch struct {
|
type WorkflowDispatch struct {
|
||||||
Inputs []WorkflowDispatchInput
|
Inputs []WorkflowDispatchInput
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1063,7 +1063,10 @@ func Cancel(ctx *context_module.Context) {
|
|||||||
return fmt.Errorf("cancel jobs: %w", err)
|
return fmt.Errorf("cancel jobs: %w", err)
|
||||||
}
|
}
|
||||||
updatedJobs = append(updatedJobs, cancelledJobs...)
|
updatedJobs = append(updatedJobs, cancelledJobs...)
|
||||||
return nil
|
if len(updatedJobs) > 0 {
|
||||||
|
return nil // a job update already refreshed the run
|
||||||
|
}
|
||||||
|
return actions_model.SettleRunAfterCancel(ctx, run)
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
ctx.ServerError("StopTask", err)
|
ctx.ServerError("StopTask", err)
|
||||||
return
|
return
|
||||||
@@ -1073,8 +1076,11 @@ func Cancel(ctx *context_module.Context) {
|
|||||||
actions_service.EmitJobsIfReadyByJobs(updatedJobs)
|
actions_service.EmitJobsIfReadyByJobs(updatedJobs)
|
||||||
|
|
||||||
actions_service.NotifyWorkflowJobsStatusUpdate(ctx, updatedJobs...)
|
actions_service.NotifyWorkflowJobsStatusUpdate(ctx, updatedJobs...)
|
||||||
if len(updatedJobs) > 0 {
|
// SettleRunAfterCancel finishes a run without updating any job, so compare the run itself.
|
||||||
actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, run.RepoID, run.ID)
|
if reloaded, err := actions_model.GetRunByRepoAndID(ctx, run.RepoID, run.ID); err != nil {
|
||||||
|
log.Error("GetRunByRepoAndID: %v", err)
|
||||||
|
} else if len(updatedJobs) > 0 || reloaded.Status != run.Status {
|
||||||
|
actions_service.NotifyWorkflowRunStatusUpdate(ctx, reloaded)
|
||||||
}
|
}
|
||||||
ctx.JSONOK()
|
ctx.JSONOK()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ func processBlameParts(ctx *context.Context, blameParts []*gitrepo.BlamePart) ma
|
|||||||
}
|
}
|
||||||
|
|
||||||
func renderBlameFillFirstBlameRow(ctx *context.Context, repoLink string, part *gitrepo.BlamePart, commit *gituser.UserCommit, br *blameRow) {
|
func renderBlameFillFirstBlameRow(ctx *context.Context, repoLink string, part *gitrepo.BlamePart, commit *gituser.UserCommit, br *blameRow) {
|
||||||
br.AvatarStackData = gituser.BuildAvatarStackData(ctx, commit.GitCommit.AllParticipantIdentities(), nil)
|
br.AvatarStackData = gituser.BuildAvatarStackData(ctx, commit.GitCommit.AllAuthorIdentities(), nil)
|
||||||
br.PreviousSha = part.PreviousSha
|
br.PreviousSha = part.PreviousSha
|
||||||
br.PreviousShaURL = fmt.Sprintf("%s/blame/commit/%s/%s", repoLink, url.PathEscape(part.PreviousSha), util.PathEscapeSegments(part.PreviousPath))
|
br.PreviousShaURL = fmt.Sprintf("%s/blame/commit/%s/%s", repoLink, url.PathEscape(part.PreviousSha), util.PathEscapeSegments(part.PreviousPath))
|
||||||
br.CommitURL = fmt.Sprintf("%s/commit/%s", repoLink, url.PathEscape(part.Sha))
|
br.CommitURL = fmt.Sprintf("%s/commit/%s", repoLink, url.PathEscape(part.Sha))
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ func RenderFile(ctx *context.Context) {
|
|||||||
extRendererOpts := extRenderer.GetExternalRendererOptions()
|
extRendererOpts := extRenderer.GetExternalRendererOptions()
|
||||||
if extRendererOpts.ContentSandbox != "" {
|
if extRendererOpts.ContentSandbox != "" {
|
||||||
ctx.Resp.Header().Add("Content-Security-Policy", "sandbox "+extRendererOpts.ContentSandbox)
|
ctx.Resp.Header().Add("Content-Security-Policy", "sandbox "+extRendererOpts.ContentSandbox)
|
||||||
|
} else {
|
||||||
|
// if no sandbox, just apply the same CSP as a general Gitea web page
|
||||||
|
ctx.SetHeaderContentSecurityPolicyGeneral()
|
||||||
}
|
}
|
||||||
|
|
||||||
err = markup.RenderWithRenderer(rctx, renderer, rendererInput, ctx.Resp)
|
err = markup.RenderWithRenderer(rctx, renderer, rendererInput, ctx.Resp)
|
||||||
|
|||||||
@@ -118,13 +118,19 @@ func CollaborationPost(ctx *context.Context) {
|
|||||||
|
|
||||||
// ChangeCollaborationAccessMode response for changing access of a collaboration
|
// ChangeCollaborationAccessMode response for changing access of a collaboration
|
||||||
func ChangeCollaborationAccessMode(ctx *context.Context) {
|
func ChangeCollaborationAccessMode(ctx *context.Context) {
|
||||||
if err := repo_model.ChangeCollaborationAccessMode(
|
// the frontend initRepoSettingsCollaboration logic: it only checks "resp.ok"
|
||||||
ctx,
|
u, err := user_model.GetUserByID(ctx, ctx.FormInt64("uid"))
|
||||||
ctx.Repo.Repository,
|
if err != nil {
|
||||||
ctx.FormInt64("uid"),
|
ctx.Status(http.StatusBadRequest)
|
||||||
perm.AccessMode(ctx.FormInt("mode"))); err != nil {
|
return
|
||||||
log.Error("ChangeCollaborationAccessMode: %v", err)
|
|
||||||
}
|
}
|
||||||
|
mode := perm.AccessMode(ctx.FormInt("mode"))
|
||||||
|
if err := repo_service.AddOrUpdateCollaborator(ctx, ctx.Repo.Repository, u, mode); err != nil {
|
||||||
|
ctx.Status(http.StatusBadRequest)
|
||||||
|
log.Error("AddOrUpdateCollaborator: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.JSONOK()
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteCollaboration delete a collaboration for a repository
|
// DeleteCollaboration delete a collaboration for a repository
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ func loadLatestCommitData(ctx *context.Context, latestCommit *git.Commit) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
avatarStackData := gituser.BuildAvatarStackData(ctx, latestCommit.AllParticipantIdentities(), nil)
|
avatarStackData := gituser.BuildAvatarStackData(ctx, latestCommit.AllAuthorIdentities(), nil)
|
||||||
avatarStackData.SearchByEmailLink = gituser.RepoCommitSearchByEmailLink(ctx.Repo.RepoLink, ctx.Repo.RefFullName)
|
avatarStackData.SearchByEmailLink = gituser.RepoCommitSearchByEmailLink(ctx.Repo.RepoLink, ctx.Repo.RefFullName)
|
||||||
ctx.Data["LatestCommitAvatarStackData"] = avatarStackData
|
ctx.Data["LatestCommitAvatarStackData"] = avatarStackData
|
||||||
ctx.Data["LatestCommitVerification"] = verification
|
ctx.Data["LatestCommitVerification"] = verification
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ package user
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
activities_model "gitea.dev/models/activities"
|
activities_model "gitea.dev/models/activities"
|
||||||
@@ -22,7 +21,6 @@ import (
|
|||||||
"gitea.dev/modules/optional"
|
"gitea.dev/modules/optional"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/templates"
|
"gitea.dev/modules/templates"
|
||||||
"gitea.dev/modules/util"
|
|
||||||
"gitea.dev/routers/web/feed"
|
"gitea.dev/routers/web/feed"
|
||||||
"gitea.dev/routers/web/org"
|
"gitea.dev/routers/web/org"
|
||||||
shared_user "gitea.dev/routers/web/shared/user"
|
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)
|
log.Error("failed to GetBlobContent: %v", err)
|
||||||
} else {
|
} else {
|
||||||
rctx := renderhelper.NewRenderContextRepoFile(ctx, profileDbRepo, renderhelper.RepoFileOptions{
|
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 {
|
if profileContent, err := markdown.RenderString(rctx, bytes); err != nil {
|
||||||
log.Error("failed to RenderString: %v", err)
|
log.Error("failed to RenderString: %v", err)
|
||||||
|
|||||||
@@ -53,8 +53,17 @@ func WebAuthnRegister(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
webAuthnUser := wa.NewWebAuthnUser(ctx, ctx.Doer)
|
webAuthnUser := wa.NewWebAuthnUser(ctx, ctx.Doer)
|
||||||
credentialOptions, sessionData, err := wa.WebAuthn.BeginRegistration(webAuthnUser, webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{
|
// the exclusions stop enrolling the same authenticator twice
|
||||||
|
credentials, err := auth.GetWebAuthnCredentialsByUID(ctx, ctx.Doer.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("GetWebAuthnCredentialsByUID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exclusions := webauthn.Credentials(credentials.ToCredentials()).CredentialDescriptors()
|
||||||
|
credentialOptions, sessionData, err := wa.WebAuthn.BeginRegistration(webAuthnUser, webauthn.WithExclusions(exclusions), webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{
|
||||||
ResidentKey: protocol.ResidentKeyRequirementRequired,
|
ResidentKey: protocol.ResidentKeyRequirementRequired,
|
||||||
|
// anything else makes Chromium raise it to credProtect level 3, hiding it from the second factor
|
||||||
|
UserVerification: protocol.VerificationRequired,
|
||||||
}))
|
}))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("Unable to BeginRegistration", err)
|
ctx.ServerError("Unable to BeginRegistration", err)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
actions_model "gitea.dev/models/actions"
|
actions_model "gitea.dev/models/actions"
|
||||||
"gitea.dev/modules/actions/jobparser"
|
"gitea.dev/modules/actions/jobparser"
|
||||||
|
"gitea.dev/modules/setting"
|
||||||
|
|
||||||
act_model "gitea.com/gitea/runner/act/model"
|
act_model "gitea.com/gitea/runner/act/model"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
@@ -17,6 +18,7 @@ import (
|
|||||||
// EvaluateRunConcurrencyFillModel evaluates the expressions in a run-level (workflow) concurrency,
|
// EvaluateRunConcurrencyFillModel evaluates the expressions in a run-level (workflow) concurrency,
|
||||||
// and fills the run attempt model with the evaluated `concurrency.group` and `concurrency.cancel-in-progress` values.
|
// and fills the run attempt model with the evaluated `concurrency.group` and `concurrency.cancel-in-progress` values.
|
||||||
// Workflow-level concurrency doesn't depend on the job outputs, so it can always be evaluated if there is no syntax error.
|
// Workflow-level concurrency doesn't depend on the job outputs, so it can always be evaluated if there is no syntax error.
|
||||||
|
// Callers must resolve `inputs`, there is no job in scope here to read `on: workflow_dispatch` from.
|
||||||
// See https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#concurrency
|
// See https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#concurrency
|
||||||
func EvaluateRunConcurrencyFillModel(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, wfRawConcurrency *act_model.RawConcurrency, vars map[string]string, inputs map[string]any) error {
|
func EvaluateRunConcurrencyFillModel(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, wfRawConcurrency *act_model.RawConcurrency, vars map[string]string, inputs map[string]any) error {
|
||||||
if err := run.LoadAttributes(ctx); err != nil {
|
if err := run.LoadAttributes(ctx); err != nil {
|
||||||
@@ -26,11 +28,10 @@ func EvaluateRunConcurrencyFillModel(ctx context.Context, run *actions_model.Act
|
|||||||
actionsRunCtx := GenerateGiteaContext(ctx, run, attempt, nil)
|
actionsRunCtx := GenerateGiteaContext(ctx, run, attempt, nil)
|
||||||
jobResults := map[string]*jobparser.JobResult{"": {}}
|
jobResults := map[string]*jobparser.JobResult{"": {}}
|
||||||
if inputs == nil {
|
if inputs == nil {
|
||||||
var err error
|
if run.Event == "workflow_dispatch" {
|
||||||
inputs, err = getWorkflowDispatchInputsFromRun(run)
|
setting.PanicInDevOrTesting("run %d: workflow_dispatch inputs must be resolved by the caller", run.ID)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("get inputs: %w", err)
|
|
||||||
}
|
}
|
||||||
|
inputs = map[string]any{}
|
||||||
}
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
|
|||||||
@@ -36,12 +36,13 @@ func TestEvaluateRunConcurrency_RunIDFallback(t *testing.T) {
|
|||||||
|
|
||||||
expr := &act_model.RawConcurrency{
|
expr := &act_model.RawConcurrency{
|
||||||
Group: "${{ github.workflow }}-${{ github.head_ref || github.run_id }}",
|
Group: "${{ github.workflow }}-${{ github.head_ref || github.run_id }}",
|
||||||
CancelInProgress: "true",
|
CancelInProgress: "True",
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.NoError(t, EvaluateRunConcurrencyFillModel(ctx, runA, attemptA, expr, nil, nil))
|
assert.NoError(t, EvaluateRunConcurrencyFillModel(ctx, runA, attemptA, expr, nil, nil))
|
||||||
assert.NoError(t, EvaluateRunConcurrencyFillModel(ctx, runB, attemptB, expr, nil, nil))
|
assert.NoError(t, EvaluateRunConcurrencyFillModel(ctx, runB, attemptB, expr, nil, nil))
|
||||||
|
|
||||||
|
assert.True(t, attemptA.ConcurrencyCancel)
|
||||||
assert.Contains(t, attemptA.ConcurrencyGroup, "791")
|
assert.Contains(t, attemptA.ConcurrencyGroup, "791")
|
||||||
assert.Contains(t, attemptB.ConcurrencyGroup, "792")
|
assert.Contains(t, attemptB.ConcurrencyGroup, "792")
|
||||||
assert.NotEqual(t, attemptA.ConcurrencyGroup, attemptB.ConcurrencyGroup)
|
assert.NotEqual(t, attemptA.ConcurrencyGroup, attemptB.ConcurrencyGroup)
|
||||||
|
|||||||
@@ -8,12 +8,16 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
actions_model "gitea.dev/models/actions"
|
actions_model "gitea.dev/models/actions"
|
||||||
|
actions_module "gitea.dev/modules/actions"
|
||||||
"gitea.dev/modules/actions/jobparser"
|
"gitea.dev/modules/actions/jobparser"
|
||||||
"gitea.dev/modules/json"
|
"gitea.dev/modules/json"
|
||||||
|
"gitea.dev/modules/log"
|
||||||
api "gitea.dev/modules/structs"
|
api "gitea.dev/modules/structs"
|
||||||
|
"gitea.dev/modules/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func getWorkflowDispatchInputsFromRun(run *actions_model.ActionRun) (map[string]any, error) {
|
// dispatchInputsForJob types a top-level job's `inputs.*` from EventPayload, empty for other events.
|
||||||
|
func dispatchInputsForJob(run *actions_model.ActionRun, job *actions_model.ActionRunJob) (map[string]any, error) {
|
||||||
if run.Event != "workflow_dispatch" {
|
if run.Event != "workflow_dispatch" {
|
||||||
return map[string]any{}, nil
|
return map[string]any{}, nil
|
||||||
}
|
}
|
||||||
@@ -21,15 +25,40 @@ func getWorkflowDispatchInputsFromRun(run *actions_model.ActionRun) (map[string]
|
|||||||
if err := json.Unmarshal([]byte(run.EventPayload), &payload); err != nil {
|
if err := json.Unmarshal([]byte(run.EventPayload), &payload); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if payload.Inputs == nil {
|
||||||
|
payload.Inputs = map[string]any{} // nil reads as "unresolved" in EvaluateRunConcurrencyFillModel
|
||||||
|
}
|
||||||
|
parsedWorkflows, err := jobparser.Parse(job.WorkflowPayload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, util.NewInvalidArgumentErrorf("parse job %d workflow payload: %v", job.ID, err)
|
||||||
|
}
|
||||||
|
if len(parsedWorkflows) != 1 {
|
||||||
|
return nil, util.NewInvalidArgumentErrorf("job %d workflow payload: not single workflow", job.ID)
|
||||||
|
}
|
||||||
|
dispatch := parsedWorkflows[0].WorkflowDispatchConfig()
|
||||||
|
if dispatch == nil { // without it the values would silently stay untyped
|
||||||
|
return nil, util.NewInvalidArgumentErrorf("job %d payload declares no workflow_dispatch", job.ID)
|
||||||
|
}
|
||||||
|
coerceDispatchInputTypes(dispatch, payload.Inputs)
|
||||||
return payload.Inputs, nil
|
return payload.Inputs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dispatchInputsForRunJobs answers for the whole run, off any top-level job's workflow header.
|
||||||
|
func dispatchInputsForRunJobs(run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) (map[string]any, error) {
|
||||||
|
for _, job := range jobs {
|
||||||
|
if job.ParentJobID == 0 {
|
||||||
|
return dispatchInputsForJob(run, job)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("run %d: no top-level job to read the workflow_dispatch declaration from", run.ID)
|
||||||
|
}
|
||||||
|
|
||||||
// getInputsForJob returns the `inputs.*` top-level expression context for a job's evaluation.
|
// getInputsForJob returns the `inputs.*` top-level expression context for a job's evaluation.
|
||||||
// - For top-level jobs, it falls back to the run's dispatch inputs (empty for non-dispatch events)
|
// - For top-level jobs, it falls back to the run's dispatch inputs (empty for non-dispatch events)
|
||||||
// - For reusable workflow children (and nested callers), this is the direct parent caller's CallPayload.Inputs
|
// - For reusable workflow children (and nested callers), this is the direct parent caller's CallPayload.Inputs
|
||||||
func getInputsForJob(ctx context.Context, run *actions_model.ActionRun, job *actions_model.ActionRunJob) (map[string]any, error) {
|
func getInputsForJob(ctx context.Context, run *actions_model.ActionRun, job *actions_model.ActionRunJob) (map[string]any, error) {
|
||||||
if job.ParentJobID == 0 {
|
if job.ParentJobID == 0 {
|
||||||
return getWorkflowDispatchInputsFromRun(run)
|
return dispatchInputsForJob(run, job)
|
||||||
}
|
}
|
||||||
|
|
||||||
caller, err := actions_model.GetRunJobByRunAndID(ctx, run.ID, job.ParentJobID)
|
caller, err := actions_model.GetRunJobByRunAndID(ctx, run.ID, job.ParentJobID)
|
||||||
@@ -50,6 +79,22 @@ func getInputsForJob(ctx context.Context, run *actions_model.ActionRun, job *act
|
|||||||
return p.Inputs, nil
|
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:`
|
// 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) {
|
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()
|
parsedJob, err := job.ParseJob()
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package actions
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDispatchInputsForRunJobs(t *testing.T) {
|
||||||
|
// a child carries the callee's `on: workflow_call`, so only a top-level job answers for the run
|
||||||
|
run := &actions_model.ActionRun{Event: "workflow_dispatch", EventPayload: `{"inputs":{"deploy":"true"}}`}
|
||||||
|
job := &actions_model.ActionRunJob{
|
||||||
|
ID: 1, JobID: "deploy",
|
||||||
|
WorkflowPayload: []byte("on: {workflow_dispatch: {inputs: {deploy: {type: boolean}}}}\njobs:\n deploy:\n steps: [{run: echo}]\n"),
|
||||||
|
}
|
||||||
|
child := &actions_model.ActionRunJob{
|
||||||
|
ID: 2, JobID: "called", ParentJobID: job.ID,
|
||||||
|
WorkflowPayload: []byte("on: workflow_call\njobs:\n called:\n steps: [{run: echo}]\n"),
|
||||||
|
}
|
||||||
|
|
||||||
|
inputs, err := dispatchInputsForRunJobs(run, []*actions_model.ActionRunJob{child, job})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, true, inputs["deploy"])
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
repo_model "gitea.dev/models/repo"
|
repo_model "gitea.dev/models/repo"
|
||||||
"gitea.dev/models/unittest"
|
"gitea.dev/models/unittest"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
|
"gitea.dev/modules/util"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
@@ -19,6 +20,7 @@ import (
|
|||||||
func Test_jobStatusResolver_Resolve(t *testing.T) {
|
func Test_jobStatusResolver_Resolve(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
run *actions_model.ActionRun // defaults to stubRun
|
||||||
jobs actions_model.ActionJobList
|
jobs actions_model.ActionJobList
|
||||||
want map[int64]actions_model.Status
|
want map[int64]actions_model.Status
|
||||||
}{
|
}{
|
||||||
@@ -145,6 +147,34 @@ jobs:
|
|||||||
needs: job1
|
needs: job1
|
||||||
steps:
|
steps:
|
||||||
- run: echo "should run, job1 failure is masked by continue-on-error"
|
- run: echo "should run, job1 failure is masked by continue-on-error"
|
||||||
|
`)},
|
||||||
|
},
|
||||||
|
want: map[int64]actions_model.Status{2: actions_model.StatusWaiting},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// a needs-gated job is evaluated server-side, so a mistyped input silently leaves it blocked
|
||||||
|
name: "`if` compares a workflow_dispatch boolean input",
|
||||||
|
run: &actions_model.ActionRun{
|
||||||
|
TriggerUser: &user_model.User{}, Repo: &repo_model.Repository{},
|
||||||
|
Event: "workflow_dispatch",
|
||||||
|
EventPayload: `{"inputs":{"deploy":"true"}}`,
|
||||||
|
},
|
||||||
|
jobs: actions_model.ActionJobList{
|
||||||
|
{ID: 1, JobID: "job1", Status: actions_model.StatusSuccess, Needs: []string{}},
|
||||||
|
{ID: 2, JobID: "job2", Status: actions_model.StatusBlocked, Needs: []string{"job1"}, WorkflowPayload: []byte(
|
||||||
|
`
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
deploy:
|
||||||
|
type: boolean
|
||||||
|
jobs:
|
||||||
|
job2:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: job1
|
||||||
|
if: ${{ inputs.deploy == true && github.event.inputs.deploy == 'true' }}
|
||||||
|
steps:
|
||||||
|
- run: echo
|
||||||
`)},
|
`)},
|
||||||
},
|
},
|
||||||
want: map[int64]actions_model.Status{2: actions_model.StatusWaiting},
|
want: map[int64]actions_model.Status{2: actions_model.StatusWaiting},
|
||||||
@@ -158,6 +188,7 @@ jobs:
|
|||||||
// Each subtest gets a unique RunID / RunAttemptID so jobs from different subtests don't bleed into each other's FindTaskNeeds queries
|
// Each subtest gets a unique RunID / RunAttemptID so jobs from different subtests don't bleed into each other's FindTaskNeeds queries
|
||||||
runID := int64(9001 + i)
|
runID := int64(9001 + i)
|
||||||
attemptID := int64(9001 + i)
|
attemptID := int64(9001 + i)
|
||||||
|
run := util.IfZero(tt.run, stubRun)
|
||||||
|
|
||||||
// Insert each test job (letting the DB assign IDs) and remember the testID -> dbID mapping so we can translate the expected map.
|
// Insert each test job (letting the DB assign IDs) and remember the testID -> dbID mapping so we can translate the expected map.
|
||||||
idMap := make(map[int64]int64, len(tt.jobs))
|
idMap := make(map[int64]int64, len(tt.jobs))
|
||||||
@@ -166,7 +197,7 @@ jobs:
|
|||||||
j.ID = 0
|
j.ID = 0
|
||||||
j.RunID = runID
|
j.RunID = runID
|
||||||
j.RunAttemptID = attemptID
|
j.RunAttemptID = attemptID
|
||||||
j.Run = stubRun
|
j.Run = run
|
||||||
|
|
||||||
// The resolver evaluates Blocked jobs via evaluateJobIf, which needs a valid YAML payload;
|
// The resolver evaluates Blocked jobs via evaluateJobIf, which needs a valid YAML payload;
|
||||||
// supply a minimal one when the case didn't.
|
// supply a minimal one when the case didn't.
|
||||||
|
|||||||
@@ -338,8 +338,8 @@ func handleWorkflows(
|
|||||||
isForkPullRequest := isForkPullRequestInput(input)
|
isForkPullRequest := isForkPullRequestInput(input)
|
||||||
|
|
||||||
for _, dwf := range detectedWorkflows {
|
for _, dwf := range detectedWorkflows {
|
||||||
// repo-level run: the workflow content is this repo at this commit
|
// 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, commit.ID.String(), false); err != nil {
|
if err := buildApproveAndInsertRun(ctx, input, ref, commit, string(p), isForkPullRequest, dwf, input.Repo.ID, false); err != nil {
|
||||||
log.Error("repo %s: %v", input.Repo.RelativePath(), err)
|
log.Error("repo %s: %v", input.Repo.RelativePath(), err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -350,7 +350,7 @@ func handleWorkflows(
|
|||||||
// buildApproveAndInsertRun assembles an ActionRun for a detected workflow, runs the
|
// 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
|
// 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.
|
// 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).
|
// from (the repo itself for repo-level runs, the source repo for scoped runs).
|
||||||
func buildApproveAndInsertRun(
|
func buildApproveAndInsertRun(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
@@ -361,9 +361,12 @@ func buildApproveAndInsertRun(
|
|||||||
isForkPullRequest bool,
|
isForkPullRequest bool,
|
||||||
dwf *actions_module.DetectedWorkflow,
|
dwf *actions_module.DetectedWorkflow,
|
||||||
workflowRepoID int64,
|
workflowRepoID int64,
|
||||||
workflowCommitSHA string,
|
|
||||||
isScopedRun bool,
|
isScopedRun bool,
|
||||||
) error {
|
) 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{
|
run := &actions_model.ActionRun{
|
||||||
Title: commit.MessageTitle(),
|
Title: commit.MessageTitle(),
|
||||||
RepoID: input.Repo.ID,
|
RepoID: input.Repo.ID,
|
||||||
@@ -380,7 +383,7 @@ func buildApproveAndInsertRun(
|
|||||||
TriggerEvent: dwf.TriggerEvent.Name,
|
TriggerEvent: dwf.TriggerEvent.Name,
|
||||||
Status: actions_model.StatusWaiting,
|
Status: actions_model.StatusWaiting,
|
||||||
WorkflowRepoID: workflowRepoID,
|
WorkflowRepoID: workflowRepoID,
|
||||||
WorkflowCommitSHA: workflowCommitSHA,
|
WorkflowCommitSHA: dwf.SourceCommitSHA,
|
||||||
IsScopedRun: isScopedRun,
|
IsScopedRun: isScopedRun,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -695,7 +698,7 @@ func detectAndHandleScopedWorkflows(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceCommitSHA, detected, filtered, err := detectScopedWorkflowsForSource(ctx, input, consumerGitRepo, consumerCommit, sourceRepo)
|
detected, filtered, err := detectScopedWorkflowsForSource(ctx, input, consumerGitRepo, consumerCommit, sourceRepo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("scoped workflows: source %d for consumer %s: %v", sourceRepoID, input.Repo.RelativePath(), err)
|
log.Error("scoped workflows: source %d for consumer %s: %v", sourceRepoID, input.Repo.RelativePath(), err)
|
||||||
continue
|
continue
|
||||||
@@ -708,7 +711,7 @@ func detectAndHandleScopedWorkflows(
|
|||||||
continue
|
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.RelativePath(), dwf.EntryName, err)
|
log.Error("scoped workflows: source %s workflow %s: %v", sourceRepo.RelativePath(), dwf.EntryName, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -741,12 +744,12 @@ func detectScopedWorkflowsForSource(
|
|||||||
consumerGitRepo *git.Repository,
|
consumerGitRepo *git.Repository,
|
||||||
consumerCommit *git.Commit,
|
consumerCommit *git.Commit,
|
||||||
sourceRepo *repo_model.Repository,
|
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
|
// 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)
|
sourceCommitSHA, parsed, err := LoadParsedScopedWorkflows(ctx, sourceRepo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
detected, filtered = actions_module.MatchScopedWorkflows(parsed, consumerGitRepo, consumerCommit, input.Event, input.Payload)
|
detected, filtered = actions_module.MatchScopedWorkflows(parsed, sourceCommitSHA, consumerGitRepo, consumerCommit, input.Event, input.Payload)
|
||||||
return sourceCommitSHA, detected, filtered, nil
|
return detected, filtered, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,7 +204,11 @@ func execRerunPlan(ctx context.Context, plan *rerunPlan) (*actions_model.ActionR
|
|||||||
if err := yaml.Unmarshal([]byte(plan.run.RawConcurrency), &rawConcurrency); err != nil {
|
if err := yaml.Unmarshal([]byte(plan.run.RawConcurrency), &rawConcurrency); err != nil {
|
||||||
return nil, fmt.Errorf("unmarshal raw concurrency: %w", err)
|
return nil, fmt.Errorf("unmarshal raw concurrency: %w", err)
|
||||||
}
|
}
|
||||||
if err := EvaluateRunConcurrencyFillModel(ctx, plan.run, newAttempt, &rawConcurrency, vars, nil); err != nil {
|
inputs, err := dispatchInputsForRunJobs(plan.run, plan.templateJobs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := EvaluateRunConcurrencyFillModel(ctx, plan.run, newAttempt, &rawConcurrency, vars, inputs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"gitea.dev/modules/gitrepo"
|
"gitea.dev/modules/gitrepo"
|
||||||
"gitea.dev/modules/httplib"
|
"gitea.dev/modules/httplib"
|
||||||
"gitea.dev/modules/json"
|
"gitea.dev/modules/json"
|
||||||
|
"gitea.dev/modules/log"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
api "gitea.dev/modules/structs"
|
api "gitea.dev/modules/structs"
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
@@ -60,7 +61,11 @@ func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRu
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, "", fmt.Errorf("look up caller source repo %d: %w", caller.WorkflowSourceRepoID, err)
|
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 {
|
if err != nil {
|
||||||
return nil, 0, "", err
|
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)
|
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.
|
// 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) {
|
func readWorkflowFromRepo(ctx context.Context, repo *repo_model.Repository, refOrSHA, path string) ([]byte, string, error) {
|
||||||
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
|
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
|
||||||
|
|||||||
@@ -10,9 +10,13 @@ import (
|
|||||||
actions_model "gitea.dev/models/actions"
|
actions_model "gitea.dev/models/actions"
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
"gitea.dev/models/unittest"
|
"gitea.dev/models/unittest"
|
||||||
|
actions_module "gitea.dev/modules/actions"
|
||||||
"gitea.dev/modules/actions/jobparser"
|
"gitea.dev/modules/actions/jobparser"
|
||||||
|
"gitea.dev/modules/json"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
|
api "gitea.dev/modules/structs"
|
||||||
"gitea.dev/modules/test"
|
"gitea.dev/modules/test"
|
||||||
|
webhook_module "gitea.dev/modules/webhook"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -278,3 +282,74 @@ func TestUndoExpansion(t *testing.T) {
|
|||||||
assert.False(t, refreshed.IsExpanded)
|
assert.False(t, refreshed.IsExpanded)
|
||||||
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: sibling.ID})
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package actions
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
actions_model "gitea.dev/models/actions"
|
actions_model "gitea.dev/models/actions"
|
||||||
"gitea.dev/models/perm"
|
"gitea.dev/models/perm"
|
||||||
@@ -129,10 +130,7 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
|
|||||||
return 0, fmt.Errorf("failed to unmarshal workflow content: %w", err)
|
return 0, fmt.Errorf("failed to unmarshal workflow content: %w", err)
|
||||||
}
|
}
|
||||||
// get inputs from post
|
// get inputs from post
|
||||||
workflow := &model.Workflow{
|
workflowDispatch := singleWorkflow.WorkflowDispatchConfig()
|
||||||
RawOn: singleWorkflow.RawOn,
|
|
||||||
}
|
|
||||||
workflowDispatch := workflow.WorkflowDispatchConfig()
|
|
||||||
if workflowDispatch == nil {
|
if workflowDispatch == nil {
|
||||||
return 0, util.ErrorWrapTranslatable(
|
return 0, util.ErrorWrapTranslatable(
|
||||||
util.NewInvalidArgumentErrorf("workflow %q has no workflow_dispatch event trigger", workflowID),
|
util.NewInvalidArgumentErrorf("workflow %q has no workflow_dispatch event trigger", workflowID),
|
||||||
@@ -144,10 +142,6 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
|
|||||||
if err = processInputs(workflowDispatch, inputsWithDefaults); err != nil {
|
if err = processInputs(workflowDispatch, inputsWithDefaults); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
// The dispatch callbacks fill boolean inputs as the strings "true"/"false". Normalize them to
|
|
||||||
// native JSON booleans so `type: boolean` inputs match GitHub, whose `inputs` context preserves
|
|
||||||
// booleans as booleans. Without this, a server-side needs-gated job `if: inputs.flag == true`
|
|
||||||
// evaluates against the string "true" and never matches, leaving the job blocked forever.
|
|
||||||
coerceDispatchInputTypes(workflowDispatch, inputsWithDefaults)
|
coerceDispatchInputTypes(workflowDispatch, inputsWithDefaults)
|
||||||
|
|
||||||
// ctx.Req.PostForm -> WorkflowDispatchPayload.Inputs -> ActionRun.EventPayload -> runner: ghc.Event
|
// ctx.Req.PostForm -> WorkflowDispatchPayload.Inputs -> ActionRun.EventPayload -> runner: ghc.Event
|
||||||
@@ -157,7 +151,7 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
|
|||||||
Workflow: workflowID,
|
Workflow: workflowID,
|
||||||
Ref: ref,
|
Ref: ref,
|
||||||
Repository: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeNone}),
|
Repository: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeNone}),
|
||||||
Inputs: inputsWithDefaults,
|
Inputs: dispatchEventInputs(inputsWithDefaults),
|
||||||
Sender: convert.ToUserWithAccessMode(ctx, doer, perm.AccessModeNone),
|
Sender: convert.ToUserWithAccessMode(ctx, doer, perm.AccessModeNone),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,23 +168,33 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
|
|||||||
return run.ID, nil
|
return run.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// coerceDispatchInputTypes normalizes workflow_dispatch input values to the JSON types declared by
|
// coerceDispatchInputTypes types `inputs`, where boolean is the only non-string dispatch input type.
|
||||||
// the workflow. Only booleans are coerced, matching GitHub, whose `inputs` context "preserves
|
|
||||||
// Boolean values as Booleans instead of converting them to strings" while every other type stays a
|
|
||||||
// string. workflow_dispatch has no `number` type (its input types are string, choice, boolean and
|
|
||||||
// environment), so booleans are the complete set to coerce here.
|
|
||||||
// A value that is already a bool is left untouched, so the coercion is idempotent.
|
|
||||||
func coerceDispatchInputTypes(dispatch *model.WorkflowDispatch, inputs map[string]any) {
|
func coerceDispatchInputTypes(dispatch *model.WorkflowDispatch, inputs map[string]any) {
|
||||||
for name, cfg := range dispatch.Inputs {
|
for name, cfg := range dispatch.Inputs {
|
||||||
if cfg.Type != "boolean" {
|
if cfg.Type != "boolean" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if s, ok := inputs[name].(string); ok {
|
if s, ok := inputs[name].(string); ok {
|
||||||
inputs[name] = s == "true"
|
inputs[name] = util.ParseYamlBool(s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dispatchEventInputs stringifies the typed inputs for `github.event.inputs`.
|
||||||
|
// workflow_dispatch input types are string, choice, boolean and environment, so after
|
||||||
|
// coerceDispatchInputTypes a value is either already a string or a bool.
|
||||||
|
func dispatchEventInputs(inputs map[string]any) map[string]any {
|
||||||
|
eventInputs := make(map[string]any, len(inputs))
|
||||||
|
for name, value := range inputs {
|
||||||
|
if b, ok := value.(bool); ok {
|
||||||
|
eventInputs[name] = strconv.FormatBool(b)
|
||||||
|
} else {
|
||||||
|
eventInputs[name] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return eventInputs
|
||||||
|
}
|
||||||
|
|
||||||
// resolveDispatchWorkflowContent returns the YAML for a dispatched workflow and records its source on the run.
|
// resolveDispatchWorkflowContent returns the YAML for a dispatched workflow and records its source on the run.
|
||||||
// - Repo-level: from the consumer's runTargetCommit.
|
// - Repo-level: from the consumer's runTargetCommit.
|
||||||
// - Scoped: from the source repo's default branch.
|
// - Scoped: from the source repo's default branch.
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ func TestCoerceDispatchInputTypes(t *testing.T) {
|
|||||||
"build_server": {Type: "boolean"},
|
"build_server": {Type: "boolean"},
|
||||||
"dry_run": {Type: "boolean"},
|
"dry_run": {Type: "boolean"},
|
||||||
"already_bool": {Type: "boolean"},
|
"already_bool": {Type: "boolean"},
|
||||||
|
"yaml_true": {Type: "boolean"},
|
||||||
|
"yaml_truthy": {Type: "boolean"},
|
||||||
"version": {Type: "string"},
|
"version": {Type: "string"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -26,6 +28,9 @@ func TestCoerceDispatchInputTypes(t *testing.T) {
|
|||||||
"dry_run": "false",
|
"dry_run": "false",
|
||||||
// already-native booleans are passed through unchanged (coercion is idempotent)
|
// already-native booleans are passed through unchanged (coercion is idempotent)
|
||||||
"already_bool": true,
|
"already_bool": true,
|
||||||
|
// source text of `default: True` and `default: yes`, only the former is a YAML 1.2 boolean
|
||||||
|
"yaml_true": "True",
|
||||||
|
"yaml_truthy": "yes",
|
||||||
// non-boolean inputs must be left untouched
|
// non-boolean inputs must be left untouched
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
}
|
}
|
||||||
@@ -37,5 +42,17 @@ func TestCoerceDispatchInputTypes(t *testing.T) {
|
|||||||
assert.Equal(t, true, inputs["build_server"])
|
assert.Equal(t, true, inputs["build_server"])
|
||||||
assert.Equal(t, false, inputs["dry_run"])
|
assert.Equal(t, false, inputs["dry_run"])
|
||||||
assert.Equal(t, true, inputs["already_bool"])
|
assert.Equal(t, true, inputs["already_bool"])
|
||||||
|
assert.Equal(t, true, inputs["yaml_true"])
|
||||||
|
assert.Equal(t, false, inputs["yaml_truthy"])
|
||||||
assert.Equal(t, "1.2.3", inputs["version"])
|
assert.Equal(t, "1.2.3", inputs["version"])
|
||||||
|
|
||||||
|
// `github.event.inputs` mirrors them as normalized strings
|
||||||
|
assert.Equal(t, map[string]any{
|
||||||
|
"build_server": "true",
|
||||||
|
"dry_run": "false",
|
||||||
|
"already_bool": "true",
|
||||||
|
"yaml_true": "true",
|
||||||
|
"yaml_truthy": "false",
|
||||||
|
"version": "1.2.3",
|
||||||
|
}, dispatchEventInputs(inputs))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,8 +78,6 @@ func (h *HTTPSign) Verify(req *http.Request, w http.ResponseWriter, store DataSt
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
store.GetData()["IsApiToken"] = true
|
|
||||||
|
|
||||||
log.Trace("HTTP Sign: Logged in user %-v", u)
|
log.Trace("HTTP Sign: Logged in user %-v", u)
|
||||||
|
|
||||||
return u, nil
|
return u, nil
|
||||||
|
|||||||
@@ -188,6 +188,26 @@ func (b *Base) TrN(cnt any, key1, keyN string, args ...any) template.HTML {
|
|||||||
return b.Locale.TrN(cnt, key1, keyN, args...)
|
return b.Locale.TrN(cnt, key1, keyN, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CspScriptNonce(ctx reqctx.RequestContext) (ret string) {
|
||||||
|
// Generate a random nonce for each request and cache it in the context to make it usable during the whole rendering process.
|
||||||
|
//
|
||||||
|
// Some "<script>" tags are not in the CSP context, so they don't need nonce,
|
||||||
|
// these tags are written as "<script nonce>" to help developers to know that "no script nonce attribute is missing"
|
||||||
|
// (e.g.: when they grep the codebase for "script" tags)
|
||||||
|
ret, _ = ctx.Value("_cspScriptNonce").(string)
|
||||||
|
if ret == "" {
|
||||||
|
ret = util.FastCryptoRandomHex(32) // 16 bytes / 128 bits entropy
|
||||||
|
ctx.SetContextValue("_cspScriptNonce", ret)
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Base) SetHeaderContentSecurityPolicyGeneral() {
|
||||||
|
if csp := WebContentSecurityPolicy(CspScriptNonce(b)); csp != "" {
|
||||||
|
b.Resp.Header().Set("Content-Security-Policy", csp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func NewBaseContext(resp http.ResponseWriter, req *http.Request) *Base {
|
func NewBaseContext(resp http.ResponseWriter, req *http.Request) *Base {
|
||||||
reqCtx := reqctx.FromContext(req.Context())
|
reqCtx := reqctx.FromContext(req.Context())
|
||||||
b := &Base{
|
b := &Base{
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.dev/modules/htmlutil"
|
||||||
"gitea.dev/modules/httplib"
|
"gitea.dev/modules/httplib"
|
||||||
"gitea.dev/modules/public"
|
"gitea.dev/modules/public"
|
||||||
|
"gitea.dev/modules/reqctx"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/util"
|
|
||||||
"gitea.dev/modules/web/middleware"
|
"gitea.dev/modules/web/middleware"
|
||||||
"gitea.dev/services/webtheme"
|
"gitea.dev/services/webtheme"
|
||||||
)
|
)
|
||||||
@@ -24,16 +25,16 @@ type TemplateContext map[string]any
|
|||||||
|
|
||||||
var _ context.Context = TemplateContext(nil)
|
var _ context.Context = TemplateContext(nil)
|
||||||
|
|
||||||
func NewTemplateContext(ctx context.Context, req *http.Request) TemplateContext {
|
func NewTemplateContext(ctx reqctx.RequestContext, req *http.Request) TemplateContext {
|
||||||
return TemplateContext{"_ctx": ctx, "_req": req}
|
return TemplateContext{"_ctx": ctx, "_req": req}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c TemplateContext) req() *http.Request {
|
func (c TemplateContext) req() *http.Request {
|
||||||
return c["_req"].(*http.Request)
|
return c["_req"].(*http.Request) //nolint:forcetypeassert // must exist
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c TemplateContext) parentContext() context.Context {
|
func (c TemplateContext) parentContext() reqctx.RequestContext {
|
||||||
return c["_ctx"].(context.Context)
|
return c["_ctx"].(reqctx.RequestContext) //nolint:forcetypeassert // must exist
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c TemplateContext) Deadline() (deadline time.Time, ok bool) {
|
func (c TemplateContext) Deadline() (deadline time.Time, ok bool) {
|
||||||
@@ -78,7 +79,7 @@ func (c TemplateContext) CurrentWebBanner() *setting.WebBannerType {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AppFullLink returns a full URL link with AppSubURL for the given app link (no AppSubURL)
|
// AppFullLink returns a full URL link with AppSubURL for the given app link
|
||||||
// If no link is given, it returns the current app full URL with sub-path but without trailing slash (that's why it is not named as AppURL)
|
// If no link is given, it returns the current app full URL with sub-path but without trailing slash (that's why it is not named as AppURL)
|
||||||
func (c TemplateContext) AppFullLink(link ...string) template.URL {
|
func (c TemplateContext) AppFullLink(link ...string) template.URL {
|
||||||
s := httplib.GuessCurrentAppURL(c.parentContext())
|
s := httplib.GuessCurrentAppURL(c.parentContext())
|
||||||
@@ -100,21 +101,10 @@ func (c TemplateContext) ScriptImport(path string, typ ...string) template.HTML
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c TemplateContext) CspScriptNonce() (ret string) {
|
func (c TemplateContext) CspScriptNonce() (ret string) {
|
||||||
// Generate a random nonce for each request and cache it in the context to make it usable during the whole rendering process.
|
return CspScriptNonce(c.parentContext())
|
||||||
//
|
|
||||||
// Some "<script>" tags are not in the CSP context, so they don't need nonce,
|
|
||||||
// these tags are written as "<script nonce>" to help developers to know that "no script nonce attribute is missing"
|
|
||||||
// (e.g.: when they grep the codebase for "script" tags)
|
|
||||||
|
|
||||||
ret, _ = c["_cspScriptNonce"].(string)
|
|
||||||
if ret == "" {
|
|
||||||
ret = util.FastCryptoRandomHex(32) // 16 bytes / 128 bits entropy
|
|
||||||
c["_cspScriptNonce"] = ret
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c TemplateContext) HeadMetaContentSecurityPolicy() template.HTML {
|
func WebContentSecurityPolicy(scriptNonce string) string {
|
||||||
if setting.Security.ContentSecurityPolicyGeneral == "unset" {
|
if setting.Security.ContentSecurityPolicyGeneral == "unset" {
|
||||||
return "" // if site admin disables the general CSP, then we don't use it
|
return "" // if site admin disables the general CSP, then we don't use it
|
||||||
}
|
}
|
||||||
@@ -130,16 +120,24 @@ func (c TemplateContext) HeadMetaContentSecurityPolicy() template.HTML {
|
|||||||
// * Browsers will merge and use the stricter rules between Gitea and reverse proxy
|
// * Browsers will merge and use the stricter rules between Gitea and reverse proxy
|
||||||
// B. Introduce some config options in "app.ini"
|
// B. Introduce some config options in "app.ini"
|
||||||
// * Maybe this approach should be avoided, don't make the config system too complex, just let users use A
|
// * Maybe this approach should be avoided, don't make the config system too complex, just let users use A
|
||||||
return template.HTML(`<meta http-equiv="Content-Security-Policy" content="` +
|
|
||||||
// allow all by default (the same as old releases with no CSP)
|
// allow all by default (the same as old releases with no CSP)
|
||||||
// * maybe some images or markup (external) renders need "data:", need to investigate
|
// * maybe some images or markup (external) renders need "data:", need to investigate
|
||||||
// * avatar upload editor needs "blob:", at least "img-src" and "content-src"
|
// * avatar upload editor needs "blob:", at least "img-src" and "content-src"
|
||||||
`default-src * data: blob:;` +
|
return `default-src * data: blob:;` +
|
||||||
|
|
||||||
// enforce nonce for all scripts, disallow inline scripts
|
// enforce nonce for all scripts, disallow inline scripts
|
||||||
`script-src * 'nonce-` + c.CspScriptNonce() + `';` +
|
`script-src * 'nonce-` + scriptNonce + `';` +
|
||||||
|
|
||||||
// it seems that Vue needs the unsafe-inline, and our custom colors (e.g.: label) also need it
|
// it seems that Vue needs the unsafe-inline, and our custom colors (e.g.: label) also need it
|
||||||
`style-src * 'unsafe-inline';` +
|
`style-src * 'unsafe-inline';`
|
||||||
`">`)
|
}
|
||||||
|
|
||||||
|
func (c TemplateContext) HeadMetaContentSecurityPolicy() template.HTML {
|
||||||
|
scriptNonce := c.CspScriptNonce()
|
||||||
|
csp := WebContentSecurityPolicy(scriptNonce)
|
||||||
|
if csp == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return htmlutil.HTMLFormat(`<meta http-equiv="Content-Security-Policy" content="%s">`, csp)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dev/modules/reqctx"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/test"
|
"gitea.dev/modules/test"
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@ func TestAppFullLink(t *testing.T) {
|
|||||||
defer test.MockVariableValue(&setting.PublicURLDetection, setting.PublicURLNever)()
|
defer test.MockVariableValue(&setting.PublicURLDetection, setting.PublicURLNever)()
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "https://gitea.example.com/sub/", nil)
|
req := httptest.NewRequest(http.MethodGet, "https://gitea.example.com/sub/", nil)
|
||||||
tmplCtx := NewTemplateContext(req.Context(), req)
|
tmplCtx := NewTemplateContext(reqctx.NewRequestContextForTest(req.Context()), req)
|
||||||
|
|
||||||
assert.Equal(t, "https://gitea.example.com/sub", string(tmplCtx.AppFullLink()))
|
assert.Equal(t, "https://gitea.example.com/sub", string(tmplCtx.AppFullLink()))
|
||||||
assert.Equal(t, "https://gitea.example.com/sub/user/repo", string(tmplCtx.AppFullLink("user/repo")))
|
assert.Equal(t, "https://gitea.example.com/sub/user/repo", string(tmplCtx.AppFullLink("user/repo")))
|
||||||
|
|||||||
@@ -372,9 +372,6 @@ func UploadHandler(ctx *context.Context) {
|
|||||||
log.Error("Error whilst uploadOrVerify LFS OID[%s]: %v", p.Oid, err)
|
log.Error("Error whilst uploadOrVerify LFS OID[%s]: %v", p.Oid, err)
|
||||||
writeStatus(ctx, http.StatusInternalServerError)
|
writeStatus(ctx, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
if _, err = git_model.RemoveLFSMetaObjectByOid(ctx, repository.ID, p.Oid); err != nil {
|
|
||||||
log.Error("Error whilst removing MetaObject for LFS OID[%s]: %v", p.Oid, err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -152,12 +152,16 @@ func (g *GithubDownloaderV3) waitAndPickClient(ctx context.Context) {
|
|||||||
var recentIdx int
|
var recentIdx int
|
||||||
var maxRemaining int
|
var maxRemaining int
|
||||||
for i := 0; i < len(g.clients); i++ {
|
for i := 0; i < len(g.clients); i++ {
|
||||||
if g.rates[i] != nil && g.rates[i].Remaining > maxRemaining {
|
if g.rates[i] == nil { // probe unknown clients once, else their rate never gets learned
|
||||||
|
g.curClientIdx = i
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if g.rates[i].Remaining > maxRemaining {
|
||||||
maxRemaining = g.rates[i].Remaining
|
maxRemaining = g.rates[i].Remaining
|
||||||
recentIdx = i
|
recentIdx = i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
g.curClientIdx = recentIdx // if no max remain, it will always pick the first client.
|
g.curClientIdx = recentIdx
|
||||||
|
|
||||||
for g.rates[g.curClientIdx] != nil && g.rates[g.curClientIdx].Remaining <= GithubLimitRateRemaining {
|
for g.rates[g.curClientIdx] != nil && g.rates[g.curClientIdx].Remaining <= GithubLimitRateRemaining {
|
||||||
timer := time.NewTimer(time.Until(g.rates[g.curClientIdx].Reset.Time))
|
timer := time.NewTimer(time.Until(g.rates[g.curClientIdx].Reset.Time))
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"gitea.dev/models/unittest"
|
"gitea.dev/models/unittest"
|
||||||
base "gitea.dev/modules/migration"
|
base "gitea.dev/modules/migration"
|
||||||
|
|
||||||
|
"github.com/google/go-github/v88/github"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -473,3 +474,25 @@ func TestGithubMultiToken(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGithubMultiTokenClientSelection(t *testing.T) {
|
||||||
|
downloader := &GithubDownloaderV3{
|
||||||
|
clients: make([]*github.Client, 3),
|
||||||
|
rates: make([]*github.Rate, 3),
|
||||||
|
}
|
||||||
|
|
||||||
|
downloader.waitAndPickClient(t.Context())
|
||||||
|
assert.Equal(t, 0, downloader.curClientIdx)
|
||||||
|
|
||||||
|
downloader.rates[0] = &github.Rate{Remaining: 100}
|
||||||
|
downloader.waitAndPickClient(t.Context())
|
||||||
|
assert.Equal(t, 1, downloader.curClientIdx)
|
||||||
|
|
||||||
|
downloader.rates[1] = &github.Rate{Remaining: 200}
|
||||||
|
downloader.waitAndPickClient(t.Context())
|
||||||
|
assert.Equal(t, 2, downloader.curClientIdx)
|
||||||
|
|
||||||
|
downloader.rates[2] = &github.Rate{Remaining: 50}
|
||||||
|
downloader.waitAndPickClient(t.Context())
|
||||||
|
assert.Equal(t, 1, downloader.curClientIdx)
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -360,9 +361,16 @@ type keyValue struct {
|
|||||||
Value string
|
Value string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pacman parses the index line by line, so a value with a newline could forge extra fields
|
||||||
|
func joinFields(values []string) string {
|
||||||
|
return strings.Join(slices.DeleteFunc(slices.Clone(values), func(value string) bool {
|
||||||
|
return strings.ContainsAny(value, "\n\r")
|
||||||
|
}), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
func writeFiles(tw *tar.Writer, opts *entryOptions) error {
|
func writeFiles(tw *tar.Writer, opts *entryOptions) error {
|
||||||
return writeFields(tw, fmt.Sprintf("%s-%s/files", opts.Package.Name, opts.Version.Version), []keyValue{
|
return writeFields(tw, fmt.Sprintf("%s-%s/files", opts.Package.Name, opts.Version.Version), []keyValue{
|
||||||
{"FILES", strings.Join(opts.FileMetadata.Files, "\n")},
|
{"FILES", joinFields(opts.FileMetadata.Files)},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,17 +389,17 @@ func writeDescription(tw *tar.Writer, opts *entryOptions) error {
|
|||||||
{"VERSION", opts.Version.Version},
|
{"VERSION", opts.Version.Version},
|
||||||
{"DESC", opts.VersionMetadata.Description},
|
{"DESC", opts.VersionMetadata.Description},
|
||||||
{"URL", opts.VersionMetadata.ProjectURL},
|
{"URL", opts.VersionMetadata.ProjectURL},
|
||||||
{"LICENSE", strings.Join(opts.VersionMetadata.Licenses, "\n")},
|
{"LICENSE", joinFields(opts.VersionMetadata.Licenses)},
|
||||||
{"GROUPS", strings.Join(opts.FileMetadata.Groups, "\n")},
|
{"GROUPS", joinFields(opts.FileMetadata.Groups)},
|
||||||
{"BUILDDATE", strconv.FormatInt(opts.FileMetadata.BuildDate, 10)},
|
{"BUILDDATE", strconv.FormatInt(opts.FileMetadata.BuildDate, 10)},
|
||||||
{"PACKAGER", opts.FileMetadata.Packager},
|
{"PACKAGER", opts.FileMetadata.Packager},
|
||||||
{"PROVIDES", strings.Join(opts.FileMetadata.Provides, "\n")},
|
{"PROVIDES", joinFields(opts.FileMetadata.Provides)},
|
||||||
{"REPLACES", strings.Join(opts.FileMetadata.Replaces, "\n")},
|
{"REPLACES", joinFields(opts.FileMetadata.Replaces)},
|
||||||
{"CONFLICTS", strings.Join(opts.FileMetadata.Conflicts, "\n")},
|
{"CONFLICTS", joinFields(opts.FileMetadata.Conflicts)},
|
||||||
{"DEPENDS", strings.Join(opts.FileMetadata.Depends, "\n")},
|
{"DEPENDS", joinFields(opts.FileMetadata.Depends)},
|
||||||
{"OPTDEPENDS", strings.Join(opts.FileMetadata.OptDepends, "\n")},
|
{"OPTDEPENDS", joinFields(opts.FileMetadata.OptDepends)},
|
||||||
{"MAKEDEPENDS", strings.Join(opts.FileMetadata.MakeDepends, "\n")},
|
{"MAKEDEPENDS", joinFields(opts.FileMetadata.MakeDepends)},
|
||||||
{"CHECKDEPENDS", strings.Join(opts.FileMetadata.CheckDepends, "\n")},
|
{"CHECKDEPENDS", joinFields(opts.FileMetadata.CheckDepends)},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package arch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestJoinFields(t *testing.T) {
|
||||||
|
values := []string{"usr/bin/a", "usr/bin/b\n\n%FILES%\netc/cron.d/x", "usr/bin/c"}
|
||||||
|
|
||||||
|
assert.Equal(t, "usr/bin/a\nusr/bin/c", joinFields(values))
|
||||||
|
assert.Len(t, values, 3) // must not modify the caller's slice
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user