mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-26 21:39:42 +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
|
||||
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
|
||||
|
||||
* SECURITY
|
||||
|
||||
@@ -87,6 +87,7 @@ require (
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/quasoft/websspi v1.1.2
|
||||
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/santhosh-tekuri/jsonschema/v6 v6.0.2
|
||||
github.com/sassoftware/go-rpmutils v0.4.0
|
||||
@@ -244,7 +245,6 @@ require (
|
||||
github.com/prometheus/common v0.68.1 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // 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/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
|
||||
@@ -9,10 +9,10 @@ package actions
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
@@ -147,7 +147,7 @@ type FindArtifactsOptions struct {
|
||||
db.ListOptions
|
||||
RepoID int64
|
||||
RunID int64
|
||||
RunAttemptID optional.Option[int64] // use optional to allow filtering by zero (legacy artifacts have run_attempt_id=0)
|
||||
RunAttemptIDs []int64 // empty means every attempt; pass 0 to target legacy artifacts, which have run_attempt_id=0
|
||||
ArtifactName string
|
||||
Status int
|
||||
FinalizedArtifactsV4 bool
|
||||
@@ -167,8 +167,8 @@ func (opts FindArtifactsOptions) ToConds() builder.Cond {
|
||||
if opts.RunID > 0 {
|
||||
cond = cond.And(builder.Eq{"run_id": opts.RunID})
|
||||
}
|
||||
if opts.RunAttemptID.Has() {
|
||||
cond = cond.And(builder.Eq{"run_attempt_id": opts.RunAttemptID.Value()})
|
||||
if len(opts.RunAttemptIDs) > 0 {
|
||||
cond = cond.And(builder.In("run_attempt_id", opts.RunAttemptIDs))
|
||||
}
|
||||
if opts.ArtifactName != "" {
|
||||
cond = cond.And(builder.Eq{"artifact_name": opts.ArtifactName})
|
||||
@@ -185,6 +185,27 @@ func (opts FindArtifactsOptions) ToConds() builder.Cond {
|
||||
return cond
|
||||
}
|
||||
|
||||
// FindReadableArtifacts returns the artifacts of opts.RunAttemptIDs, only keeps the ones from a newer attempt.
|
||||
func FindReadableArtifacts(ctx context.Context, opts FindArtifactsOptions) ([]*ActionArtifact, error) {
|
||||
arts, err := db.Find[ActionArtifact](ctx, opts)
|
||||
if err != nil || len(opts.RunAttemptIDs) <= 1 {
|
||||
return arts, err
|
||||
}
|
||||
return keepLatestAttemptArtifacts(arts), nil
|
||||
}
|
||||
|
||||
// keepLatestAttemptArtifacts keeps, per name, only the artifacts of the newest attempt that has it.
|
||||
// A v3 artifact is one row per uploaded file, so the whole group of the winning attempt is kept.
|
||||
func keepLatestAttemptArtifacts(arts []*ActionArtifact) []*ActionArtifact {
|
||||
latest := make(map[string]int64)
|
||||
for _, art := range arts {
|
||||
latest[art.ArtifactName] = max(latest[art.ArtifactName], art.RunAttemptID)
|
||||
}
|
||||
return slices.DeleteFunc(arts, func(art *ActionArtifact) bool {
|
||||
return art.RunAttemptID != latest[art.ArtifactName]
|
||||
})
|
||||
}
|
||||
|
||||
// ActionArtifactMeta is the meta-data of an artifact
|
||||
type ActionArtifactMeta struct {
|
||||
ArtifactName string
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestKeepLatestAttemptArtifacts(t *testing.T) {
|
||||
arts := []*ActionArtifact{
|
||||
{ID: 1, RunAttemptID: 1, ArtifactName: "inherited"},
|
||||
{ID: 2, RunAttemptID: 1, ArtifactName: "shadowed", ArtifactPath: "a.txt"},
|
||||
{ID: 3, RunAttemptID: 1, ArtifactName: "shadowed", ArtifactPath: "b.txt"},
|
||||
{ID: 4, RunAttemptID: 2, ArtifactName: "shadowed", ArtifactPath: "c.txt"},
|
||||
{ID: 5, RunAttemptID: 2, ArtifactName: "own"},
|
||||
}
|
||||
|
||||
// the whole "shadowed" group of attempt 1 is dropped, its multi-file rows must not mix with attempt 2
|
||||
var ids []int64
|
||||
for _, art := range keepLatestAttemptArtifacts(arts) {
|
||||
ids = append(ids, art.ID)
|
||||
}
|
||||
assert.Equal(t, []int64{1, 4, 5}, ids)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
@@ -96,6 +97,56 @@ func GetRunAttemptByRunIDAndAttemptNum(ctx context.Context, runID, attemptNum in
|
||||
return &attempt, nil
|
||||
}
|
||||
|
||||
// GetArtifactAttemptIDs returns the IDs of the attempts whose artifacts the job may read, newest first,
|
||||
// always including the job's own attempt.
|
||||
// An attempt that re-ran only some of the run's jobs keeps the artifacts of the attempt it re-ran from,
|
||||
// because the jobs it passed through never upload them again; a rerun of the whole run starts over.
|
||||
func GetArtifactAttemptIDs(ctx context.Context, job *ActionRunJob) ([]int64, error) {
|
||||
if job.Attempt <= 1 || job.RunAttemptID == 0 {
|
||||
return []int64{job.RunAttemptID}, nil
|
||||
}
|
||||
|
||||
attempts, err := ListRunAttemptsByRunID(ctx, job.RunID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// a newer attempt is never readable, and attempt 1 has nothing older to continue into
|
||||
candidateIDs := container.FilterSlice(attempts, func(a *ActionRunAttempt) (int64, bool) {
|
||||
return a.ID, a.Attempt > 1 && a.Attempt <= job.Attempt
|
||||
})
|
||||
passThroughAttemptIDs, err := findPassThroughAttemptIDs(ctx, candidateIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids := make([]int64, 0, len(attempts))
|
||||
for _, attempt := range attempts {
|
||||
if attempt.Attempt > job.Attempt {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, attempt.ID)
|
||||
if !slices.Contains(passThroughAttemptIDs, attempt.ID) {
|
||||
// stops at the first attempt that passed no job through
|
||||
break
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// findPassThroughAttemptIDs narrows the given attempts to those that were a rerun of selected jobs:
|
||||
// only such a rerun clones jobs carrying a source task.
|
||||
// TODO: best-effort. Needs a better way to distinguish between "partial re-run" and "full re-run".
|
||||
func findPassThroughAttemptIDs(ctx context.Context, attemptIDs []int64) ([]int64, error) {
|
||||
passThroughAttemptIDs := make([]int64, 0, len(attemptIDs))
|
||||
return passThroughAttemptIDs, db.GetEngine(ctx).
|
||||
Table("action_run_job").
|
||||
Cols("run_attempt_id").
|
||||
In("run_attempt_id", attemptIDs).
|
||||
Where("source_task_id <> 0").
|
||||
Distinct("run_attempt_id").
|
||||
Find(&passThroughAttemptIDs)
|
||||
}
|
||||
|
||||
// FindConcurrentRunAttempts returns attempts in the given concurrency group and status set.
|
||||
// Results are unordered; callers must not depend on any particular row order.
|
||||
func FindConcurrentRunAttempts(ctx context.Context, repoID int64, concurrencyGroup string, statuses []Status) ([]*ActionRunAttempt, error) {
|
||||
|
||||
+75
-47
@@ -448,58 +448,74 @@ func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, col
|
||||
return affected, RefreshReusableCallerStatus(ctx, parent)
|
||||
}
|
||||
|
||||
{
|
||||
// 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 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)
|
||||
}
|
||||
}
|
||||
if err := refreshRunStatus(ctx, job.RepoID, job.RunID, job.RunAttemptID, StatusUnknown); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
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.
|
||||
// No-op if caller is not a reusable caller.
|
||||
//
|
||||
@@ -660,6 +676,8 @@ func CancelPreviousJobsByJobConcurrency(ctx context.Context, job *ActionRunJob)
|
||||
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) {
|
||||
cancelledJobs := make([]*ActionRunJob, 0, len(jobs))
|
||||
|
||||
@@ -684,6 +702,16 @@ func CancelJobs(ctx context.Context, jobs []*ActionRunJob) ([]*ActionRunJob, err
|
||||
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
|
||||
func cancelOneJob(ctx context.Context, job *ActionRunJob) (*ActionRunJob, error) {
|
||||
if job.Status.IsDone() {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -197,3 +198,91 @@ func TestCancelJobs_NestedBlockedReusableCaller(t *testing.T) {
|
||||
gotRun := unittest.AssertExistsAndLoadBean(t, &ActionRun{ID: run.ID})
|
||||
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
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
} else if !has {
|
||||
@@ -543,6 +543,10 @@ func UpdateTaskByState(ctx context.Context, runnerID int64, state *runnerv1.Task
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||
@@ -17,6 +20,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
"xorm.io/xorm/contexts"
|
||||
)
|
||||
|
||||
func TestActionTask_GetRunJobLink(t *testing.T) {
|
||||
@@ -357,6 +361,35 @@ func TestCreateTaskForRunnerPagination(t *testing.T) {
|
||||
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,
|
||||
// which is the state every cancellation test starts from.
|
||||
func newRunningTaskForCancelling(t *testing.T, name string, hasCancellingSupport bool) (*ActionTask, *ActionRunJob) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
|
||||
"gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/log"
|
||||
)
|
||||
@@ -33,11 +34,18 @@ func BuildAvatarStackData(ctx context.Context, allParticipants []*git.CommitIden
|
||||
ret := &AvatarStackData{
|
||||
Participants: make([]*CommitParticipant, 0, len(allParticipants)),
|
||||
}
|
||||
uniqueUserIDs := make(container.Set[int64])
|
||||
for _, p := range allParticipants {
|
||||
var giteaUser *user.User
|
||||
if emailUserMap != nil {
|
||||
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})
|
||||
}
|
||||
return ret
|
||||
|
||||
@@ -39,8 +39,7 @@ func GetUserCommitsByGitCommits(ctx context.Context, gitCommits []*git.Commit, r
|
||||
emailSet := make(container.Set[string])
|
||||
for _, c := range gitCommits {
|
||||
emailSet.Add(c.Author.Email)
|
||||
emailSet.Add(c.Committer.Email)
|
||||
for _, p := range c.AllParticipantIdentities() {
|
||||
for _, p := range c.AllAuthorIdentities() {
|
||||
emailSet.Add(p.Email)
|
||||
}
|
||||
}
|
||||
@@ -55,7 +54,7 @@ func GetUserCommitsByGitCommits(ctx context.Context, gitCommits []*git.Commit, r
|
||||
uc := &UserCommit{
|
||||
AuthorUser: emailUserMap.GetByEmail(c.Author.Email), // FIXME: why GetUserCommitsByGitCommits uses "Author", but ParseCommitsWithSignature uses "Committer"?
|
||||
GitCommit: c,
|
||||
AvatarStackData: BuildAvatarStackData(ctx, c.AllParticipantIdentities(), emailUserMap),
|
||||
AvatarStackData: BuildAvatarStackData(ctx, c.AllAuthorIdentities(), emailUserMap),
|
||||
}
|
||||
uc.AvatarStackData.SearchByEmailLink = searchByEmailLink
|
||||
userCommits = append(userCommits, uc)
|
||||
|
||||
@@ -7,41 +7,45 @@ import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/log"
|
||||
)
|
||||
|
||||
type commitChecker struct {
|
||||
ctx context.Context
|
||||
commitCache map[string]bool
|
||||
gitRepoFacade gitrepo.Repository
|
||||
ctx context.Context
|
||||
commitCache map[string]bool
|
||||
repoOptional *repo_model.Repository
|
||||
|
||||
gitRepo *git.Repository
|
||||
gitRepoCloser io.Closer
|
||||
}
|
||||
|
||||
func newCommitChecker(ctx context.Context, gitRepo gitrepo.Repository) *commitChecker {
|
||||
return &commitChecker{ctx: ctx, commitCache: make(map[string]bool), gitRepoFacade: gitRepo}
|
||||
func newCommitChecker(ctx context.Context, repo *repo_model.Repository) *commitChecker {
|
||||
return &commitChecker{ctx: ctx, commitCache: make(map[string]bool), repoOptional: repo}
|
||||
}
|
||||
|
||||
func (c *commitChecker) Close() error {
|
||||
if c != nil && c.gitRepoCloser != nil {
|
||||
if c.gitRepoCloser != nil {
|
||||
return c.gitRepoCloser.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *commitChecker) IsCommitIDExisting(commitID string) bool {
|
||||
if c.repoOptional == nil {
|
||||
return false
|
||||
}
|
||||
exist, inCache := c.commitCache[commitID]
|
||||
if inCache {
|
||||
return exist
|
||||
}
|
||||
|
||||
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 {
|
||||
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
|
||||
}
|
||||
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)}
|
||||
rctx := markup.NewRenderContext(ctx)
|
||||
helper.ctx = rctx
|
||||
helper.commitChecker = newCommitChecker(ctx, repo)
|
||||
var metas map[string]string
|
||||
if repo != nil {
|
||||
helper.repoLink = repo.Link()
|
||||
helper.commitChecker = newCommitChecker(ctx, repo)
|
||||
metas = repo.ComposeCommentMetas(ctx)
|
||||
} else {
|
||||
// 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:
|
||||
finalLink = r.ctx.ResolveLinkRoot(link)
|
||||
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:
|
||||
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:
|
||||
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
|
||||
}
|
||||
@@ -58,9 +58,9 @@ func NewRenderContextRepoFile(ctx context.Context, repo *repo_model.Repository,
|
||||
helper := &RepoFile{opts: util.OptionalArg(opts)}
|
||||
rctx := markup.NewRenderContext(ctx)
|
||||
helper.ctx = rctx
|
||||
helper.commitChecker = newCommitChecker(ctx, repo)
|
||||
if repo != nil {
|
||||
helper.repoLink = repo.Link()
|
||||
helper.commitChecker = newCommitChecker(ctx, repo)
|
||||
rctx = rctx.WithMetas(repo.ComposeRepoFileMetas(ctx))
|
||||
} else {
|
||||
// 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) {
|
||||
rctx := NewRenderContextRepoFile(t.Context(), repo1, RepoFileOptions{
|
||||
CurrentRefSubURL: "/commit/1234",
|
||||
CurrentTreePath: "my-dir",
|
||||
CurrentTreePath: "my dir",
|
||||
}).
|
||||
WithMarkupType(markdown.MarkupName)
|
||||
rendered, err := testRenderString(rctx, `
|
||||
@@ -76,8 +76,8 @@ func TestRepoFile(t *testing.T) {
|
||||
<video src="LINK">
|
||||
`)
|
||||
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>
|
||||
<video src="/user2/repo1/media/commit/1234/my-dir/LINK">
|
||||
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%20dir/LINK">
|
||||
</video>`, rendered)
|
||||
})
|
||||
}
|
||||
@@ -89,8 +89,8 @@ func TestRepoFileOrgMode(t *testing.T) {
|
||||
t.Run("Links", func(t *testing.T) {
|
||||
rctx := NewRenderContextRepoFile(t.Context(), repo1, RepoFileOptions{
|
||||
CurrentRefSubURL: "/commit/1234",
|
||||
CurrentTreePath: "my-dir",
|
||||
}).WithRelativePath("my-dir/a.org")
|
||||
CurrentTreePath: "my dir",
|
||||
}).WithRelativePath("my dir/a.org")
|
||||
|
||||
rendered, err := testRenderString(rctx, `
|
||||
[[https://google.com/]]
|
||||
@@ -99,7 +99,7 @@ func TestRepoFileOrgMode(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, `<p>
|
||||
<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)
|
||||
})
|
||||
|
||||
|
||||
@@ -36,9 +36,9 @@ func (r *RepoWiki) ResolveLink(link, preferLinkType string) (finalLink string) {
|
||||
case markup.LinkTypeRoot:
|
||||
finalLink = r.ctx.ResolveLinkRoot(link)
|
||||
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:
|
||||
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
|
||||
}
|
||||
@@ -57,9 +57,9 @@ type RepoWikiOptions struct {
|
||||
func NewRenderContextRepoWiki(ctx context.Context, repo *repo_model.Repository, opts ...RepoWikiOptions) *markup.RenderContext {
|
||||
helper := &RepoWiki{opts: util.OptionalArg(opts)}
|
||||
rctx := markup.NewRenderContext(ctx).WithMarkupType(markdown.MarkupName)
|
||||
helper.commitChecker = newCommitChecker(ctx, repo)
|
||||
if repo != nil {
|
||||
helper.repoLink = repo.Link()
|
||||
helper.commitChecker = newCommitChecker(ctx, repo)
|
||||
rctx = rctx.WithMetas(repo.ComposeWikiMetas(ctx))
|
||||
} else {
|
||||
// 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) {
|
||||
rctx := NewRenderContextRepoWiki(t.Context(), repo1).WithMarkupType(markdown.MarkupName)
|
||||
rctx := NewRenderContextRepoWiki(t.Context(), repo1, RepoWikiOptions{
|
||||
currentTreePath: "my dir",
|
||||
}).WithMarkupType(markdown.MarkupName)
|
||||
rendered, err := testRenderString(rctx, `
|
||||
<img src="LINK">
|
||||
<video src="LINK">
|
||||
`)
|
||||
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>
|
||||
<video src="/user2/repo1/wiki/raw/LINK">
|
||||
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/my%20dir/LINK">
|
||||
</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})
|
||||
}
|
||||
|
||||
// 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
|
||||
func IsOwnerMemberCollaborator(ctx context.Context, repo *Repository, userID int64) (bool, error) {
|
||||
if repo.OwnerID == userID {
|
||||
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/perm"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
|
||||
@@ -69,28 +67,6 @@ func TestRepository_IsCollaborator(t *testing.T) {
|
||||
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) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
|
||||
@@ -6,7 +6,10 @@ package jobparser
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/act/exprparser"
|
||||
@@ -23,12 +26,6 @@ func NewExpressionEvaluator(interpreter exprparser.Interpreter) *ExpressionEvalu
|
||||
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 {
|
||||
var in string
|
||||
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, "}}") {
|
||||
return nil
|
||||
}
|
||||
expr, _ := rewriteSubExpression(in, false)
|
||||
res, err := ee.evaluate(expr, exprparser.DefaultStatusCheckNone)
|
||||
res, err := ee.evaluateScalar(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
// 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; {
|
||||
k := node.Content[i*2]
|
||||
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 {
|
||||
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
||||
return in
|
||||
}
|
||||
|
||||
expr, _ := rewriteSubExpression(in, true)
|
||||
evaluated, err := ee.evaluate(expr, exprparser.DefaultStatusCheckNone)
|
||||
// interpolate evaluates every part on its own, so a malformed one cannot restructure its neighbours
|
||||
func (ee ExpressionEvaluator) interpolate(in string) (string, error) {
|
||||
parts, err := splitSubExpressions(in)
|
||||
if err != nil {
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
|
||||
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, "}}") {
|
||||
if len(parts) == 1 && !parts[0].isExpr {
|
||||
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("(?:''|[^'])*'")
|
||||
pos := 0
|
||||
exprStart := -1
|
||||
strStart := -1
|
||||
var results []string
|
||||
var formatOut strings.Builder
|
||||
for pos < len(in) {
|
||||
if strStart > -1 {
|
||||
matches := strPattern.FindStringIndex(in[pos:])
|
||||
if matches == nil {
|
||||
return "", errors.New("unclosed string")
|
||||
}
|
||||
// evaluateScalar keeps the type of a lone expression, so `${{ fromJSON('[1,2]') }}` stays an array
|
||||
func (ee ExpressionEvaluator) evaluateScalar(in string) (any, error) {
|
||||
parts, err := splitSubExpressions(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(parts) == 1 && parts[0].isExpr {
|
||||
return ee.interpreter.Evaluate(parts[0].text, exprparser.DefaultStatusCheckNone)
|
||||
}
|
||||
return ee.interpolate(in)
|
||||
}
|
||||
|
||||
strStart = -1
|
||||
pos += matches[1]
|
||||
} else if exprStart > -1 {
|
||||
exprEnd := strings.Index(in[pos:], "}}")
|
||||
strStart = strings.Index(in[pos:], "'")
|
||||
// evaluateCondition evaluates an `if:`, an expression even without `${{ }}`. Mixed content
|
||||
// interpolates to a string, so the success() default applies to it separately.
|
||||
func (ee ExpressionEvaluator) evaluateCondition(in string) (bool, error) {
|
||||
parts, err := splitSubExpressions(in)
|
||||
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 {
|
||||
if exprEnd < strStart {
|
||||
strStart = -1
|
||||
} else {
|
||||
exprEnd = -1
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
// mixed content is a string, so the success() default applies to it separately
|
||||
if !expressionCallsFunction(in, "success", "always", "failure", "cancelled") {
|
||||
status, err := ee.interpreter.Evaluate("success()", exprparser.DefaultStatusCheckNone)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !exprparser.IsTruthy(status) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
interpolated, err := ee.interpolate(in)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return exprparser.IsTruthy(interpolated), nil
|
||||
}
|
||||
|
||||
if len(results) == 1 && formatOut.String() == "{0}" && !forceFormat {
|
||||
return in, nil
|
||||
// coerceToString converts an evaluated expression value to a string the way GitHub does,
|
||||
// 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, ", "))
|
||||
return out, nil
|
||||
switch value.Kind() {
|
||||
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 (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/act/exprparser"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"github.com/rhysd/actionlint"
|
||||
"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{}))
|
||||
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 {
|
||||
job := jobs[i]
|
||||
@@ -63,10 +67,14 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
|
||||
}
|
||||
job.Strategy.RawMatrix = encodeMatrix(matrix)
|
||||
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()
|
||||
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)
|
||||
if err := evaluator.EvaluateYamlNode(&job.RawContinueOnError); err != nil {
|
||||
@@ -150,16 +158,45 @@ func encodeRunsOn(runsOn []string) yaml.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 {
|
||||
return name
|
||||
return name, nil
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"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"
|
||||
"fmt"
|
||||
|
||||
"gitea.com/gitea/runner/act/exprparser"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
@@ -32,6 +33,11 @@ func (w *SingleWorkflow) Job() (string, *Job) {
|
||||
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) {
|
||||
ids, jobs, err := parseMappingNode[*Job](&w.RawJobs)
|
||||
if err != nil {
|
||||
@@ -292,7 +298,7 @@ func EvaluateConcurrency(rc *model.RawConcurrency, jobID string, job *Job, gitCt
|
||||
if evaluated.RawExpression != "" {
|
||||
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 {
|
||||
@@ -518,15 +524,7 @@ func EvaluateJobIfExpression(jobID string, job *Job, gitCtx map[string]any, resu
|
||||
matrix = matrixes[0]
|
||||
}
|
||||
evaluator := NewExpressionEvaluator(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs))
|
||||
expr, err := rewriteSubExpression(job.If.Value, false)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
result, err := evaluator.evaluate(expr, exprparser.DefaultStatusCheckSuccess)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return exprparser.IsTruthy(result), nil
|
||||
return evaluator.evaluateCondition(job.If.Value)
|
||||
}
|
||||
|
||||
// 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: "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},
|
||||
// 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 {
|
||||
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) {
|
||||
switch typ {
|
||||
case InputTypeString:
|
||||
return toString(v), nil
|
||||
return coerceToString(v), nil
|
||||
case InputTypeBoolean:
|
||||
// strict type matching: a boolean input only accepts a native bool, not a "true"/"false" string
|
||||
if b, ok := v.(bool); ok {
|
||||
@@ -361,11 +361,11 @@ func EvaluateWorkflowCallOutputs(spec *WorkflowCallSpec, gitCtx *model.GithubCon
|
||||
Vars: vars,
|
||||
Inputs: inputs,
|
||||
}
|
||||
interpreter := exprparser.NewInterpeter(env, exprparser.Config{})
|
||||
evaluator := NewExpressionEvaluator(exprparser.NewInterpeter(env, exprparser.Config{}))
|
||||
|
||||
out := make(map[string]string, len(spec.Outputs))
|
||||
for name, o := range spec.Outputs {
|
||||
v, err := evaluateWorkflowCallOutputValue(interpreter, o.Value)
|
||||
v, err := evaluator.interpolate(o.Value)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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).
|
||||
func MatchScopedWorkflows(
|
||||
parsed []*ParsedScopedWorkflow,
|
||||
sourceCommitSHA string,
|
||||
consumerGitRepo *git.Repository,
|
||||
consumerCommit *git.Commit,
|
||||
triggedEvent webhook_module.HookEventType,
|
||||
@@ -71,9 +72,10 @@ func MatchScopedWorkflows(
|
||||
continue
|
||||
}
|
||||
dwf := &DetectedWorkflow{
|
||||
EntryName: p.EntryName,
|
||||
TriggerEvent: evt,
|
||||
Content: p.Content,
|
||||
EntryName: p.EntryName,
|
||||
TriggerEvent: evt,
|
||||
Content: p.Content,
|
||||
SourceCommitSHA: sourceCommitSHA,
|
||||
}
|
||||
switch detectWorkflowMatch(consumerGitRepo, consumerCommit, triggedEvent, payload, evt) {
|
||||
case detectMatched:
|
||||
|
||||
@@ -28,6 +28,8 @@ type DetectedWorkflow struct {
|
||||
EntryName string
|
||||
TriggerEvent *jobparser.Event
|
||||
Content []byte
|
||||
// SourceCommitSHA is the commit Content was read from, and must always be filled in together with Content.
|
||||
SourceCommitSHA string
|
||||
}
|
||||
|
||||
type detectResult int
|
||||
@@ -203,17 +205,19 @@ func DetectWorkflows(
|
||||
if evt.IsSchedule() {
|
||||
if detectSchedule {
|
||||
dwf := &DetectedWorkflow{
|
||||
EntryName: entry.Name(),
|
||||
TriggerEvent: evt,
|
||||
Content: content,
|
||||
EntryName: entry.Name(),
|
||||
TriggerEvent: evt,
|
||||
Content: content,
|
||||
SourceCommitSHA: commit.ID.String(),
|
||||
}
|
||||
schedules = append(schedules, dwf)
|
||||
}
|
||||
} else {
|
||||
dwf := &DetectedWorkflow{
|
||||
EntryName: entry.Name(),
|
||||
TriggerEvent: evt,
|
||||
Content: content,
|
||||
EntryName: entry.Name(),
|
||||
TriggerEvent: evt,
|
||||
Content: content,
|
||||
SourceCommitSHA: commit.ID.String(),
|
||||
}
|
||||
switch detectWorkflowMatch(gitRepo, commit, triggedEvent, payload, evt) {
|
||||
case detectMatched:
|
||||
@@ -252,9 +256,10 @@ func DetectScheduledWorkflows(gitRepo *git.Repository, commit *git.Commit) ([]*D
|
||||
if evt.IsSchedule() {
|
||||
log.Trace("detect scheduled workflow: %q", entry.Name())
|
||||
dwf := &DetectedWorkflow{
|
||||
EntryName: entry.Name(),
|
||||
TriggerEvent: evt,
|
||||
Content: content,
|
||||
EntryName: entry.Name(),
|
||||
TriggerEvent: evt,
|
||||
Content: content,
|
||||
SourceCommitSHA: commit.ID.String(),
|
||||
}
|
||||
wfs = append(wfs, dwf)
|
||||
}
|
||||
|
||||
@@ -28,13 +28,10 @@ func Init() {
|
||||
|
||||
WebAuthn = &webauthn.WebAuthn{
|
||||
Config: &webauthn.Config{
|
||||
RPDisplayName: setting.AppName,
|
||||
RPID: setting.Domain,
|
||||
RPOrigins: []string{appURL},
|
||||
AuthenticatorSelection: protocol.AuthenticatorSelection{
|
||||
UserVerification: protocol.VerificationDiscouraged,
|
||||
},
|
||||
AttestationPreference: protocol.PreferDirectAttestation,
|
||||
RPDisplayName: setting.AppName,
|
||||
RPID: setting.Domain,
|
||||
RPOrigins: []string{appURL},
|
||||
AttestationPreference: protocol.PreferNoAttestation, // Gitea never verifies attestation
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,9 +39,7 @@ type CommitMessage struct {
|
||||
|
||||
trailerValues CommitMessageTrailerValues
|
||||
|
||||
allParticipants []*CommitIdentity
|
||||
committerCoAuthorIdx int
|
||||
committerCoAuthor *CommitIdentity
|
||||
allAuthors []*CommitIdentity
|
||||
}
|
||||
|
||||
func (c *CommitMessage) MessageUTF8() string {
|
||||
@@ -146,63 +144,50 @@ func CommitMessageParseTrailer(s string) CommitMessageTrailerValues {
|
||||
return ret
|
||||
}
|
||||
|
||||
// AllParticipantIdentities returns all the participants in the commit, the first one is the commit's author
|
||||
func (c *Commit) AllParticipantIdentities() []*CommitIdentity {
|
||||
if c.allParticipants != nil {
|
||||
return c.allParticipants
|
||||
// AllAuthorIdentities returns all the author and co-authors in the commit. Committer is not included:
|
||||
// * 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)
|
||||
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{}
|
||||
addParticipant := func(name, email string, role int) (existingRole int) {
|
||||
addAuthor := func(name, email string, role int) {
|
||||
if name == "" && email == "" {
|
||||
return 0
|
||||
return
|
||||
}
|
||||
emailLower := strings.ToLower(email)
|
||||
if existingRole = exclude[emailLower]; emailLower != "" && existingRole != 0 {
|
||||
return existingRole
|
||||
key := strings.ToLower(email)
|
||||
if key == "" {
|
||||
key = strings.ToLower(name)
|
||||
}
|
||||
c.allParticipants = append(c.allParticipants, &CommitIdentity{Name: name, Email: email, role: role})
|
||||
exclude[emailLower] = role
|
||||
return 0
|
||||
if existingRole := exclude[key]; key != "" && existingRole != 0 {
|
||||
return
|
||||
}
|
||||
c.allAuthors = append(c.allAuthors, &CommitIdentity{Name: name, Email: email, role: role})
|
||||
exclude[key] = role
|
||||
}
|
||||
|
||||
c.committerCoAuthorIdx = -1
|
||||
addParticipant(c.Author.Name, c.Author.Email, commitIdentityRoleAuthor)
|
||||
addParticipant(c.Committer.Name, c.Committer.Email, commitIdentityRoleCommitter)
|
||||
for _, coAuthorValue := range c.MessageTrailer()["co-authored-by"] {
|
||||
addAuthor(c.Author.Name, c.Author.Email, commitIdentityRoleAuthor)
|
||||
for _, coAuthorValue := range trailerCoAuthors {
|
||||
addr, err := mail.ParseAddress(coAuthorValue)
|
||||
coAuthorName, coAuthorEmail := coAuthorValue, ""
|
||||
if err == nil {
|
||||
coAuthorName, coAuthorEmail = addr.Name, addr.Address
|
||||
}
|
||||
existingRole := addParticipant(coAuthorName, coAuthorEmail, commitIdentityRoleCoAuthor)
|
||||
if existingRole == commitIdentityRoleCommitter && c.committerCoAuthorIdx == -1 {
|
||||
c.committerCoAuthorIdx = len(c.allParticipants)
|
||||
c.committerCoAuthor = &CommitIdentity{coAuthorName, coAuthorEmail, commitIdentityRoleCoAuthor}
|
||||
}
|
||||
addAuthor(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) {
|
||||
all := c.AllParticipantIdentities()
|
||||
if len(all) <= 1 {
|
||||
return nil // no co-author list
|
||||
all := c.AllAuthorIdentities()
|
||||
if len(all) == 0 {
|
||||
return nil
|
||||
}
|
||||
if all[1].role != commitIdentityRoleCommitter {
|
||||
return all[1:] // no committer, so all after author are co-authors
|
||||
if all[0].role == commitIdentityRoleAuthor {
|
||||
return all[1:]
|
||||
}
|
||||
if c.committerCoAuthorIdx == -1 {
|
||||
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
|
||||
return all
|
||||
}
|
||||
|
||||
@@ -52,41 +52,44 @@ func TestCommitMessageTrailer(t *testing.T) {
|
||||
func TestCommitMessageParticipants(t *testing.T) {
|
||||
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} }
|
||||
roleAuthor, roleCommitter, roleCoAuthor := commitIdentityRoleAuthor, commitIdentityRoleCommitter, commitIdentityRoleCoAuthor
|
||||
roleAuthor, _, roleCoAuthor := commitIdentityRoleAuthor, commitIdentityRoleCommitter, commitIdentityRoleCoAuthor
|
||||
type testCase struct {
|
||||
name string
|
||||
commit *Commit
|
||||
identities []*CommitIdentity
|
||||
}
|
||||
t.Run("AllParticipants", func(t *testing.T) {
|
||||
|
||||
t.Run("AllAuthors", func(t *testing.T) {
|
||||
cases := []testCase{
|
||||
{
|
||||
"DifferentUsers",
|
||||
"CommitterExcluded",
|
||||
&Commit{
|
||||
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{
|
||||
Author: sig("a", "a@m.com"), Committer: sig("a", "A@M.com"),
|
||||
CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: a@m.com"},
|
||||
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
|
||||
CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: other-name <a@m.com>"},
|
||||
},
|
||||
[]*CommitIdentity{idt("a", "a@m.com", roleAuthor)},
|
||||
},
|
||||
{
|
||||
"NoCommitter",
|
||||
"EmptyAuthor", // synthesized commits (push feed) may have no author signature at all
|
||||
&Commit{
|
||||
Author: sig("a", "a@m.com"), Committer: sig("", ""),
|
||||
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: Full Name <X@M.com>"},
|
||||
Author: sig("", ""), Committer: sig("", ""),
|
||||
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 {
|
||||
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) {
|
||||
@@ -116,12 +119,12 @@ func TestCommitMessageParticipants(t *testing.T) {
|
||||
[]*CommitIdentity{},
|
||||
},
|
||||
{
|
||||
"CoAuthorCommitterNameWithIndex", // restore the committer co-author to the co-author list by the index with correct name
|
||||
"CoAuthorNameOnlyAndDuplicate",
|
||||
&Commit{
|
||||
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 {
|
||||
|
||||
@@ -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.
|
||||
// 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.
|
||||
func performRequest(ctx context.Context, client *http.Client, req *http.Request) (*http.Response, error) {
|
||||
log.Trace("performRequest: %s", req.URL)
|
||||
@@ -264,7 +264,7 @@ func performRequest(ctx context.Context, client *http.Client, req *http.Request)
|
||||
return res, err
|
||||
}
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
defer res.Body.Close()
|
||||
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) {
|
||||
cases := []struct {
|
||||
link *Link
|
||||
|
||||
Vendored
+33
-22
@@ -5,11 +5,11 @@ package external
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/markup"
|
||||
@@ -91,52 +91,63 @@ func (p *Renderer) GetExternalRendererOptions() (ret markup.ExternalRendererOpti
|
||||
return ret
|
||||
}
|
||||
|
||||
func envMark(envName string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "%" + envName + "%"
|
||||
func (p *Renderer) prepareExternalCommand(vars map[string]string) (string, []string, error) {
|
||||
fields, err := shellquote.Split(strings.TrimSpace(p.Command))
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return "$" + envName
|
||||
if len(fields) == 0 {
|
||||
return "", nil, errors.New("no command")
|
||||
}
|
||||
var replacements []string
|
||||
for k, v := range vars {
|
||||
replacements = append(replacements, "$"+k, v)
|
||||
replacements = append(replacements, "%"+k+"%", v) // for legacy Windows-style support
|
||||
}
|
||||
r := strings.NewReplacer(replacements...)
|
||||
for i := range fields {
|
||||
fields[i] = r.Replace(fields[i])
|
||||
}
|
||||
return fields[0], fields[1:], nil
|
||||
}
|
||||
|
||||
// Render renders the data of the document to HTML via the external tool.
|
||||
func (p *Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
|
||||
baseLinkSrc := ctx.RenderHelper.ResolveLink("", markup.LinkTypeDefault)
|
||||
baseLinkRaw := ctx.RenderHelper.ResolveLink("", markup.LinkTypeRaw)
|
||||
command := strings.NewReplacer(
|
||||
envMark("GITEA_PREFIX_SRC"), baseLinkSrc,
|
||||
envMark("GITEA_PREFIX_RAW"), baseLinkRaw,
|
||||
).Replace(p.Command)
|
||||
commands, err := shellquote.Split(command)
|
||||
if err != nil || len(commands) == 0 {
|
||||
return fmt.Errorf("%s invalid command %q: %w", p.Name(), p.Command, err)
|
||||
cmdVars := map[string]string{
|
||||
"GITEA_PREFIX_SRC": baseLinkSrc,
|
||||
"GITEA_PREFIX_RAW": baseLinkRaw,
|
||||
}
|
||||
cmdProg, cmdArgs, err := p.prepareExternalCommand(cmdVars)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid external render (%s) command %q: %w", p.Name(), p.Command, err)
|
||||
}
|
||||
args := commands[1:]
|
||||
|
||||
if p.IsInputFile {
|
||||
// write to temp file
|
||||
f, cleanup, err := setting.AppDataTempDir("git-repo-content").CreateTempFileRandom("gitea_input")
|
||||
tmpFile, cleanup, err := setting.AppDataTempDir("git-repo-content").CreateTempFileRandom("gitea_input")
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s create temp file when rendering %s failed: %w", p.Name(), p.Command, err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
_, err = io.Copy(f, input)
|
||||
_, err = io.Copy(tmpFile, input)
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
_ = tmpFile.Close()
|
||||
return fmt.Errorf("%s write data to temp file when rendering %s failed: %w", p.Name(), p.Command, err)
|
||||
}
|
||||
|
||||
err = f.Close()
|
||||
err = tmpFile.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s close temp file when rendering %s failed: %w", p.Name(), p.Command, err)
|
||||
}
|
||||
args = append(args, f.Name())
|
||||
cmdArgs = append(cmdArgs, tmpFile.Name())
|
||||
}
|
||||
|
||||
processCtx, _, finished := process.GetManager().AddContext(ctx, fmt.Sprintf("Render [%s] for %s", commands[0], baseLinkSrc))
|
||||
processCtx, _, finished := process.GetManager().AddContext(ctx, fmt.Sprintf("Render [%s] for %s", cmdProg, baseLinkSrc))
|
||||
defer finished()
|
||||
|
||||
cmd := exec.CommandContext(processCtx, commands[0], args...)
|
||||
cmd := exec.CommandContext(processCtx, cmdProg, cmdArgs...)
|
||||
cmd.Env = append(
|
||||
os.Environ(),
|
||||
"GITEA_PREFIX_SRC="+baseLinkSrc,
|
||||
@@ -151,7 +162,7 @@ func (p *Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.
|
||||
process.SetSysProcAttribute(cmd)
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("%s render run command %s %v failed: %w\nStderr: %s", p.Name(), commands[0], args, err, stderr.String())
|
||||
return fmt.Errorf("%s render run command %s %v failed: %w\nStderr: %s", p.Name(), cmdProg, shellquote.Join(cmdArgs...), err, stderr.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package external
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPrepareExternalCommand(t *testing.T) {
|
||||
r := &Renderer{MarkupRenderer: &setting.MarkupRenderer{Command: ""}}
|
||||
_, _, err := r.prepareExternalCommand(map[string]string{"KEY": "val"})
|
||||
assert.ErrorContains(t, err, "no command")
|
||||
|
||||
r = &Renderer{MarkupRenderer: &setting.MarkupRenderer{Command: `"/foo bar/bin" --opt $KEY "$KEY" %KEY% other`}}
|
||||
prog, args, err := r.prepareExternalCommand(map[string]string{"KEY": `a"b`})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "/foo bar/bin", prog)
|
||||
assert.Equal(t, []string{"--opt", `a"b`, `a"b`, `a"b`, "other"}, args)
|
||||
}
|
||||
Vendored
+1
-8
@@ -21,19 +21,12 @@ type frontendRenderer struct {
|
||||
patterns []string
|
||||
}
|
||||
|
||||
var (
|
||||
_ markup.PostProcessRenderer = (*frontendRenderer)(nil)
|
||||
_ markup.ExternalRenderer = (*frontendRenderer)(nil)
|
||||
)
|
||||
var _ markup.ExternalRenderer = (*frontendRenderer)(nil)
|
||||
|
||||
func (p *frontendRenderer) Name() string {
|
||||
return p.name
|
||||
}
|
||||
|
||||
func (p *frontendRenderer) NeedPostProcess() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
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
|
||||
// There are some approaches to make it more accurate, but they are all complicated:
|
||||
|
||||
@@ -29,9 +29,8 @@ func init() {
|
||||
type renderer struct{}
|
||||
|
||||
var (
|
||||
_ 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.Renderer = (*renderer)(nil)
|
||||
_ markup.ExternalRenderer = (*renderer)(nil) // FIXME: this is not an external render, need to refactor the framework in the future
|
||||
)
|
||||
|
||||
type mimeHandler struct {
|
||||
@@ -96,8 +95,6 @@ func (renderer) Name() string {
|
||||
return "jupyter-render"
|
||||
}
|
||||
|
||||
func (renderer) NeedPostProcess() bool { return true }
|
||||
|
||||
func (renderer) GetExternalRendererOptions() 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.
|
||||
@@ -215,7 +212,7 @@ func renderCellCode(output htmlutil.HTMLWriter, cell Cell, language string) erro
|
||||
|
||||
// Highlight code
|
||||
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("</code></pre></div>")
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ func TestIntegrationAndSanitization(t *testing.T) {
|
||||
maliciousNotebook := `{
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 2,
|
||||
"metadata": {},
|
||||
"metadata": {"language_info":{"name":"any lang"}},
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
@@ -274,7 +274,7 @@ func TestIntegrationAndSanitization(t *testing.T) {
|
||||
"execution_count": 1,
|
||||
"data": {
|
||||
"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": {}
|
||||
@@ -295,8 +295,8 @@ func TestIntegrationAndSanitization(t *testing.T) {
|
||||
<div class="cell-line">
|
||||
<div class="cell-left cell-prompt">In [1]:</div>
|
||||
<div class="cell-right cell-input">
|
||||
<pre><code class="chroma language-python">
|
||||
<span class="n">a</span><span class="o">=</span><span class="mi">1</span>
|
||||
<pre><code class="chroma language-fallback">
|
||||
a=1
|
||||
</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
@@ -304,7 +304,7 @@ func TestIntegrationAndSanitization(t *testing.T) {
|
||||
<div class="cell-left cell-prompt">Out [1]:</div>
|
||||
<div class="cell-right cell-output">
|
||||
<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>
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
func TestMain(m *testing.M) {
|
||||
setting.IsInTesting = true
|
||||
markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true
|
||||
setting.Markdown.FileNamePatterns = []string{"*.md"}
|
||||
markup.RefreshFileNamePatterns()
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
@@ -153,6 +153,9 @@ func ParsePackage(r io.Reader) (*Package, error) {
|
||||
return nil, err
|
||||
}
|
||||
} 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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ func TestParsePackage(t *testing.T) {
|
||||
data := createPackage(c, map[string][]byte{
|
||||
".PKGINFO": createPKGINFOContent(packageName, packageVersion),
|
||||
"/test/dummy.txt": {},
|
||||
"usr/lib/legit\n\n%FILES%\n/etc/cron.d/x": {}, // must not reach the file list
|
||||
})
|
||||
|
||||
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 {
|
||||
manifestMatch := manifestPattern.FindStringSubmatch(path.Base(file.Name))
|
||||
if len(manifestMatch) == 0 {
|
||||
if strings.HasSuffix(file.Name, "/") || !manifestPattern.MatchString(path.Base(file.Name)) {
|
||||
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 {
|
||||
return nil, ErrManifestFileTooLarge
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package swift
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -24,6 +25,18 @@ const (
|
||||
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) {
|
||||
t.Run("MissingManifestFile", func(t *testing.T) {
|
||||
data := test.WriteZipArchive(map[string]string{"dummy.txt": ""})
|
||||
@@ -65,6 +78,77 @@ func TestParsePackage(t *testing.T) {
|
||||
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) {
|
||||
data := test.WriteZipArchive(map[string]string{
|
||||
"Package.swift": "// swift-tools-version:5.7\n//\n// Package.swift",
|
||||
|
||||
@@ -50,7 +50,8 @@ var Markdown = struct {
|
||||
MathCodeBlockDetection []string
|
||||
MathCodeBlockOptions MarkdownMathCodeBlockOptions `ini:"-"`
|
||||
}{
|
||||
EnableMath: true,
|
||||
EnableMath: true,
|
||||
FileNamePatterns: []string{"*.md"},
|
||||
}
|
||||
|
||||
// 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
|
||||
func (a *AzureBlobStorage) IterateObjects(dirName string, fn func(path string, obj Object) error) error {
|
||||
dirName = a.buildAzureBlobPath(dirName)
|
||||
if dirName != "" {
|
||||
dirName += "/"
|
||||
}
|
||||
basePrefix := buildObjectStorePathPrefix(a.cfg.BasePath, "")
|
||||
dirPrefix := buildObjectStorePathPrefix(a.cfg.BasePath, dirName)
|
||||
pager := a.client.NewListBlobsFlatPager(a.cfg.Container, &container.ListBlobsFlatOptions{
|
||||
Prefix: &dirName,
|
||||
Prefix: &dirPrefix,
|
||||
})
|
||||
for pager.More() {
|
||||
resp, err := pager.NextPage(a.ctx)
|
||||
@@ -317,7 +315,8 @@ func (a *AzureBlobStorage) IterateObjects(dirName string, fn func(path string, o
|
||||
return convertAzureBlobErr(err)
|
||||
}
|
||||
for _, object := range resp.Segment.BlobItems {
|
||||
blobClient := a.getBlobClient(*object.Name)
|
||||
objPath := strings.TrimPrefix(*object.Name, basePrefix)
|
||||
blobClient := a.getBlobClient(objPath)
|
||||
object := &azureBlobObject{
|
||||
Context: a.ctx,
|
||||
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 {
|
||||
defer object.Close()
|
||||
return fn(strings.TrimPrefix(object.Name, a.cfg.BasePath), object)
|
||||
return fn(objPath, object)
|
||||
}(object, fn); err != nil {
|
||||
return convertAzureBlobErr(err)
|
||||
}
|
||||
@@ -336,7 +335,6 @@ func (a *AzureBlobStorage) IterateObjects(dirName string, fn func(path string, o
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete delete a file
|
||||
func (a *AzureBlobStorage) getBlobClient(path string) *blob.Client {
|
||||
return a.client.ServiceClient().NewContainerClient(a.cfg.Container).NewBlobClient(a.buildAzureBlobPath(path))
|
||||
}
|
||||
|
||||
@@ -27,24 +27,16 @@ func TestAzureBlobStorage(t *testing.T) {
|
||||
Container: "test",
|
||||
},
|
||||
}
|
||||
table := []struct {
|
||||
name string
|
||||
test func(t *testing.T, typStr Type, cfg *setting.Storage)
|
||||
}{
|
||||
{
|
||||
name: "iterator",
|
||||
test: testStorageIterator,
|
||||
},
|
||||
{
|
||||
name: "testBlobStorageURLContentTypeAndDisposition",
|
||||
test: testBlobStorageURLContentTypeAndDisposition,
|
||||
},
|
||||
}
|
||||
for _, entry := range table {
|
||||
t.Run(entry.name, func(t *testing.T) {
|
||||
entry.test(t, storageType, config)
|
||||
})
|
||||
}
|
||||
t.Run("Iterator", func(t *testing.T) {
|
||||
testStorageIterator(t, storageType, config)
|
||||
})
|
||||
t.Run("BlobStorageURLContentTypeAndDisposition", func(t *testing.T) {
|
||||
testBlobStorageURLContentTypeAndDisposition(t, storageType, config)
|
||||
})
|
||||
t.Run("IteratorWithBasePath", func(t *testing.T) {
|
||||
config.AzureBlobConfig.BasePath = "test-base-path"
|
||||
testStorageIterator(t, storageType, config)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAzureBlobStoragePath(t *testing.T) {
|
||||
|
||||
@@ -26,6 +26,8 @@ import (
|
||||
|
||||
var _ ObjectStorage = &MinioStorage{}
|
||||
|
||||
const unknownSizePartSize = 1024 * 1024 * 16 // same as minio-go's minPartSize
|
||||
|
||||
type minioObject struct {
|
||||
*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
|
||||
// do not support "x-amz-checksum-algorithm" header, so use legacy MD5 checksum
|
||||
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 {
|
||||
|
||||
@@ -11,11 +11,13 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/public"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// ErrURLNotSupported represents url is not supported
|
||||
@@ -139,6 +141,23 @@ func SaveFrom(objStorage ObjectStorage, path string, callback func(w io.Writer)
|
||||
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 (
|
||||
// Attachments represents attachments storage
|
||||
Attachments ObjectStorage = uninitializedStorage
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"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)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
defer func() {
|
||||
for _, f := range testFiles {
|
||||
_ = l.Delete(f[0])
|
||||
}
|
||||
}()
|
||||
|
||||
expectedList := map[string][]string{
|
||||
"a": {"a/1.txt"},
|
||||
@@ -43,7 +49,9 @@ func testStorageIterator(t *testing.T, typStr Type, cfg *setting.Storage) {
|
||||
for dir, expected := range expectedList {
|
||||
count := 0
|
||||
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)
|
||||
count++
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,11 @@ func IsEmptyString(s string) bool {
|
||||
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)
|
||||
func NormalizeEOL(input []byte) []byte {
|
||||
var right, left, pos int
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@
|
||||
"jquery": "4.0.0",
|
||||
"js-yaml": "4.2.0",
|
||||
"katex": "0.17.0",
|
||||
"mermaid": "11.15.0",
|
||||
"mermaid": "11.16.1",
|
||||
"online-3d-viewer": "0.18.0",
|
||||
"pdfobject": "2.3.1",
|
||||
"perfect-debounce": "2.1.0",
|
||||
|
||||
Generated
+12
-12
@@ -73,7 +73,7 @@ importers:
|
||||
version: 0.1.0-rc2
|
||||
'@mermaid-js/layout-elk':
|
||||
specifier: 0.2.1
|
||||
version: 0.2.1(mermaid@11.15.0)
|
||||
version: 0.2.1(mermaid@11.16.1)
|
||||
'@primer/octicons':
|
||||
specifier: 19.28.1
|
||||
version: 19.28.1
|
||||
@@ -147,8 +147,8 @@ importers:
|
||||
specifier: 0.17.0
|
||||
version: 0.17.0
|
||||
mermaid:
|
||||
specifier: 11.15.0
|
||||
version: 11.15.0
|
||||
specifier: 11.16.1
|
||||
version: 11.16.1
|
||||
online-3d-viewer:
|
||||
specifier: 0.18.0
|
||||
version: 0.18.0
|
||||
@@ -942,8 +942,8 @@ packages:
|
||||
peerDependencies:
|
||||
mermaid: ^11.0.2
|
||||
|
||||
'@mermaid-js/parser@1.1.1':
|
||||
resolution: {integrity: sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==}
|
||||
'@mermaid-js/parser@1.2.0':
|
||||
resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.6':
|
||||
resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
|
||||
@@ -3301,8 +3301,8 @@ packages:
|
||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
mermaid@11.15.0:
|
||||
resolution: {integrity: sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==}
|
||||
mermaid@11.16.1:
|
||||
resolution: {integrity: sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g==}
|
||||
|
||||
micromark-core-commonmark@2.0.3:
|
||||
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
|
||||
@@ -5179,13 +5179,13 @@ snapshots:
|
||||
- supports-color
|
||||
- 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:
|
||||
d3: 7.9.0
|
||||
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:
|
||||
'@chevrotain/types': 11.1.2
|
||||
|
||||
@@ -7764,11 +7764,11 @@ snapshots:
|
||||
|
||||
merge2@1.4.1: {}
|
||||
|
||||
mermaid@11.15.0:
|
||||
mermaid@11.16.1:
|
||||
dependencies:
|
||||
'@braintree/sanitize-url': 7.1.2
|
||||
'@iconify/utils': 3.1.3
|
||||
'@mermaid-js/parser': 1.1.1
|
||||
'@mermaid-js/parser': 1.2.0
|
||||
'@types/d3': 7.4.3
|
||||
'@upsetjs/venn.js': 2.0.0
|
||||
cytoscape: 3.33.4
|
||||
|
||||
@@ -66,6 +66,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -74,7 +75,6 @@ import (
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/storage"
|
||||
"gitea.dev/modules/util"
|
||||
@@ -336,15 +336,19 @@ type (
|
||||
)
|
||||
|
||||
func (ar artifactRoutes) listArtifacts(ctx *ArtifactContext) {
|
||||
_, runID, ok := validateRunID(ctx)
|
||||
task, runID, ok := validateRunID(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
attemptIDs, ok := readableArtifactAttemptIDs(ctx, task)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{
|
||||
RunID: runID,
|
||||
RunAttemptID: optional.Some(ctx.ActionTask.Job.RunAttemptID),
|
||||
Status: int(actions.ArtifactStatusUploadConfirmed),
|
||||
artifacts, err := actions.FindReadableArtifacts(ctx, actions.FindArtifactsOptions{
|
||||
RunID: runID,
|
||||
RunAttemptIDs: attemptIDs,
|
||||
Status: int(actions.ArtifactStatusUploadConfirmed),
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("Error getting artifacts: %v", err)
|
||||
@@ -397,7 +401,7 @@ type (
|
||||
|
||||
// getDownloadArtifactURL generates download url for each artifact
|
||||
func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) {
|
||||
_, runID, ok := validateRunID(ctx)
|
||||
task, runID, ok := validateRunID(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -407,11 +411,16 @@ func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) {
|
||||
return
|
||||
}
|
||||
|
||||
artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{
|
||||
RunID: runID,
|
||||
RunAttemptID: optional.Some(ctx.ActionTask.Job.RunAttemptID),
|
||||
ArtifactName: itemPath,
|
||||
Status: int(actions.ArtifactStatusUploadConfirmed),
|
||||
attemptIDs, ok := readableArtifactAttemptIDs(ctx, task)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
artifacts, err := actions.FindReadableArtifacts(ctx, actions.FindArtifactsOptions{
|
||||
RunID: runID,
|
||||
RunAttemptIDs: attemptIDs,
|
||||
ArtifactName: itemPath,
|
||||
Status: int(actions.ArtifactStatusUploadConfirmed),
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("Error getting artifacts: %v", err)
|
||||
@@ -461,7 +470,7 @@ func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) {
|
||||
|
||||
// downloadArtifact downloads artifact content
|
||||
func (ar artifactRoutes) downloadArtifact(ctx *ArtifactContext) {
|
||||
_, runID, ok := validateRunID(ctx)
|
||||
task, runID, ok := validateRunID(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -483,10 +492,17 @@ func (ar artifactRoutes) downloadArtifact(ctx *ArtifactContext) {
|
||||
ctx.HTTPError(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if ctx.ActionTask.Job.RunAttemptID > 0 && artifact.RunAttemptID != ctx.ActionTask.Job.RunAttemptID {
|
||||
log.Error("Error mismatch runAttemptID and artifactID, task: %v, artifact: %v", ctx.ActionTask.Job.RunAttemptID, artifactID)
|
||||
ctx.HTTPError(http.StatusBadRequest)
|
||||
return
|
||||
// resolving the readable attempts costs a query, and an artifact of the task's own attempt never needs it
|
||||
if artifact.RunAttemptID != task.Job.RunAttemptID {
|
||||
attemptIDs, ok := readableArtifactAttemptIDs(ctx, task)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !slices.Contains(attemptIDs, artifact.RunAttemptID) {
|
||||
log.Error("Error artifact %d belongs to run attempt %d, which the task cannot read: %v", artifactID, artifact.RunAttemptID, attemptIDs)
|
||||
ctx.HTTPError(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if artifact.Status != actions.ArtifactStatusUploadConfirmed {
|
||||
log.Error("Error artifact not found: %s", artifact.Status.ToString())
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
"gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/storage"
|
||||
)
|
||||
@@ -261,9 +260,9 @@ func listOrderedChunksForArtifact(st storage.ObjectStorage, runID, artifactID in
|
||||
func mergeChunksForRun(ctx *ArtifactContext, st storage.ObjectStorage, runID, runAttemptID int64, artifactName string) error {
|
||||
// read all db artifacts by name
|
||||
artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{
|
||||
RunID: runID,
|
||||
RunAttemptID: optional.Some(runAttemptID),
|
||||
ArtifactName: artifactName,
|
||||
RunID: runID,
|
||||
RunAttemptIDs: []int64{runAttemptID},
|
||||
ArtifactName: artifactName,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -43,7 +43,7 @@ func validateRunID(ctx *ArtifactContext) (*actions.ActionTask, int64, bool) {
|
||||
return task, runID, true
|
||||
}
|
||||
|
||||
func validateRunIDV4(ctx *ArtifactContext, rawRunID string) (*actions.ActionTask, int64, bool) { //nolint:unparam // ActionTask is never used
|
||||
func validateRunIDV4(ctx *ArtifactContext, rawRunID string) (*actions.ActionTask, int64, bool) {
|
||||
task := ctx.ActionTask
|
||||
runID, err := strconv.ParseInt(rawRunID, 10, 64)
|
||||
if err != nil || task.Job.RunID != runID {
|
||||
@@ -54,6 +54,18 @@ func validateRunIDV4(ctx *ArtifactContext, rawRunID string) (*actions.ActionTask
|
||||
return task, runID, true
|
||||
}
|
||||
|
||||
// readableArtifactAttemptIDs resolves the attempts a task may read artifacts from:
|
||||
// its own attempt, plus the attempts it inherits from when only a subset of the run's jobs was re-run.
|
||||
func readableArtifactAttemptIDs(ctx *ArtifactContext, task *actions.ActionTask) ([]int64, bool) {
|
||||
attemptIDs, err := actions.GetArtifactAttemptIDs(ctx, task.Job)
|
||||
if err != nil {
|
||||
log.Error("Error getting readable artifact attempts: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError, "Error getting readable artifact attempts")
|
||||
return nil, false
|
||||
}
|
||||
return attemptIDs, true
|
||||
}
|
||||
|
||||
func validateArtifactHash(ctx *ArtifactContext, artifactName string) bool {
|
||||
paramHash := ctx.PathParam("artifact_hash")
|
||||
// use artifact name to create upload url
|
||||
|
||||
@@ -107,7 +107,6 @@ import (
|
||||
actions_module "gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/storage"
|
||||
"gitea.dev/modules/util"
|
||||
@@ -262,9 +261,28 @@ func (r *artifactV4Routes) verifySignature(ctx *ArtifactContext, endp string) (*
|
||||
return task, artifactName, true
|
||||
}
|
||||
|
||||
func (r *artifactV4Routes) getArtifactByName(ctx *ArtifactContext, runID, runAttemptID int64, name string) (*actions_model.ActionArtifact, error) {
|
||||
// getOwnAttemptArtifactByName resolves an artifact of the given attempt whatever its status,
|
||||
// since upload and finalize work on the pending row they just created.
|
||||
func (r *artifactV4Routes) getOwnAttemptArtifactByName(ctx *ArtifactContext, runID, runAttemptID int64, name string) (*actions_model.ActionArtifact, error) {
|
||||
return r.findArtifactByName(ctx, runID, []int64{runAttemptID}, name, nil)
|
||||
}
|
||||
|
||||
// getDownloadableArtifactByName resolves the newest artifact with the given name within the attempts whose content can still be served,
|
||||
// so a pending, deleted or expired row of a newer attempt does not shadow the confirmed copy inherited from an older one.
|
||||
func (r *artifactV4Routes) getDownloadableArtifactByName(ctx *ArtifactContext, runID int64, runAttemptIDs []int64, name string) (*actions_model.ActionArtifact, error) {
|
||||
return r.findArtifactByName(ctx, runID, runAttemptIDs, name, builder.Eq{"status": actions_model.ArtifactStatusUploadConfirmed})
|
||||
}
|
||||
|
||||
func (r *artifactV4Routes) findArtifactByName(ctx *ArtifactContext, runID int64, runAttemptIDs []int64, name string, extraCond builder.Cond) (*actions_model.ActionArtifact, error) {
|
||||
cond := builder.NewCond().
|
||||
And(builder.Eq{"run_id": runID, "artifact_name": name}, builder.Like{"content_encoding", "%/%"}).
|
||||
And(builder.In("run_attempt_id", runAttemptIDs))
|
||||
if extraCond != nil {
|
||||
cond = cond.And(extraCond)
|
||||
}
|
||||
|
||||
var art actions_model.ActionArtifact
|
||||
has, err := db.GetEngine(ctx).Where(builder.Eq{"run_id": runID, "run_attempt_id": runAttemptID, "artifact_name": name}, builder.Like{"content_encoding", "%/%"}).Get(&art)
|
||||
has, err := db.GetEngine(ctx).Where(cond).OrderBy("run_attempt_id DESC, id DESC").Get(&art)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
@@ -384,7 +402,7 @@ func (r *artifactV4Routes) uploadArtifact(ctx *ArtifactContext) {
|
||||
switch comp {
|
||||
case "block", "appendBlock":
|
||||
// get artifact by name
|
||||
artifact, err := r.getArtifactByName(ctx, task.Job.RunID, task.Job.RunAttemptID, artifactName)
|
||||
artifact, err := r.getOwnAttemptArtifactByName(ctx, task.Job.RunID, task.Job.RunAttemptID, artifactName)
|
||||
if err != nil {
|
||||
log.Error("Error artifact not found: %v", err)
|
||||
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
||||
@@ -471,7 +489,7 @@ func (r *artifactV4Routes) finalizeArtifact(ctx *ArtifactContext) {
|
||||
}
|
||||
|
||||
// get artifact by name
|
||||
artifact, err := r.getArtifactByName(ctx, runID, ctx.ActionTask.Job.RunAttemptID, req.Name)
|
||||
artifact, err := r.getOwnAttemptArtifactByName(ctx, runID, ctx.ActionTask.Job.RunAttemptID, req.Name)
|
||||
if err != nil {
|
||||
log.Error("Error artifact not found: %v", err)
|
||||
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
||||
@@ -578,14 +596,18 @@ func (r *artifactV4Routes) listArtifacts(ctx *ArtifactContext) {
|
||||
if ok := r.parseProtobufBody(ctx, &req); !ok {
|
||||
return
|
||||
}
|
||||
_, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
|
||||
task, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
attemptIDs, ok := readableArtifactAttemptIDs(ctx, task)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{
|
||||
artifacts, err := actions_model.FindReadableArtifacts(ctx, actions_model.FindArtifactsOptions{
|
||||
RunID: runID,
|
||||
RunAttemptID: optional.Some(ctx.ActionTask.Job.RunAttemptID),
|
||||
RunAttemptIDs: attemptIDs,
|
||||
Status: int(actions_model.ArtifactStatusUploadConfirmed),
|
||||
FinalizedArtifactsV4: true,
|
||||
})
|
||||
@@ -597,6 +619,8 @@ func (r *artifactV4Routes) listArtifacts(ctx *ArtifactContext) {
|
||||
|
||||
list := []*ListArtifactsResponse_MonolithArtifact{}
|
||||
|
||||
// both filters pick from what this attempt may read, so they run after the shadowed artifacts are gone:
|
||||
// a shadowed artifact is not downloadable either, GetSignedArtifactURL resolves by name
|
||||
table := map[string]*ListArtifactsResponse_MonolithArtifact{}
|
||||
for _, artifact := range artifacts {
|
||||
if _, ok := table[artifact.ArtifactName]; ok || req.IdFilter != nil && artifact.ID != req.IdFilter.Value || req.NameFilter != nil && artifact.ArtifactName != req.NameFilter.Value {
|
||||
@@ -631,7 +655,11 @@ func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) {
|
||||
if ok := r.parseProtobufBody(ctx, &req); !ok {
|
||||
return
|
||||
}
|
||||
_, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
|
||||
task, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
attemptIDs, ok := readableArtifactAttemptIDs(ctx, task)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -639,17 +667,12 @@ func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) {
|
||||
artifactName := req.Name
|
||||
|
||||
// get artifact by name
|
||||
artifact, err := r.getArtifactByName(ctx, runID, ctx.ActionTask.Job.RunAttemptID, artifactName)
|
||||
artifact, err := r.getDownloadableArtifactByName(ctx, runID, attemptIDs, artifactName)
|
||||
if err != nil {
|
||||
log.Error("Error artifact not found: %v", err)
|
||||
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
||||
return
|
||||
}
|
||||
if artifact.Status != actions_model.ArtifactStatusUploadConfirmed {
|
||||
log.Error("Error artifact not found: %s", artifact.Status.ToString())
|
||||
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
||||
return
|
||||
}
|
||||
|
||||
respData := GetSignedArtifactURLResponse{}
|
||||
|
||||
@@ -671,16 +694,15 @@ func (r *artifactV4Routes) downloadArtifact(ctx *ArtifactContext) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// get artifact by name
|
||||
artifact, err := r.getArtifactByName(ctx, task.Job.RunID, task.Job.RunAttemptID, artifactName)
|
||||
if err != nil {
|
||||
log.Error("Error artifact not found: %v", err)
|
||||
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
||||
attemptIDs, ok := readableArtifactAttemptIDs(ctx, task)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if artifact.Status != actions_model.ArtifactStatusUploadConfirmed {
|
||||
log.Error("Error artifact not found: %s", artifact.Status.ToString())
|
||||
|
||||
// get artifact by name
|
||||
artifact, err := r.getDownloadableArtifactByName(ctx, task.Job.RunID, attemptIDs, artifactName)
|
||||
if err != nil {
|
||||
log.Error("Error artifact not found: %v", err)
|
||||
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
||||
return
|
||||
}
|
||||
@@ -704,7 +726,7 @@ func (r *artifactV4Routes) deleteArtifact(ctx *ArtifactContext) {
|
||||
}
|
||||
|
||||
// get artifact by name
|
||||
artifact, err := r.getArtifactByName(ctx, runID, ctx.ActionTask.Job.RunAttemptID, req.Name)
|
||||
artifact, err := r.getOwnAttemptArtifactByName(ctx, runID, ctx.ActionTask.Job.RunAttemptID, req.Name)
|
||||
if err != nil {
|
||||
log.Error("Error artifact not found: %v", err)
|
||||
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
|
||||
|
||||
@@ -135,7 +135,7 @@ func CommonRoutes() *web.Router {
|
||||
r.Group("/{branch}/{repository}", func() {
|
||||
r.Put("", reqPackageAccess(perm.AccessModeWrite), alpine.UploadPackageFile)
|
||||
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.Get("", alpine.DownloadPackageFile)
|
||||
r.Delete("", reqPackageAccess(perm.AccessModeWrite), alpine.DeletePackageFile)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
packages_model "gitea.dev/models/packages"
|
||||
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)
|
||||
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 {
|
||||
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 {
|
||||
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]
|
||||
|
||||
metadata := latest.Metadata.(*npm_module.Metadata)
|
||||
@@ -42,7 +53,10 @@ func createPackageMetadataResponse(registryURL string, pds []*packages_model.Pac
|
||||
DistTags: distTags,
|
||||
Description: metadata.Description,
|
||||
Readme: metadata.Readme,
|
||||
Maintainers: []npm_module.User{{Name: latest.Owner.Name}},
|
||||
Time: times,
|
||||
Homepage: metadata.ProjectURL,
|
||||
Keywords: metadata.Keywords,
|
||||
Author: npm_module.User{Name: metadata.Author},
|
||||
License: metadata.License,
|
||||
Versions: versions,
|
||||
@@ -61,8 +75,10 @@ func createPackageMetadataVersion(registryURL string, pd *packages_model.Package
|
||||
Version: pd.Version.Version,
|
||||
Description: metadata.Description,
|
||||
Author: npm_module.User{Name: metadata.Author},
|
||||
Maintainers: []npm_module.User{{Name: pd.Owner.Name}},
|
||||
Homepage: metadata.ProjectURL,
|
||||
License: metadata.License,
|
||||
Keywords: metadata.Keywords,
|
||||
Dependencies: metadata.Dependencies,
|
||||
BundleDependencies: metadata.BundleDependencies,
|
||||
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
|
||||
|
||||
import (
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/markup/markdown"
|
||||
"io"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web"
|
||||
@@ -84,9 +85,6 @@ func MarkdownRaw(ctx *context.APIContext) {
|
||||
// "$ref": "#/responses/MarkdownRender"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
defer ctx.Req.Body.Close()
|
||||
if err := markdown.RenderRaw(markup.NewRenderContext(ctx), ctx.Req.Body, ctx.Resp); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
textBytes, _ := io.ReadAll(io.LimitReader(ctx.Req.Body, setting.UI.MaxDisplayFileSize))
|
||||
common.RenderMarkup(ctx.Base, ctx.Repo, "markdown", util.UnsafeBytesToString(textBytes), "", "")
|
||||
}
|
||||
|
||||
@@ -7,19 +7,15 @@ import (
|
||||
go_context "context"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/modules/web"
|
||||
context_service "gitea.dev/services/context"
|
||||
"gitea.dev/services/contexttest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -27,13 +23,6 @@ import (
|
||||
|
||||
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) {
|
||||
setting.AppURL = AppURL
|
||||
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,
|
||||
}
|
||||
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)
|
||||
Markup(ctx)
|
||||
assert.Equal(t, expectedBody, resp.Body.String())
|
||||
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) {
|
||||
@@ -76,11 +63,10 @@ func testRenderMarkdown(t *testing.T, mode string, wiki bool, text, responseBody
|
||||
Markdown(ctx)
|
||||
assert.Equal(t, responseBody, resp.Body.String())
|
||||
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) {
|
||||
unittest.PrepareTestEnv(t)
|
||||
markup.Init(&markup.RenderHelperFuncs{
|
||||
IsUsernameMentionable: func(ctx go_context.Context, username string) bool {
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
setting.AppURL = AppURL
|
||||
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) {
|
||||
setting.AppURL = AppURL
|
||||
markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true
|
||||
ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markdown")
|
||||
for i := 0; i < len(simpleCases); i += 2 {
|
||||
ctx.Req.Body = io.NopCloser(strings.NewReader(simpleCases[i]))
|
||||
MarkdownRaw(ctx)
|
||||
assert.Equal(t, simpleCases[i+1], resp.Body.String())
|
||||
resp.Body.Reset()
|
||||
testCases := []struct {
|
||||
in, out string
|
||||
mode string
|
||||
}{
|
||||
{in: "", out: ""},
|
||||
{in: "[[special-syntax]]", out: "<p>[[special-syntax]]</p>\n", mode: "markdown"},
|
||||
{in: "[[special|syntax]]", out: "<p>[[special|syntax]]</p>\n", mode: "markdown"},
|
||||
{in: "01234567890123456789", out: "<p>01234567890123456789</p>\n", mode: "gfm"}, // commit-like content should not crash the render
|
||||
}
|
||||
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 {
|
||||
err := templates.PageRenderer().HTML(outBuf, respCode, tmpl, ctxData, tmplCtx)
|
||||
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"))
|
||||
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"
|
||||
// and the urlPathContext is "/gitea/owner/repo/src/branch/features/feat-123/doc"
|
||||
|
||||
ctx.SetHeaderContentSecurityPolicyGeneral()
|
||||
|
||||
if mode == "" || mode == "markdown" {
|
||||
// raw Markdown doesn't do any special handling
|
||||
// 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"
|
||||
refPath = strings.Join(fields[3:], "/") // it is "branch/features/feat-12/doc"
|
||||
refPath = strings.TrimSuffix(refPath, "/"+treePath) // now we get the correct branch path: "branch/features/feat-12"
|
||||
refPath = util.PathEscapeSegments(refPath)
|
||||
} else if fields = strings.SplitN(repoLinkPath, "/", 3); len(fields) == 2 {
|
||||
repoOwnerName, repoName = fields[0], fields[1]
|
||||
}
|
||||
|
||||
@@ -73,12 +73,9 @@ func TwoFactorPost(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.Session.Get("linkAccount") != nil {
|
||||
err = linkAccountFromContext(ctx, u)
|
||||
if err != nil {
|
||||
ctx.ServerError("UserSignIn", err)
|
||||
return
|
||||
}
|
||||
if err = completePendingLinks(ctx, u); err != nil {
|
||||
ctx.ServerError("completePendingLinks", err)
|
||||
return
|
||||
}
|
||||
|
||||
_ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true)
|
||||
@@ -145,6 +142,11 @@ func TwoFactorScratchPost(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err = completePendingLinks(ctx, u); err != nil {
|
||||
ctx.ServerError("completePendingLinks", err)
|
||||
return
|
||||
}
|
||||
|
||||
handleSignInFull(ctx, u, remember)
|
||||
if ctx.Written() {
|
||||
return
|
||||
|
||||
+18
-27
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"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.
|
||||
// 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 {
|
||||
ctx.ServerError("UserSignIn", err)
|
||||
ctx.ServerError("HasTwoFactorOrWebAuthn", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
if !hasTwoFactor {
|
||||
handleSignIn(ctx, u, form.Remember)
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]any{
|
||||
// User will need to use 2FA TOTP or WebAuthn, save data
|
||||
"twofaUid": u.ID,
|
||||
"twofaRemember": form.Remember,
|
||||
}
|
||||
if hasTOTPtwofa {
|
||||
// User will need to use WebAuthn, save data
|
||||
updates["totpEnrolled"] = u.ID
|
||||
}
|
||||
handleTwoFactorRequired(ctx, u, form.Remember, nil)
|
||||
}
|
||||
|
||||
func handleTwoFactorRequired(ctx *context.Context, u *user_model.User, remember bool, extra map[string]any) {
|
||||
updates := map[string]any{"twofaUid": u.ID, "twofaRemember": remember}
|
||||
maps.Copy(updates, extra)
|
||||
if err := regenerateSession(ctx, nil, updates); err != nil {
|
||||
ctx.ServerError("UserSignIn: Unable to update session", err)
|
||||
ctx.ServerError("RegenerateSession", err)
|
||||
return
|
||||
}
|
||||
|
||||
// If we have WebAuthn redirect there first
|
||||
if hasWebAuthnTwofa {
|
||||
hasWebAuthn, err := auth.HasWebAuthnRegistrationsByUID(ctx, u.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("HasWebAuthnRegistrationsByUID", err)
|
||||
return
|
||||
}
|
||||
if hasWebAuthn {
|
||||
ctx.Redirect(setting.AppSubURL + "/user/webauthn")
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback to 2FA
|
||||
ctx.Redirect(setting.AppSubURL + "/user/two_factor")
|
||||
}
|
||||
|
||||
@@ -408,6 +398,7 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember bool) {
|
||||
"twofaRemember",
|
||||
"linkAccount",
|
||||
"linkAccountData",
|
||||
"openidPendingURI",
|
||||
}, map[string]any{
|
||||
session.KeyUID: u.ID,
|
||||
session.KeyUname: u.Name,
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"testing"
|
||||
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/session"
|
||||
"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.
|
||||
// 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
|
||||
_, err := auth.GetTwoFactorByUID(ctx, u.ID)
|
||||
hasTwoFactor, err := auth.HasTwoFactorOrWebAuthn(ctx, u.ID)
|
||||
if err != nil {
|
||||
if !auth.IsErrTwoFactorNotEnrolled(err) {
|
||||
ctx.ServerError("UserLinkAccount", err)
|
||||
return
|
||||
}
|
||||
|
||||
err = externalaccount.LinkAccountToUser(ctx, linkAccountData.AuthSourceID, u, linkAccountData.GothUser)
|
||||
if err != nil {
|
||||
ctx.ServerError("UserLinkAccount", err)
|
||||
return
|
||||
}
|
||||
if !hasTwoFactor {
|
||||
if err := externalaccount.LinkAccountToUser(ctx, linkAccountData.AuthSourceID, u, linkAccountData.GothUser); err != nil {
|
||||
ctx.ServerError("UserLinkAccount", err)
|
||||
return
|
||||
}
|
||||
@@ -170,25 +168,10 @@ func oauth2LinkAccount(ctx *context.Context, u *user_model.User, linkAccountData
|
||||
return
|
||||
}
|
||||
|
||||
if err := regenerateSession(ctx, nil, map[string]any{
|
||||
// User needs to use 2FA, save data and redirect to 2FA page.
|
||||
"twofaUid": u.ID,
|
||||
"twofaRemember": remember,
|
||||
handleTwoFactorRequired(ctx, u, remember, map[string]any{
|
||||
"linkAccount": true,
|
||||
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
|
||||
@@ -279,6 +262,15 @@ func LinkAccountPostRegister(ctx *context.Context) {
|
||||
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 {
|
||||
linkAccountData := oauth2GetLinkAccountData(ctx)
|
||||
if linkAccountData == nil {
|
||||
|
||||
@@ -361,12 +361,11 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
|
||||
|
||||
needs2FA := false
|
||||
if !authSource.TwoFactorShouldSkip() {
|
||||
_, err := auth.GetTwoFactorByUID(ctx, u.ID)
|
||||
if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) {
|
||||
var err error
|
||||
if needs2FA, err = auth.HasTwoFactorOrWebAuthn(ctx, u.ID); err != nil {
|
||||
ctx.ServerError("UserSignIn", err)
|
||||
return
|
||||
}
|
||||
needs2FA = err == nil
|
||||
}
|
||||
|
||||
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{
|
||||
// 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")
|
||||
handleTwoFactorRequired(ctx, u, false, map[string]any{session.KeySignInMethod: session.SignInMethodOAuth2})
|
||||
}
|
||||
|
||||
// OAuth2UserLoginCallback attempts to handle the callback from the OAuth2 provider and if successful
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
auth_model "gitea.dev/models/auth"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/auth/openid"
|
||||
"gitea.dev/modules/log"
|
||||
@@ -26,6 +27,36 @@ const (
|
||||
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
|
||||
func SignInOpenID(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("sign_in")
|
||||
@@ -154,6 +185,10 @@ func signInOpenIDVerify(ctx *context.Context) {
|
||||
log.Trace("User exists, logging in")
|
||||
remember, _ := ctx.Session.Get("openid_signin_remember").(bool)
|
||||
log.Trace("Session stored openid-remember: %t", remember)
|
||||
openIDRequireTwoFactor(ctx, u, remember, "")
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
handleSignIn(ctx, u, remember)
|
||||
return
|
||||
}
|
||||
@@ -270,7 +305,12 @@ func ConnectOpenIDPost(ctx *context.Context) {
|
||||
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}
|
||||
if err := user_model.AddUserOpenID(ctx, userOID); err != nil {
|
||||
if user_model.IsErrOpenIDAlreadyUsed(err) {
|
||||
@@ -282,9 +322,6 @@ func ConnectOpenIDPost(ctx *context.Context) {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -238,6 +238,19 @@ func ResetPasswdPost(ctx *context.Context) {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,8 @@ func WebAuthnPasskeyAssertion(ctx *context.Context) {
|
||||
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 {
|
||||
ctx.ServerError("webauthn.BeginDiscoverableLogin", err)
|
||||
return
|
||||
@@ -91,7 +92,7 @@ func WebAuthnPasskeyLogin(ctx *context.Context) {
|
||||
parsedResponse, err := protocol.ParseCredentialRequestResponse(ctx.Req)
|
||||
if err != nil {
|
||||
// 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)
|
||||
return
|
||||
}
|
||||
@@ -147,12 +148,9 @@ func WebAuthnPasskeyLogin(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Now handle account linking if that's requested
|
||||
if ctx.Session.Get("linkAccount") != nil {
|
||||
if err := linkAccountFromContext(ctx, user); err != nil {
|
||||
ctx.ServerError("LinkAccountFromStore", err)
|
||||
return
|
||||
}
|
||||
if err := completePendingLinks(ctx, user); err != nil {
|
||||
ctx.ServerError("completePendingLinks", err)
|
||||
return
|
||||
}
|
||||
|
||||
remember := false // TODO: implement remember me
|
||||
@@ -186,7 +184,8 @@ func WebAuthnLoginAssertion(ctx *context.Context) {
|
||||
}
|
||||
|
||||
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 {
|
||||
ctx.ServerError("webauthn.BeginLogin", err)
|
||||
return
|
||||
@@ -261,12 +260,9 @@ func WebAuthnLoginAssertionPost(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Now handle account linking if that's requested
|
||||
if ctx.Session.Get("linkAccount") != nil {
|
||||
if err := linkAccountFromContext(ctx, user); err != nil {
|
||||
ctx.ServerError("LinkAccountFromStore", err)
|
||||
return
|
||||
}
|
||||
if err := completePendingLinks(ctx, user); err != nil {
|
||||
ctx.ServerError("completePendingLinks", err)
|
||||
return
|
||||
}
|
||||
|
||||
remember := ctx.Session.Get("twofaRemember").(bool)
|
||||
|
||||
@@ -5,7 +5,6 @@ package org
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
@@ -201,7 +200,7 @@ func prepareOrgProfileReadme(ctx *context.Context, prepareResult *shared_user.Pr
|
||||
}
|
||||
|
||||
rctx := renderhelper.NewRenderContextRepoFile(ctx, profileRepo, renderhelper.RepoFileOptions{
|
||||
CurrentRefSubURL: path.Join("branch", util.PathEscapeSegments(profileRepo.DefaultBranch)),
|
||||
CurrentRefSubURL: git.RefNameFromBranch(profileRepo.DefaultBranch).RefWebLinkPath(),
|
||||
})
|
||||
ctx.Data["ProfileReadmeContent"], err = markdown.RenderString(rctx, readmeBytes)
|
||||
if err != nil {
|
||||
|
||||
@@ -662,6 +662,10 @@ type WorkflowDispatchInput struct {
|
||||
Options []string `yaml:"options"`
|
||||
}
|
||||
|
||||
func (i WorkflowDispatchInput) IsDefaultTrue() bool {
|
||||
return util.ParseYamlBool(i.Default)
|
||||
}
|
||||
|
||||
type WorkflowDispatch struct {
|
||||
Inputs []WorkflowDispatchInput
|
||||
}
|
||||
|
||||
@@ -1063,7 +1063,10 @@ func Cancel(ctx *context_module.Context) {
|
||||
return fmt.Errorf("cancel jobs: %w", err)
|
||||
}
|
||||
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 {
|
||||
ctx.ServerError("StopTask", err)
|
||||
return
|
||||
@@ -1073,8 +1076,11 @@ func Cancel(ctx *context_module.Context) {
|
||||
actions_service.EmitJobsIfReadyByJobs(updatedJobs)
|
||||
|
||||
actions_service.NotifyWorkflowJobsStatusUpdate(ctx, updatedJobs...)
|
||||
if len(updatedJobs) > 0 {
|
||||
actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, run.RepoID, run.ID)
|
||||
// SettleRunAfterCancel finishes a run without updating any job, so compare the run itself.
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
br.AvatarStackData = gituser.BuildAvatarStackData(ctx, commit.GitCommit.AllParticipantIdentities(), nil)
|
||||
br.AvatarStackData = gituser.BuildAvatarStackData(ctx, commit.GitCommit.AllAuthorIdentities(), nil)
|
||||
br.PreviousSha = part.PreviousSha
|
||||
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))
|
||||
|
||||
@@ -64,6 +64,9 @@ func RenderFile(ctx *context.Context) {
|
||||
extRendererOpts := extRenderer.GetExternalRendererOptions()
|
||||
if 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)
|
||||
|
||||
@@ -118,13 +118,19 @@ func CollaborationPost(ctx *context.Context) {
|
||||
|
||||
// ChangeCollaborationAccessMode response for changing access of a collaboration
|
||||
func ChangeCollaborationAccessMode(ctx *context.Context) {
|
||||
if err := repo_model.ChangeCollaborationAccessMode(
|
||||
ctx,
|
||||
ctx.Repo.Repository,
|
||||
ctx.FormInt64("uid"),
|
||||
perm.AccessMode(ctx.FormInt("mode"))); err != nil {
|
||||
log.Error("ChangeCollaborationAccessMode: %v", err)
|
||||
// the frontend initRepoSettingsCollaboration logic: it only checks "resp.ok"
|
||||
u, err := user_model.GetUserByID(ctx, ctx.FormInt64("uid"))
|
||||
if err != nil {
|
||||
ctx.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
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
|
||||
|
||||
@@ -134,7 +134,7 @@ func loadLatestCommitData(ctx *context.Context, latestCommit *git.Commit) bool {
|
||||
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)
|
||||
ctx.Data["LatestCommitAvatarStackData"] = avatarStackData
|
||||
ctx.Data["LatestCommitVerification"] = verification
|
||||
|
||||
@@ -7,7 +7,6 @@ package user
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
activities_model "gitea.dev/models/activities"
|
||||
@@ -22,7 +21,6 @@ import (
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/routers/web/feed"
|
||||
"gitea.dev/routers/web/org"
|
||||
shared_user "gitea.dev/routers/web/shared/user"
|
||||
@@ -255,7 +253,7 @@ func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.R
|
||||
log.Error("failed to GetBlobContent: %v", err)
|
||||
} else {
|
||||
rctx := renderhelper.NewRenderContextRepoFile(ctx, profileDbRepo, renderhelper.RepoFileOptions{
|
||||
CurrentRefSubURL: path.Join("branch", util.PathEscapeSegments(profileDbRepo.DefaultBranch)),
|
||||
CurrentRefSubURL: git.RefNameFromBranch(profileDbRepo.DefaultBranch).RefWebLinkPath(),
|
||||
})
|
||||
if profileContent, err := markdown.RenderString(rctx, bytes); err != nil {
|
||||
log.Error("failed to RenderString: %v", err)
|
||||
|
||||
@@ -53,8 +53,17 @@ func WebAuthnRegister(ctx *context.Context) {
|
||||
}
|
||||
|
||||
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,
|
||||
// anything else makes Chromium raise it to credProtect level 3, hiding it from the second factor
|
||||
UserVerification: protocol.VerificationRequired,
|
||||
}))
|
||||
if err != nil {
|
||||
ctx.ServerError("Unable to BeginRegistration", err)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
act_model "gitea.com/gitea/runner/act/model"
|
||||
"go.yaml.in/yaml/v4"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
// 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.
|
||||
// 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
|
||||
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 {
|
||||
@@ -26,11 +28,10 @@ func EvaluateRunConcurrencyFillModel(ctx context.Context, run *actions_model.Act
|
||||
actionsRunCtx := GenerateGiteaContext(ctx, run, attempt, nil)
|
||||
jobResults := map[string]*jobparser.JobResult{"": {}}
|
||||
if inputs == nil {
|
||||
var err error
|
||||
inputs, err = getWorkflowDispatchInputsFromRun(run)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get inputs: %w", err)
|
||||
if run.Event == "workflow_dispatch" {
|
||||
setting.PanicInDevOrTesting("run %d: workflow_dispatch inputs must be resolved by the caller", run.ID)
|
||||
}
|
||||
inputs = map[string]any{}
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
@@ -36,12 +36,13 @@ func TestEvaluateRunConcurrency_RunIDFallback(t *testing.T) {
|
||||
|
||||
expr := &act_model.RawConcurrency{
|
||||
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, runB, attemptB, expr, nil, nil))
|
||||
|
||||
assert.True(t, attemptA.ConcurrencyCancel)
|
||||
assert.Contains(t, attemptA.ConcurrencyGroup, "791")
|
||||
assert.Contains(t, attemptB.ConcurrencyGroup, "792")
|
||||
assert.NotEqual(t, attemptA.ConcurrencyGroup, attemptB.ConcurrencyGroup)
|
||||
|
||||
@@ -8,12 +8,16 @@ import (
|
||||
"fmt"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
actions_module "gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
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" {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
// - 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
|
||||
func getInputsForJob(ctx context.Context, run *actions_model.ActionRun, job *actions_model.ActionRunJob) (map[string]any, error) {
|
||||
if job.ParentJobID == 0 {
|
||||
return getWorkflowDispatchInputsFromRun(run)
|
||||
return dispatchInputsForJob(run, job)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// pullRequestTargetBaseSHA returns the base branch commit of a pull_request_target run, and whether the run is one.
|
||||
func pullRequestTargetBaseSHA(run *actions_model.ActionRun) (string, bool) {
|
||||
if run.TriggerEvent != actions_module.GithubEventPullRequestTarget {
|
||||
return "", false
|
||||
}
|
||||
payload, err := run.GetPullRequestEventPayload()
|
||||
if err != nil {
|
||||
log.Error("run %d: get pull request event payload: %v", run.ID, err)
|
||||
return "", false
|
||||
}
|
||||
if payload.PullRequest == nil || payload.PullRequest.Base == nil || payload.PullRequest.Base.Sha == "" {
|
||||
return "", false
|
||||
}
|
||||
return payload.PullRequest.Base.Sha, true
|
||||
}
|
||||
|
||||
// evaluateJobIf evaluates a job's `if:`
|
||||
func evaluateJobIf(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, job *actions_model.ActionRunJob, vars map[string]string, allNeedsSucceed bool) (bool, error) {
|
||||
parsedJob, err := job.ParseJob()
|
||||
|
||||
@@ -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"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
func Test_jobStatusResolver_Resolve(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
run *actions_model.ActionRun // defaults to stubRun
|
||||
jobs actions_model.ActionJobList
|
||||
want map[int64]actions_model.Status
|
||||
}{
|
||||
@@ -145,6 +147,34 @@ jobs:
|
||||
needs: job1
|
||||
steps:
|
||||
- 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},
|
||||
@@ -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
|
||||
runID := 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.
|
||||
idMap := make(map[int64]int64, len(tt.jobs))
|
||||
@@ -166,7 +197,7 @@ jobs:
|
||||
j.ID = 0
|
||||
j.RunID = runID
|
||||
j.RunAttemptID = attemptID
|
||||
j.Run = stubRun
|
||||
j.Run = run
|
||||
|
||||
// The resolver evaluates Blocked jobs via evaluateJobIf, which needs a valid YAML payload;
|
||||
// supply a minimal one when the case didn't.
|
||||
|
||||
@@ -338,8 +338,8 @@ func handleWorkflows(
|
||||
isForkPullRequest := isForkPullRequestInput(input)
|
||||
|
||||
for _, dwf := range detectedWorkflows {
|
||||
// repo-level run: the workflow content is this repo at this commit
|
||||
if err := buildApproveAndInsertRun(ctx, input, ref, commit, string(p), isForkPullRequest, dwf, input.Repo.ID, commit.ID.String(), false); err != nil {
|
||||
// repo-level run: the workflow content is this repo at dwf.SourceCommitSHA
|
||||
if err := buildApproveAndInsertRun(ctx, input, ref, commit, string(p), isForkPullRequest, dwf, input.Repo.ID, false); err != nil {
|
||||
log.Error("repo %s: %v", input.Repo.RelativePath(), err)
|
||||
continue
|
||||
}
|
||||
@@ -350,7 +350,7 @@ func handleWorkflows(
|
||||
// buildApproveAndInsertRun assembles an ActionRun for a detected workflow, runs the
|
||||
// fork-PR approval gate, and inserts it. Repo-level and scoped runs share this path so
|
||||
// run construction and the approval flow have a single implementation that can't drift.
|
||||
// workflowRepoID/workflowCommitSHA point at the repo+commit the workflow content comes
|
||||
// workflowRepoID and dwf.SourceCommitSHA point at the repo+commit the workflow content comes
|
||||
// from (the repo itself for repo-level runs, the source repo for scoped runs).
|
||||
func buildApproveAndInsertRun(
|
||||
ctx context.Context,
|
||||
@@ -361,9 +361,12 @@ func buildApproveAndInsertRun(
|
||||
isForkPullRequest bool,
|
||||
dwf *actions_module.DetectedWorkflow,
|
||||
workflowRepoID int64,
|
||||
workflowCommitSHA string,
|
||||
isScopedRun bool,
|
||||
) error {
|
||||
if dwf.SourceCommitSHA == "" {
|
||||
// unreachable in the normal flow; catches a test case that builds a DetectedWorkflow without it
|
||||
setting.PanicInDevOrTesting("workflow %q has no source commit", dwf.EntryName)
|
||||
}
|
||||
run := &actions_model.ActionRun{
|
||||
Title: commit.MessageTitle(),
|
||||
RepoID: input.Repo.ID,
|
||||
@@ -380,7 +383,7 @@ func buildApproveAndInsertRun(
|
||||
TriggerEvent: dwf.TriggerEvent.Name,
|
||||
Status: actions_model.StatusWaiting,
|
||||
WorkflowRepoID: workflowRepoID,
|
||||
WorkflowCommitSHA: workflowCommitSHA,
|
||||
WorkflowCommitSHA: dwf.SourceCommitSHA,
|
||||
IsScopedRun: isScopedRun,
|
||||
}
|
||||
|
||||
@@ -695,7 +698,7 @@ func detectAndHandleScopedWorkflows(
|
||||
continue
|
||||
}
|
||||
|
||||
sourceCommitSHA, detected, filtered, err := detectScopedWorkflowsForSource(ctx, input, consumerGitRepo, consumerCommit, sourceRepo)
|
||||
detected, filtered, err := detectScopedWorkflowsForSource(ctx, input, consumerGitRepo, consumerCommit, sourceRepo)
|
||||
if err != nil {
|
||||
log.Error("scoped workflows: source %d for consumer %s: %v", sourceRepoID, input.Repo.RelativePath(), err)
|
||||
continue
|
||||
@@ -708,7 +711,7 @@ func detectAndHandleScopedWorkflows(
|
||||
continue
|
||||
}
|
||||
|
||||
if err := buildApproveAndInsertRun(ctx, input, ref, consumerCommit, string(p), isForkPullRequest, dwf, sourceRepo.ID, sourceCommitSHA, true); err != nil {
|
||||
if err := buildApproveAndInsertRun(ctx, input, ref, consumerCommit, string(p), isForkPullRequest, dwf, sourceRepo.ID, true); err != nil {
|
||||
log.Error("scoped workflows: source %s workflow %s: %v", sourceRepo.RelativePath(), dwf.EntryName, err)
|
||||
continue
|
||||
}
|
||||
@@ -741,12 +744,12 @@ func detectScopedWorkflowsForSource(
|
||||
consumerGitRepo *git.Repository,
|
||||
consumerCommit *git.Commit,
|
||||
sourceRepo *repo_model.Repository,
|
||||
) (sourceCommitSHA string, detected, filtered []*actions_module.DetectedWorkflow, err error) {
|
||||
) (detected, filtered []*actions_module.DetectedWorkflow, err error) {
|
||||
// scoped workflow content is always taken from the source repo's default branch; the parse is cached per (source, default-branch SHA) and reused across consuming repos/events
|
||||
sourceCommitSHA, parsed, err := LoadParsedScopedWorkflows(ctx, sourceRepo)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
detected, filtered = actions_module.MatchScopedWorkflows(parsed, consumerGitRepo, consumerCommit, input.Event, input.Payload)
|
||||
return sourceCommitSHA, detected, filtered, nil
|
||||
detected, filtered = actions_module.MatchScopedWorkflows(parsed, sourceCommitSHA, consumerGitRepo, consumerCommit, input.Event, input.Payload)
|
||||
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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/util"
|
||||
@@ -60,7 +61,11 @@ func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRu
|
||||
if err != nil {
|
||||
return nil, 0, "", fmt.Errorf("look up caller source repo %d: %w", caller.WorkflowSourceRepoID, err)
|
||||
}
|
||||
bytes, resolvedSHA, err := readWorkflowFromRepo(ctx, callerRepo, caller.WorkflowSourceCommitSHA, ref.Path)
|
||||
sourceCommitSHA := resolveSameRepoWorkflowSourceCommit(run, caller)
|
||||
if sourceCommitSHA != caller.WorkflowSourceCommitSHA {
|
||||
log.Warn("run %d (pull_request_target) records workflow source commit %s, resolving %q at base commit %s instead", run.ID, caller.WorkflowSourceCommitSHA, ref.Path, sourceCommitSHA)
|
||||
}
|
||||
bytes, resolvedSHA, err := readWorkflowFromRepo(ctx, callerRepo, sourceCommitSHA, ref.Path)
|
||||
if err != nil {
|
||||
return nil, 0, "", err
|
||||
}
|
||||
@@ -92,6 +97,19 @@ func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRu
|
||||
return nil, 0, "", fmt.Errorf("unsupported uses kind %d", ref.Kind)
|
||||
}
|
||||
|
||||
// resolveSameRepoWorkflowSourceCommit returns the commit to read a same-repo reusable workflow from.
|
||||
// pull_request_target runs must resolve local `uses:` at the PR base commit, not a stored head SHA.
|
||||
func resolveSameRepoWorkflowSourceCommit(run *actions_model.ActionRun, caller *actions_model.ActionRunJob) string {
|
||||
// only a SHA copied from the run row can be the polluted head one; a SHA resolved from a `uses:` ref is right by construction
|
||||
if run.IsScopedRun || caller.WorkflowSourceRepoID != run.RepoID || caller.WorkflowSourceCommitSHA != run.WorkflowCommitSHA {
|
||||
return caller.WorkflowSourceCommitSHA
|
||||
}
|
||||
if baseSHA, ok := pullRequestTargetBaseSHA(run); ok && baseSHA != caller.WorkflowSourceCommitSHA {
|
||||
return baseSHA
|
||||
}
|
||||
return caller.WorkflowSourceCommitSHA
|
||||
}
|
||||
|
||||
// readWorkflowFromRepo loads a workflow file from `repo` at `refOrSHA` and returns its content plus the resolved commit SHA.
|
||||
func readWorkflowFromRepo(ctx context.Context, repo *repo_model.Repository, refOrSHA, path string) ([]byte, string, error) {
|
||||
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
|
||||
|
||||
@@ -10,9 +10,13 @@ import (
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
actions_module "gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/test"
|
||||
webhook_module "gitea.dev/modules/webhook"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -278,3 +282,74 @@ func TestUndoExpansion(t *testing.T) {
|
||||
assert.False(t, refreshed.IsExpanded)
|
||||
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: sibling.ID})
|
||||
}
|
||||
|
||||
func TestResolveSameRepoWorkflowSourceCommit(t *testing.T) {
|
||||
prtRun := func(baseSHA string) *actions_model.ActionRun {
|
||||
payload, err := json.Marshal(api.PullRequestPayload{
|
||||
PullRequest: &api.PullRequest{
|
||||
Base: &api.PRBranchInfo{Sha: baseSHA},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// a run recorded before the fix points at the PR head commit
|
||||
return &actions_model.ActionRun{
|
||||
ID: 42,
|
||||
RepoID: 1,
|
||||
Event: webhook_module.HookEventPullRequest,
|
||||
TriggerEvent: actions_module.GithubEventPullRequestTarget,
|
||||
EventPayload: string(payload),
|
||||
WorkflowCommitSHA: "head-sha",
|
||||
}
|
||||
}
|
||||
pushRun := &actions_model.ActionRun{
|
||||
RepoID: 1,
|
||||
TriggerEvent: "push",
|
||||
WorkflowCommitSHA: "head-sha",
|
||||
}
|
||||
caller := func(sourceRepoID int64, sourceCommitSHA string) *actions_model.ActionRunJob {
|
||||
return &actions_model.ActionRunJob{WorkflowSourceRepoID: sourceRepoID, WorkflowSourceCommitSHA: sourceCommitSHA}
|
||||
}
|
||||
|
||||
t.Run("pull_request_target pins to base commit", func(t *testing.T) {
|
||||
got := resolveSameRepoWorkflowSourceCommit(prtRun("base-sha"), caller(1, "head-sha"))
|
||||
assert.Equal(t, "base-sha", got)
|
||||
})
|
||||
|
||||
t.Run("legacy nested caller (with head-sha) pins to base commit", func(t *testing.T) {
|
||||
nested := caller(1, "head-sha")
|
||||
nested.ParentJobID = 99
|
||||
got := resolveSameRepoWorkflowSourceCommit(prtRun("base-sha"), nested)
|
||||
assert.Equal(t, "base-sha", got)
|
||||
})
|
||||
|
||||
t.Run("pull_request_target keeps stored SHA when already base", func(t *testing.T) {
|
||||
run := prtRun("base-sha")
|
||||
run.WorkflowCommitSHA = "base-sha"
|
||||
got := resolveSameRepoWorkflowSourceCommit(run, caller(1, "base-sha"))
|
||||
assert.Equal(t, "base-sha", got)
|
||||
})
|
||||
|
||||
t.Run("non pull_request_target keeps stored SHA", func(t *testing.T) {
|
||||
got := resolveSameRepoWorkflowSourceCommit(pushRun, caller(1, "head-sha"))
|
||||
assert.Equal(t, "head-sha", got)
|
||||
})
|
||||
|
||||
t.Run("scoped run keeps stored SHA", func(t *testing.T) {
|
||||
run := prtRun("base-sha")
|
||||
run.IsScopedRun = true
|
||||
got := resolveSameRepoWorkflowSourceCommit(run, caller(1, "head-sha"))
|
||||
assert.Equal(t, "head-sha", got)
|
||||
})
|
||||
|
||||
t.Run("cross-repo caller keeps stored SHA", func(t *testing.T) {
|
||||
got := resolveSameRepoWorkflowSourceCommit(prtRun("base-sha"), caller(2, "head-sha"))
|
||||
assert.Equal(t, "head-sha", got)
|
||||
})
|
||||
|
||||
t.Run("caller resolved from a uses: ref keeps its own SHA", func(t *testing.T) {
|
||||
nested := caller(1, "tag-v1-sha")
|
||||
nested.ParentJobID = 99
|
||||
got := resolveSameRepoWorkflowSourceCommit(prtRun("base-sha"), nested)
|
||||
assert.Equal(t, "tag-v1-sha", got)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package actions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"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)
|
||||
}
|
||||
// get inputs from post
|
||||
workflow := &model.Workflow{
|
||||
RawOn: singleWorkflow.RawOn,
|
||||
}
|
||||
workflowDispatch := workflow.WorkflowDispatchConfig()
|
||||
workflowDispatch := singleWorkflow.WorkflowDispatchConfig()
|
||||
if workflowDispatch == nil {
|
||||
return 0, util.ErrorWrapTranslatable(
|
||||
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 {
|
||||
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)
|
||||
|
||||
// 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,
|
||||
Ref: ref,
|
||||
Repository: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeNone}),
|
||||
Inputs: inputsWithDefaults,
|
||||
Inputs: dispatchEventInputs(inputsWithDefaults),
|
||||
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
|
||||
}
|
||||
|
||||
// coerceDispatchInputTypes normalizes workflow_dispatch input values to the JSON types declared by
|
||||
// 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.
|
||||
// coerceDispatchInputTypes types `inputs`, where boolean is the only non-string dispatch input type.
|
||||
func coerceDispatchInputTypes(dispatch *model.WorkflowDispatch, inputs map[string]any) {
|
||||
for name, cfg := range dispatch.Inputs {
|
||||
if cfg.Type != "boolean" {
|
||||
continue
|
||||
}
|
||||
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.
|
||||
// - Repo-level: from the consumer's runTargetCommit.
|
||||
// - Scoped: from the source repo's default branch.
|
||||
|
||||
@@ -16,6 +16,8 @@ func TestCoerceDispatchInputTypes(t *testing.T) {
|
||||
"build_server": {Type: "boolean"},
|
||||
"dry_run": {Type: "boolean"},
|
||||
"already_bool": {Type: "boolean"},
|
||||
"yaml_true": {Type: "boolean"},
|
||||
"yaml_truthy": {Type: "boolean"},
|
||||
"version": {Type: "string"},
|
||||
},
|
||||
}
|
||||
@@ -26,6 +28,9 @@ func TestCoerceDispatchInputTypes(t *testing.T) {
|
||||
"dry_run": "false",
|
||||
// already-native booleans are passed through unchanged (coercion is idempotent)
|
||||
"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
|
||||
"version": "1.2.3",
|
||||
}
|
||||
@@ -37,5 +42,17 @@ func TestCoerceDispatchInputTypes(t *testing.T) {
|
||||
assert.Equal(t, true, inputs["build_server"])
|
||||
assert.Equal(t, false, inputs["dry_run"])
|
||||
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"])
|
||||
|
||||
// `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
|
||||
}
|
||||
|
||||
store.GetData()["IsApiToken"] = true
|
||||
|
||||
log.Trace("HTTP Sign: Logged in user %-v", u)
|
||||
|
||||
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...)
|
||||
}
|
||||
|
||||
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 {
|
||||
reqCtx := reqctx.FromContext(req.Context())
|
||||
b := &Base{
|
||||
|
||||
@@ -12,10 +12,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/htmlutil"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/public"
|
||||
"gitea.dev/modules/reqctx"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web/middleware"
|
||||
"gitea.dev/services/webtheme"
|
||||
)
|
||||
@@ -24,16 +25,16 @@ type TemplateContext map[string]any
|
||||
|
||||
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}
|
||||
}
|
||||
|
||||
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 {
|
||||
return c["_ctx"].(context.Context)
|
||||
func (c TemplateContext) parentContext() reqctx.RequestContext {
|
||||
return c["_ctx"].(reqctx.RequestContext) //nolint:forcetypeassert // must exist
|
||||
}
|
||||
|
||||
func (c TemplateContext) Deadline() (deadline time.Time, ok bool) {
|
||||
@@ -78,7 +79,7 @@ func (c TemplateContext) CurrentWebBanner() *setting.WebBannerType {
|
||||
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)
|
||||
func (c TemplateContext) AppFullLink(link ...string) template.URL {
|
||||
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) {
|
||||
// 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, _ = c["_cspScriptNonce"].(string)
|
||||
if ret == "" {
|
||||
ret = util.FastCryptoRandomHex(32) // 16 bytes / 128 bits entropy
|
||||
c["_cspScriptNonce"] = ret
|
||||
}
|
||||
return ret
|
||||
return CspScriptNonce(c.parentContext())
|
||||
}
|
||||
|
||||
func (c TemplateContext) HeadMetaContentSecurityPolicy() template.HTML {
|
||||
func WebContentSecurityPolicy(scriptNonce string) string {
|
||||
if setting.Security.ContentSecurityPolicyGeneral == "unset" {
|
||||
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
|
||||
// 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
|
||||
return template.HTML(`<meta http-equiv="Content-Security-Policy" content="` +
|
||||
// allow all by default (the same as old releases with no CSP)
|
||||
// * maybe some images or markup (external) renders need "data:", need to investigate
|
||||
// * avatar upload editor needs "blob:", at least "img-src" and "content-src"
|
||||
`default-src * data: blob:;` +
|
||||
|
||||
// allow all by default (the same as old releases with no CSP)
|
||||
// * maybe some images or markup (external) renders need "data:", need to investigate
|
||||
// * avatar upload editor needs "blob:", at least "img-src" and "content-src"
|
||||
return `default-src * data: blob:;` +
|
||||
|
||||
// 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
|
||||
`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"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/reqctx"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
@@ -57,7 +58,7 @@ func TestAppFullLink(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.PublicURLDetection, setting.PublicURLNever)()
|
||||
|
||||
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/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)
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -152,12 +152,16 @@ func (g *GithubDownloaderV3) waitAndPickClient(ctx context.Context) {
|
||||
var recentIdx int
|
||||
var maxRemaining int
|
||||
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
|
||||
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 {
|
||||
timer := time.NewTimer(time.Until(g.rates[g.curClientIdx].Reset.Time))
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"gitea.dev/models/unittest"
|
||||
base "gitea.dev/modules/migration"
|
||||
|
||||
"github.com/google/go-github/v88/github"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"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"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -360,9 +361,16 @@ type keyValue struct {
|
||||
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 {
|
||||
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},
|
||||
{"DESC", opts.VersionMetadata.Description},
|
||||
{"URL", opts.VersionMetadata.ProjectURL},
|
||||
{"LICENSE", strings.Join(opts.VersionMetadata.Licenses, "\n")},
|
||||
{"GROUPS", strings.Join(opts.FileMetadata.Groups, "\n")},
|
||||
{"LICENSE", joinFields(opts.VersionMetadata.Licenses)},
|
||||
{"GROUPS", joinFields(opts.FileMetadata.Groups)},
|
||||
{"BUILDDATE", strconv.FormatInt(opts.FileMetadata.BuildDate, 10)},
|
||||
{"PACKAGER", opts.FileMetadata.Packager},
|
||||
{"PROVIDES", strings.Join(opts.FileMetadata.Provides, "\n")},
|
||||
{"REPLACES", strings.Join(opts.FileMetadata.Replaces, "\n")},
|
||||
{"CONFLICTS", strings.Join(opts.FileMetadata.Conflicts, "\n")},
|
||||
{"DEPENDS", strings.Join(opts.FileMetadata.Depends, "\n")},
|
||||
{"OPTDEPENDS", strings.Join(opts.FileMetadata.OptDepends, "\n")},
|
||||
{"MAKEDEPENDS", strings.Join(opts.FileMetadata.MakeDepends, "\n")},
|
||||
{"CHECKDEPENDS", strings.Join(opts.FileMetadata.CheckDepends, "\n")},
|
||||
{"PROVIDES", joinFields(opts.FileMetadata.Provides)},
|
||||
{"REPLACES", joinFields(opts.FileMetadata.Replaces)},
|
||||
{"CONFLICTS", joinFields(opts.FileMetadata.Conflicts)},
|
||||
{"DEPENDS", joinFields(opts.FileMetadata.Depends)},
|
||||
{"OPTDEPENDS", joinFields(opts.FileMetadata.OptDepends)},
|
||||
{"MAKEDEPENDS", joinFields(opts.FileMetadata.MakeDepends)},
|
||||
{"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