Compare commits

..
13 Commits
Author SHA1 Message Date
d29364a4f6 fix(actions): harden log cursor, activity type and actor filter handling (#39721) (#39731)
Backport #39721 by @bircni

Fixes a few Actions bugs where user input or nondeterminism caused
failures:

- The job log view indexed task steps with the client-supplied cursor
step without a bounds check, so an out-of-range value panicked and
returned a 500.
- Workflow activity type filters (`types:`) were compiled with
`glob.MustCompile`, so an invalid pattern in a workflow file panicked
during event detection. Invalid patterns now simply don't match.
- Listing workflow runs with an unknown `actor` returned a 500; it now
returns 404.
- The v4 artifact `ListArtifacts` response was built by ranging over a
map, so its order changed between calls. It now follows database order.
- The error from marshalling a workflow job when inserting run jobs was
silently ignored.

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-10 20:23:39 +00:00
Giteabotandsilverwind 998a06627e fix(git): match git config values exactly when syncing (#39728) (#39729)
Backport #39728 by @silverwind

Since https://github.com/go-gitea/gitea/pull/39703, Gitea fails to start
when its gitconfig holds a `gc.reflogExpire` other than `90` (e.g.
`30.days`), because `git config --unset-all` exits 5 when no value
matches. Value patterns were also unanchored regexps, so `90` also
removed `90.days`.

- Match values exactly in `configUnsetAll` and `configAddNonExist`
- Treat "nothing to unset" as success
- Use `--replace-all` in `configSet` so a key with multiple values no
longer fails startup

Co-authored-by: silverwind <me@silverwind.io>
2026-10-10 20:06:48 +02:00
250d18d5f6 fix(actions): settle cancelled reusable workflow runs stuck in cancelling (#39706) (#39717)
Backport #39706 by @benv666

Fixes #39702

**Root Cause:**

Cancelling a reusable workflow caller wrote it `cancelled` right away,
even while a child was still `cancelling`. When the runner acknowledged,
the caller did not change, so the run was never refreshed and stayed
`cancelling`.

SQLite hid this because the child is cancelled a second time, which
re-aggregates the caller, while MySQL reports no affected rows for that
unchanged update.

**Changes:**

- A caller now takes its status only from its children: it stays
`cancelling` until its last child finishes, and its final status then
refreshes the run.
- Cancelling a run whose jobs are all done only settles the run, which
did not wake the runs waiting for its concurrency group. It now does, so
force-cancelling an already stuck run also frees them.


NOTE: Code was AI-generated, but reviewed and tested by me.

Signed-off-by: BenV <165034+benv666@users.noreply.github.com>
Co-authored-by: BenV <165034+benv666@users.noreply.github.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
2026-10-10 09:13:47 -06:00
Giteabotandsilverwind c26daf578f fix(git): leave reflog expiry to git's defaults (#39703) (#39711)
Backport #39703 by @silverwind

Gitea's default `gc.reflogExpire = 90` is parsed by git as
`1990-<month>-<day>`, currently `1990-10-09`, so reachable reflog
entries never expired.

Since git 2.54, auto maintenance runs its reflog-expire task in the
foreground of every push, and its trigger ignores reachability, so busy
repos rerun `git reflog expire --all` on every push without pruning
anything, stalling large repos for over a minute.

- Stop setting `gc.reflogExpire` so git's own defaults apply, and remove
the `90` written by earlier versions
- Treat the legacy `[git.reflog] EXPIRATION` as days, as documented

The next push to each repo prunes the accumulated entries once.

Fixes: https://github.com/go-gitea/gitea/issues/39693

Co-authored-by: silverwind <me@silverwind.io>
2026-10-10 00:36:00 -07:00
silverwind 5371e788fe fix(deps): update module golang.org/x/net to v0.60.0 [security] (#39695) (#39701)
Backport https://github.com/go-gitea/gitea/pull/39695

`go.sum` conflicted on older x/net `go.mod` hashes that `release/v28`
still lists. `make tidy` resolves it to the same change as on `main`.
2026-10-08 23:31:32 -07:00
GiteabotandShivansh Garg 0001e58e6b fix: OIDC discovery advertises unsupported id_token response_type (#39498) (#39689)
Backport #39498 by @SHIVANSHGARG07

The OIDC discovery document at `/.well-known/openid-configuration`
advertised `id_token` in `response_types_supported`, but
`/login/oauth/authorize` only implements the authorization code flow and
rejects any other response_type with `unsupported_response_type`. This
mismatch caused OIDC client libraries that rely on discovery to attempt
the implicit flow and fail silently.

This removes `id_token` from `response_types_supported` so discovery
matches actual server behavior, and adds an integration test asserting
`response_type=id_token` is rejected consistently.

Manually verified: rebuilt Gitea, registered an OAuth2 app, confirmed
`/.well-known/openid-configuration` no longer lists `id_token`, and
confirmed `/login/oauth/authorize?...&response_type=id_token` still
correctly returns `error=unsupported_response_type`.

Fixes #39482.

<!--
Before submitting:
- Target the `main` branch; release branches are for backports only.
- Use a Conventional Commits title, e.g. `fix(repo): handle empty branch
names`.
- Read the contributing guidelines:
https://github.com/go-gitea/gitea/blob/main/CONTRIBUTING.md
- Documentation changes go to https://gitea.com/gitea/docs

Describe your change below and link any issue it fixes.
-->

Co-authored-by: Shivansh Garg <shivanshgarg587@gmail.com>
2026-10-08 18:57:06 +00:00
Giteabot 79c4237cb2 chore(deps): update go toolchain directive to v1.27.2 (#39685) (#39687)
Backport #39685 by @GiteaBot

This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [go](https://go.dev/)
([source](https://redirect.github.com/golang/go)) | toolchain | patch |
`1.27.1` → `1.27.2` |

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR has been generated by [Mend Renovate
CLI](https://redirect.github.com/renovatebot/renovate).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xMjEuNCIsInVwZGF0ZWRJblZlciI6IjQ0LjEyMS40IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJkZXBlbmRlbmNpZXMiXX0=-->
2026-10-08 11:25:28 -07:00
Giteabotandbircni 20f73b2230 ci: skip reverted commits in release notes, publish tagged snaps to stable (#39657) (#39678)
Backport #39657 by @bircni

Release notes: revert commits and the commits they revert are now left
out of the git-cliff changelog. A step before git-cliff finds commits
whose subject starts with "revert", reads the PR numbers on their revert
lines, and writes both SHAs to `.cliffignore`.

Snap: since the move from Launchpad to GitHub Actions, every build was
uploaded to `latest/edge`, including the stable-grade build that
`part-gitea-pull.sh` makes for an unreleased tag. Launchpad used to
release those builds to candidate and stable automatically. Without
that, v28.0.0 had to be promoted by hand and v28.1.0 stayed in edge.
Stable-grade snaps now go to `latest/stable` and `latest/candidate`;
main builds still go to edge. Candidate has to be updated too because
the pull script compares against it.

Signed-off-by: bircni <bircni@icloud.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-10-08 15:57:37 +02:00
175bc89bf9 fix: correct "go get" URL ssh scheme, fix form binding (#39674, #39528) (#39676)
* Backport #39674
* Fix form binding errors (found by #39528)
* Fix #39680

---------

Co-authored-by: Roland Singer <10167163+r0l1@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-08 12:30:59 +00:00
GiteabotandKShot 793e77b073 fix(webhook): keep line breaks in Telegram rich messages (#39650) (#39677)
Backport #39650 by @Kshot3000

Since the Telegram webhook switched to Bot API rich messages
(https://github.com/go-gitea/gitea/pull/38298), a bare newline in
`rich_message.html` is treated as insignificant whitespace, so
multi-line messages — issue and PR bodies, comments, push commit lists —
arrive in Telegram as a single paragraph. This restores the old layout
by converting line breaks (`\n`, `\r\n`, `\r`) to `<br>` in
`createTelegramPayloadHTML`, after sanitizing, covering every Telegram
payload type at once.

Verified: the new `Line breaks are kept in rich messages` test plus the
updated Push/Issue/IssueComment/PullRequest/Review expectations fail on
unpatched code (literal `\n` in the payload) and pass with the fix; the
full `services/webhook` package passes, gofmt/vet clean.

Fixes https://github.com/go-gitea/gitea/issues/39649

---
Tips welcome: PayPal kyleblake0659@gmail.com · BTC
3GnR7TWBXAB3pPztBWpNF4LMNEX5yX8vZK

Co-authored-by: KShot <kshot9000@gmail.com>
2026-10-08 13:31:11 +02:00
wxiaoguang 5018ae1b29 fix: correct websocket notification for change password page (#39671) (#39670)
* Backport #39671
* Fix #39669

And also fix a UI bug
2026-10-07 19:26:34 +00:00
c60b13b2dd fix(markup): display MathML has no space below it (#39663) (#39668)
Backport #39663 by @nschloe

Since MathML is allowed in markup (#36352, #38034, #39337), a
display-style `<math display="block">` sits flush against the text below
it: it gets no margin of its own, and markup paragraphs have
`margin-top: 0`. Display math rendered by KaTeX gets 16px above and
below.

This adds `math[display="block"]` to the markup block elements that get
`margin-top: 0; margin-bottom: 16px`, like `p`, `pre` and `table`.

To test, view a Markdown file containing

```markdown
Text before.

<math display="block">
<mi>x</mi><mo>=</mo><mn>1</mn>
</math>

Text after.
```

Before, the equation has 16px above and 0px below; after, 16px on both
sides.

Before:

<img width="1544" height="238" alt="before"
src="https://github.com/user-attachments/assets/318238d9-018c-4945-974c-91bd1b282ff2"
/>

After:

<img width="1544" height="283" alt="after"
src="https://github.com/user-attachments/assets/9afde2be-b5ed-4624-a33f-4933e7fae79b"
/>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Nico Schlömer <nschloe@users.noreply.github.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-07 18:23:27 +00:00
Giteabotandwxiaoguang 27bd022b34 fix: various bugs (#39661) (#39667)
Backport #39661

1. fix #39660: relax email validation
2. fix #39658: use "int64" instead of time.Duration (for JSON v2)

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-07 10:11:58 -07:00
51 changed files with 411 additions and 146 deletions

No files matched your search

@@ -37,8 +37,15 @@ jobs:
# retry snapcraft uploads which can be flaky
- name: Publish snap
run: |
# part-gitea-pull.sh decides what is built, see the comments there:
# * grade devel (main or a prerelease tag) goes to latest/edge
# * grade stable (a new release tag) goes to latest/stable, and to latest/candidate which marks it as released
channel=latest/edge
if unsquashfs -cat "$SNAP" meta/snap.yaml | grep -qx 'grade: stable'; then
channel=latest/stable,latest/candidate
fi
for attempt in 1 2 3 4 5; do
snapcraft upload "$SNAP" --release latest/edge && exit 0
snapcraft upload "$SNAP" --release "$channel" && exit 0
echo "::warning::snap upload attempt $attempt failed, retrying in 15s"
sleep 15
done
@@ -76,6 +76,10 @@ jobs:
run: |
previous=$(git tag --list --sort=-v:refname | grep -xE 'v[0-9]+\.[0-9]+\.[0-9]+' | grep -A1 -xF "$GITHUB_REF_NAME" | tail -1) # highest stable version below this one
echo "range=$previous..$GITHUB_SHA" >> "$GITHUB_OUTPUT"
- name: skip reverts and reverted commits in changelog
env:
RANGE: ${{ steps.range.outputs.range }}
run: ./tools/generate-cliffignore.sh "$RANGE" | sort -u > .cliffignore
- uses: orhun/git-cliff-action@a9a95522b26fe6403f7bb24031f21fb573d0f5ff # v4.9.1
with:
args: --tag ${{ github.ref_name }} ${{ steps.range.outputs.range }}
+1 -1
View File
@@ -6,7 +6,7 @@ SHASUM ?= shasum -a 256
AIR_PACKAGE ?= github.com/air-verse/air@v1.67.4 # renovate: datasource=go
EDITORCONFIG_CHECKER_PACKAGE ?= github.com/editorconfig-checker/editorconfig-checker/v4/cmd/editorconfig-checker@v4.0.2 # renovate: datasource=go
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2 # renovate: datasource=go
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.14.0 # renovate: datasource=go
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.17 # renovate: datasource=go
MISSPELL_PACKAGE ?= github.com/golangci/misspell/cmd/misspell@v0.8.0 # renovate: datasource=go
SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@v0.36.6 # renovate: datasource=go
+3 -3
View File
@@ -810,7 +810,6 @@ LEVEL = Info
;[git.config]
;diff.algorithm = histogram
;core.logAllRefUpdates = true
;gc.reflogExpire = 90
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -1098,8 +1097,9 @@ LEVEL = Info
;; Force ssh:// clone url instead of scp-style uri when default SSH port is used
;USE_COMPAT_SSH_URI = false
;;
;; Value for the "go get" request returns the repository url as https or ssh, default is https
;GO_GET_CLONE_URL_PROTOCOL = https
;; Scheme of the returned URL for the "go get" response.
;; Default is "https" if DISABLE_HTTP_GIT=false or SSH is disabled, otherwise "ssh".
;GO_GET_CLONE_URL_PROTOCOL =
;;
;; Close issues as long as a commit on any branch marks it as fixed
;DEFAULT_CLOSE_ISSUES_VIA_COMMITS_IN_ANY_BRANCH = false
+2 -2
View File
@@ -2,7 +2,7 @@ module gitea.dev
go 1.27
toolchain go1.27.1
toolchain go1.27.2
require (
connectrpc.com/connect v1.21.0
@@ -98,7 +98,7 @@ require (
golang.org/x/crypto v0.57.0
golang.org/x/image v0.46.0
golang.org/x/mod v0.41.0
golang.org/x/net v0.59.0
golang.org/x/net v0.60.0
golang.org/x/oauth2 v0.37.0
golang.org/x/sync v0.23.0
golang.org/x/sys v0.48.0
+2 -2
View File
@@ -714,8 +714,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+20 -11
View File
@@ -853,25 +853,34 @@ func cancelReusableCaller(ctx context.Context, caller *ActionRunJob, force bool)
}
// Cancel descendants deepest-first, then the caller: a caller's status is aggregated from its children,
// so each child must reach its final state before its parent caller is re-aggregated.
// so each child must be cancelled before its parent caller is re-read.
// A child's ID always exceeds its parent's, so descending ID is a valid deepest-first order.
descendants := CollectAllDescendantJobs(caller, attemptJobs)
slices.SortFunc(descendants, func(a, b *ActionRunJob) int { return cmp.Compare(b.ID, a.ID) })
callersWithChildren := make(container.Set[int64])
for _, d := range descendants {
callersWithChildren.Add(d.ParentJobID)
}
for _, c := range descendants {
cancelled, err := cancelOneJob(ctx, c, force)
for _, job := range append(descendants, caller) {
if !callersWithChildren.Contains(job.ID) {
cancelled, err := cancelOneJob(ctx, job, force)
if err != nil {
return cancelledJobs, err
}
if cancelled != nil {
cancelledJobs = append(cancelledJobs, cancelled)
}
continue
}
// the job is a caller and its children's cascade already re-aggregated it
reloaded, err := GetRunJobByRunAndID(ctx, job.RunID, job.ID)
if err != nil {
return cancelledJobs, err
}
if cancelled != nil {
cancelledJobs = append(cancelledJobs, cancelled)
if reloaded.Status != job.Status {
cancelledJobs = append(cancelledJobs, reloaded)
}
}
if c, err := cancelOneJob(ctx, caller, force); err != nil {
return cancelledJobs, err
} else if c != nil {
cancelledJobs = append(cancelledJobs, c)
}
return cancelledJobs, nil
}
+70
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"testing"
runnerv1 "gitea.dev/actionslib/runner/v1"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
"gitea.dev/modules/timeutil"
@@ -397,3 +398,72 @@ func TestForceCancelJobs(t *testing.T) {
assert.Equal(t, StatusCancelled, callerAfter.Status)
})
}
func TestCancelJobs_CallerWaitsForCancellingChild(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
run := &ActionRun{
Title: "caller-cancelling-child",
RepoID: 4,
Index: 9811,
OwnerID: 1,
WorkflowID: "test.yaml",
TriggerUserID: 1,
Ref: "refs/heads/master",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
EventPayload: "{}",
Status: StatusRunning,
}
require.NoError(t, db.Insert(ctx, run))
attempt := &ActionRunAttempt{RepoID: run.RepoID, RunID: run.ID, Attempt: 1, TriggerUserID: 1, Status: StatusRunning}
require.NoError(t, db.Insert(ctx, attempt))
run.LatestAttemptID = attempt.ID
require.NoError(t, UpdateRun(ctx, run, "latest_attempt_id"))
newJob := func(name string, parentID int64, isCaller bool) *ActionRunJob {
job := &ActionRunJob{
RunID: run.ID,
RunAttemptID: attempt.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
Name: name,
JobID: name,
Attempt: 1,
Status: StatusRunning,
ParentJobID: parentID,
IsReusableCaller: isCaller,
IsExpanded: isCaller,
}
require.NoError(t, db.Insert(ctx, job))
return job
}
outer := newJob("outer", 0, true)
inner := newJob("inner", outer.ID, true)
child := newJob("child", inner.ID, false)
runner := &ActionRunner{UUID: "caller-cancelling-child", Name: "caller-cancelling-child", HasCancellingSupport: true}
require.NoError(t, db.Insert(ctx, runner))
task := &ActionTask{JobID: child.ID, Attempt: 1, RunnerID: runner.ID, Status: StatusRunning, Started: timeutil.TimeStampNow(), RepoID: run.RepoID, OwnerID: run.OwnerID, CommitSHA: run.CommitSHA}
require.NoError(t, db.Insert(ctx, task))
child.TaskID = task.ID
_, err := UpdateRunJob(ctx, child, nil, "task_id")
require.NoError(t, err)
cancelled, err := CancelJobs(ctx, []*ActionRunJob{outer}, false)
require.NoError(t, err)
assert.Len(t, cancelled, 3)
for _, job := range []*ActionRunJob{outer, inner, child} {
assert.Equal(t, StatusCancelling, unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID}).Status, job.Name)
}
_, err = UpdateTaskByState(ctx, runner.ID, &runnerv1.TaskState{Id: task.ID, Result: runnerv1.Result_RESULT_CANCELLED})
require.NoError(t, err)
for _, job := range []*ActionRunJob{outer, inner, child} {
assert.Equal(t, StatusCancelled, unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID}).Status, job.Name)
}
assert.Equal(t, StatusCancelled, unittest.AssertExistsAndLoadBean(t, &ActionRun{ID: run.ID}).Status)
}
+5 -3
View File
@@ -99,8 +99,8 @@ func (protectBranch *ProtectedBranch) loadGlob() {
var err error
protectBranch.globRule, err = glob.Compile(protectBranch.RuleName, '/')
if err != nil {
log.Warn("Invalid glob rule for ProtectedBranch[%d]: %s %v", protectBranch.ID, protectBranch.RuleName, err)
protectBranch.globRule = glob.MustCompile(glob.QuoteMeta(protectBranch.RuleName), '/')
log.Debug("Invalid glob rule for ProtectedBranch[%d]: %s %v", protectBranch.ID, protectBranch.RuleName, err)
protectBranch.globRule, _ = glob.Compile(glob.QuoteMeta(protectBranch.RuleName), '/')
}
}
@@ -110,7 +110,9 @@ func (protectBranch *ProtectedBranch) Match(branchName string) bool {
if protectBranch.isPlainName {
return strings.EqualFold(protectBranch.RuleName, branchName)
}
if protectBranch.globRule == nil {
return false // in case of invalid glob rule, we don't match anything
}
return protectBranch.globRule.Match(branchName)
}
+12 -6
View File
@@ -9,6 +9,7 @@ import (
"gitea.dev/models/db"
"gitea.dev/modules/glob"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
)
@@ -55,10 +56,17 @@ func FindRepoProtectedBranchRules(ctx context.Context, repoID int64) (ProtectedB
// FindAllMatchedBranches find all matched branches
func FindAllMatchedBranches(ctx context.Context, repoID int64, ruleName string) ([]string, error) {
results := make([]string, 0, 10)
rule, err := glob.Compile(ruleName)
if err != nil {
log.Debug("Failed to compile rule %s: %v", ruleName, err)
return results, nil
}
const pageSize = 100
for page := 1; ; page++ {
brancheNames, err := FindBranchNames(ctx, FindBranchOptions{
branchNames, err := FindBranchNames(ctx, FindBranchOptions{
ListOptions: db.ListOptions{
PageSize: 100,
PageSize: pageSize,
Page: page,
},
RepoID: repoID,
@@ -67,14 +75,12 @@ func FindAllMatchedBranches(ctx context.Context, repoID int64, ruleName string)
if err != nil {
return nil, err
}
rule := glob.MustCompile(ruleName)
for _, branch := range brancheNames {
for _, branch := range branchNames {
if rule.Match(branch) {
results = append(results, branch)
}
}
if len(brancheNames) < 100 {
if len(branchNames) < pageSize {
break
}
}
+10 -1
View File
@@ -616,6 +616,15 @@ func ComposeHTTPSCloneURL(ctx context.Context, owner, repo string) string {
// ComposeSSHCloneURL returns SSH clone URL based on the given owner and repository name.
func ComposeSSHCloneURL(doer *user_model.User, ownerName, repoName string) string {
return composeSSHCloneURL(doer, ownerName, repoName, setting.Repository.UseCompatSSHURI)
}
// ComposeSSHCloneURI is like ComposeSSHCloneURL but always returns the "ssh://" form, because "go get" rejects scp-style addresses
func ComposeSSHCloneURI(doer *user_model.User, ownerName, repoName string) string {
return composeSSHCloneURL(doer, ownerName, repoName, true)
}
func composeSSHCloneURL(doer *user_model.User, ownerName, repoName string, useURI bool) string {
sshUser := setting.SSH.User
sshDomain := setting.SSH.Domain
@@ -642,7 +651,7 @@ func ComposeSSHCloneURL(doer *user_model.User, ownerName, repoName string) strin
if ip := net.ParseIP(sshHost); ip != nil && ip.To4() == nil {
sshHost = "[" + sshHost + "]" // for IPv6 address, wrap it with brackets
}
if setting.Repository.UseCompatSSHURI {
if useURI {
return fmt.Sprintf("ssh://%s@%s/%s/%s.git", sshUser, sshHost, url.PathEscape(ownerName), url.PathEscape(repoName))
}
return fmt.Sprintf("%s@%s:%s/%s.git", sshUser, sshHost, url.PathEscape(ownerName), url.PathEscape(repoName))
+2
View File
@@ -185,6 +185,8 @@ func TestComposeSSHCloneURL(t *testing.T) {
assert.Equal(t, "git@domain:user/repo.git", ComposeSSHCloneURL(&user_model.User{Name: "doer"}, "user", "repo"))
setting.Repository.UseCompatSSHURI = true
assert.Equal(t, "ssh://git@domain/user/repo.git", ComposeSSHCloneURL(&user_model.User{Name: "doer"}, "user", "repo"))
setting.Repository.UseCompatSSHURI = false
assert.Equal(t, "ssh://git@domain/user/repo.git", ComposeSSHCloneURI(nil, "user", "repo"))
// test SSH_DOMAIN while use non-standard SSH port
setting.SSH.Port = 123
setting.Repository.UseCompatSSHURI = false
+20 -8
View File
@@ -151,16 +151,28 @@ func TestListEmails(t *testing.T) {
func TestEmailAddressValidate(t *testing.T) {
cases := map[string]bool{
"": false,
"root@localhost": true,
"user@[192.168.1.2]": true,
"@a": false,
"abc@gmail.com": true,
"abc@gmail.com\n": false,
"": false,
"@a": false,
// "_" shouldn't appear in domain but can appear in hostname, since we can't stop site admins from doing so, just accept it
"root@local_host": true,
"root@localhost": true,
"root@LOCALHOST": true,
"user@[192.168.1.2]": true,
"user@[IPv6:FFff::1]": true,
"abc@gmail.com": true,
"abc@gmail.com.": false,
"abc@gmail.com-": false,
"abc@gmail.com\n": false,
"abc@gmail..com": false,
"abc@gmail com": false,
"abc@gmail*com": false,
"Foo <foo@bar.com>": false,
"abc@gmail.com (x)": false,
"jürgen@example.com": false,
"a@foo_bar.com": false,
"jürgen@example.com": false, // utf8 address is not supported yet
}
for tc, isValid := range cases {
t.Run(tc, func(t *testing.T) {
+18 -8
View File
@@ -532,7 +532,7 @@ func matchIssuesEvent(issuePayload *api.IssuePayload, evt *jobparser.Event) bool
}
for _, val := range vals {
if slices.ContainsFunc(actions, glob.MustCompile(val, '/').Match) {
if matchActivityType(val, actions...) {
matchTimes++
break
}
@@ -575,7 +575,7 @@ func matchPullRequestEvent(ctx context.Context, gitRepo *git.Repository, commit
}
log.Trace("matching pull_request %s with %v", action, vals)
for _, val := range vals {
if glob.MustCompile(val, '/').Match(string(action)) {
if matchActivityType(val, string(action)) {
activityTypeMatched = true
matchTimes++
break
@@ -678,7 +678,7 @@ func matchIssueCommentEvent(issueCommentPayload *api.IssueCommentPayload, evt *j
// NONE
for _, val := range vals {
if glob.MustCompile(val, '/').Match(string(issueCommentPayload.Action)) {
if matchActivityType(val, string(issueCommentPayload.Action)) {
matchTimes++
break
}
@@ -718,7 +718,7 @@ func matchPullRequestReviewEvent(prPayload *api.PullRequestPayload, evt *jobpars
}
for _, val := range vals {
if slices.ContainsFunc(actions, glob.MustCompile(val, '/').Match) {
if matchActivityType(val, actions...) {
matchTimes++
break
}
@@ -758,7 +758,7 @@ func matchPullRequestReviewCommentEvent(prPayload *api.PullRequestPayload, evt *
}
for _, val := range vals {
if slices.ContainsFunc(actions, glob.MustCompile(val, '/').Match) {
if matchActivityType(val, actions...) {
matchTimes++
break
}
@@ -795,7 +795,7 @@ func matchReleaseEvent(payload *api.ReleasePayload, evt *jobparser.Event) bool {
action = "edited"
}
for _, val := range vals {
if glob.MustCompile(val, '/').Match(string(action)) {
if matchActivityType(val, string(action)) {
matchTimes++
break
}
@@ -832,7 +832,7 @@ func matchPackageEvent(payload *api.PackagePayload, evt *jobparser.Event) bool {
action = "published"
}
for _, val := range vals {
if glob.MustCompile(val, '/').Match(string(action)) {
if matchActivityType(val, string(action)) {
matchTimes++
break
}
@@ -857,7 +857,7 @@ func matchWorkflowRunEvent(payload *api.WorkflowRunPayload, evt *jobparser.Event
case "types":
action := payload.Action
for _, val := range vals {
if glob.MustCompile(val, '/').Match(action) {
if matchActivityType(val, action) {
matchTimes++
break
}
@@ -893,3 +893,13 @@ func matchWorkflowRunEvent(payload *api.WorkflowRunPayload, evt *jobparser.Event
}
return matchTimes == len(evt.Acts())
}
// matchActivityType treats an invalid user-provided pattern as no match instead of panicking
func matchActivityType(pattern string, actions ...string) bool {
g, err := glob.Compile(pattern, '/')
if err != nil {
log.Debug("invalid activity type pattern %q: %v", pattern, err)
return false
}
return slices.ContainsFunc(actions, g.Match)
}
+7
View File
@@ -205,6 +205,13 @@ func TestDetectMatched(t *testing.T) {
yamlOn: "on:\n release:\n types: [published]",
expected: detectMatched,
},
{
desc: "HookEventRelease(release) doesn't match an invalid activity type pattern",
triggedEvent: webhook_module.HookEventRelease,
payload: &api.ReleasePayload{Action: api.HookReleasePublished},
yamlOn: "on:\n release:\n types: [\"[\"]",
expected: detectNotApplicable,
},
{
desc: "HookEventPackage(package) `created` action doesn't match GithubEventRegistryPackage(registry_package) with `updated` activity type",
triggedEvent: webhook_module.HookEventPackage,
+3 -3
View File
@@ -102,11 +102,11 @@ func NewEmbeddedFS(data []byte) fs.ReadDirFS {
efs := &embeddedFS{data: data, files: make(map[string]*embeddedFileInfo)}
efs.meta = sync.OnceValue(func() *EmbeddedMeta {
var meta EmbeddedMeta
p := bytes.LastIndexByte(data, '\n')
if p < 0 {
_, metaJSON, ok := bytes.CutLast(data, []byte{'\n'})
if !ok {
return &meta
}
if err := json.Unmarshal(data[p+1:], &meta); err != nil {
if err := json.Unmarshal(metaJSON, &meta); err != nil {
panic("embedded file is not valid")
}
return &meta
+11 -15
View File
@@ -125,6 +125,11 @@ func syncGitConfig(ctx context.Context) (err error) {
return err
}
// Gitea used to write "90", which git reads as a date in 1990
if err := configUnsetAll(ctx, "gc.reflogExpire", "90"); err != nil {
return err
}
// Apply user's git config options last so they take precedence over builtin defaults
for k, v := range setting.GitConfig.Options {
if err = configSet(ctx, strings.ToLower(k), v); err != nil {
@@ -162,7 +167,7 @@ func configSet(ctx context.Context, key, value string) error {
return nil
}
if _, _, err = gitcmd.NewCommand("config", "--global").
if _, _, err = gitcmd.NewCommand("config", "--global", "--replace-all").
AddDynamicArguments(key, value).
RunStdString(ctx); err != nil {
return fmt.Errorf("failed to set git global config %s, err: %w", key, err)
@@ -190,7 +195,7 @@ func configSetNonExist(ctx context.Context, key, value string) error {
}
func configAddNonExist(ctx context.Context, key, value string) error {
_, _, err := gitcmd.NewCommand("config", "--global", "--get").AddDynamicArguments(key, regexp.QuoteMeta(value)).RunStdString(ctx)
_, _, err := gitcmd.NewCommand("config", "--global", "--get").AddDynamicArguments(key, "^"+regexp.QuoteMeta(value)+"$").RunStdString(ctx)
if err == nil {
// already exist
return nil
@@ -207,18 +212,9 @@ func configAddNonExist(ctx context.Context, key, value string) error {
}
func configUnsetAll(ctx context.Context, key, value string) error {
_, _, err := gitcmd.NewCommand("config", "--global", "--get").AddDynamicArguments(key).RunStdString(ctx)
if err == nil {
// exist, need to remove
_, _, err = gitcmd.NewCommand("config", "--global", "--unset-all").AddDynamicArguments(key, regexp.QuoteMeta(value)).RunStdString(ctx)
if err != nil {
return fmt.Errorf("failed to unset git global config %s, err: %w", key, err)
}
return nil
_, _, err := gitcmd.NewCommand("config", "--global", "--unset-all").AddDynamicArguments(key, "^"+regexp.QuoteMeta(value)+"$").RunStdString(ctx)
if err != nil && !gitcmd.IsErrorExitCode(err, 5) { // 5: no such key or no matching value
return fmt.Errorf("failed to unset git global config %s, err: %w", key, err)
}
if gitcmd.IsErrorExitCode(err, 1) {
// not exist
return nil
}
return fmt.Errorf("failed to get git config %s, err: %w", key, err)
return nil
}
+6
View File
@@ -32,7 +32,9 @@ func TestGitConfig(t *testing.T) {
assert.NoError(t, configSetNonExist(ctx, "test.key-a", "val-a-changed"))
assert.False(t, gitConfigContains("key-a = val-a-changed"))
assert.NoError(t, configAddNonExist(ctx, "test.key-a", "val-a2"))
assert.NoError(t, configSet(ctx, "test.key-a", "val-a-changed"))
assert.NoError(t, configUnsetAll(ctx, "test.key-a", "val-a"))
assert.True(t, gitConfigContains("key-a = val-a-changed"))
assert.NoError(t, configAddNonExist(ctx, "test.key-b", "val-b"))
@@ -41,6 +43,8 @@ func TestGitConfig(t *testing.T) {
assert.NoError(t, configAddNonExist(ctx, "test.key-b", "val-2b"))
assert.True(t, gitConfigContains("key-b = val-b"))
assert.True(t, gitConfigContains("key-b = val-2b"))
assert.NoError(t, configAddNonExist(ctx, "test.key-b", "val"))
assert.True(t, gitConfigContains("key-b = val\n"))
assert.NoError(t, configUnsetAll(ctx, "test.key-b", "val-b"))
assert.False(t, gitConfigContains("key-b = val-b"))
@@ -60,8 +64,10 @@ func TestSyncGitConfig(t *testing.T) {
defer test.MockVariableValue(&setting.GitConfig)()
assert.Empty(t, setting.GitConfig.Options)
assert.NoError(t, configSet(t.Context(), "gc.reflogExpire", "90"))
assert.NoError(t, syncGitConfig(t.Context()))
assert.True(t, gitConfigContains("commitGraph = true")) // builtin default config
assert.False(t, gitConfigContains("reflogExpire"))
setting.GitConfig.Options["sync-test.cfg-key-a"] = "CfgValA"
setting.GitConfig.Options["core.commitgraph"] = "false"
+1 -1
View File
@@ -177,7 +177,7 @@ func initGlobCompiler(g *globCompiler, pattern string, separators []rune) (Glob,
regex, err := regexp.Compile(g.regexpPattern)
if err != nil {
return nil, fmt.Errorf("failed to compile regexp: %w", err)
return nil, fmt.Errorf("failed to compile glob regexp: %w", err)
}
g.regexp = regex
+2 -2
View File
@@ -38,14 +38,14 @@ func ReloadTemplates(ctx context.Context) ResponseExtra {
// FlushOptions represents the options for the flush call
type FlushOptions struct {
Timeout time.Duration
Timeout int64
NonBlocking bool
}
// FlushQueues calls the internal flush-queues function
func FlushQueues(ctx context.Context, timeout time.Duration, nonBlocking bool) ResponseExtra {
reqURL := setting.LocalURL + "api/internal/manager/flush-queues"
req := newInternalRequestAPI(ctx, reqURL, "POST", FlushOptions{Timeout: timeout, NonBlocking: nonBlocking})
req := newInternalRequestAPI(ctx, reqURL, "POST", FlushOptions{Timeout: int64(timeout), NonBlocking: nonBlocking})
if timeout > 0 {
req.SetReadWriteTimeout(timeout + 10*time.Second)
}
+2 -3
View File
@@ -89,7 +89,6 @@ func loadGitFrom(rootCfg ConfigProvider) {
GitConfig.Options = make(map[string]string)
GitConfig.SetOption("diff.algorithm", "histogram")
GitConfig.SetOption("core.logAllRefUpdates", "true")
GitConfig.SetOption("gc.reflogExpire", "90")
secGitReflog := rootCfg.Section("git.reflog")
if secGitReflog.HasKey("ENABLED") {
@@ -97,8 +96,8 @@ func loadGitFrom(rootCfg ConfigProvider) {
GitConfig.SetOption("core.logAllRefUpdates", secGitReflog.Key("ENABLED").In("true", []string{"true", "false"}))
}
if secGitReflog.HasKey("EXPIRATION") {
deprecatedSetting(rootCfg, "git.reflog", "EXPIRATION", "git.config", "core.reflogExpire", "1.21")
GitConfig.SetOption("gc.reflogExpire", secGitReflog.Key("EXPIRATION").String())
deprecatedSetting(rootCfg, "git.reflog", "EXPIRATION", "git.config", "gc.reflogExpire", "1.21")
GitConfig.SetOption("gc.reflogExpire", secGitReflog.Key("EXPIRATION").String()+".days")
}
for _, key := range secGitConfig.Keys() {
+4 -4
View File
@@ -38,8 +38,8 @@ diff.algorithm = other
}
func TestGitReflog(t *testing.T) {
defer test.MockVariableValue(&Git)
defer test.MockVariableValue(&GitConfig)
defer test.MockVariableValue(&Git)()
defer test.MockVariableValue(&GitConfig)()
// default reflog config without legacy options
cfg, err := NewConfigProviderFromData(``)
@@ -47,7 +47,7 @@ func TestGitReflog(t *testing.T) {
loadGitFrom(cfg)
assert.Equal(t, "true", GitConfig.GetOption("core.logAllRefUpdates"))
assert.Equal(t, "90", GitConfig.GetOption("gc.reflogExpire"))
assert.Empty(t, GitConfig.GetOption("gc.reflogExpire"))
// custom reflog config by legacy options
cfg, err = NewConfigProviderFromData(`
@@ -59,5 +59,5 @@ EXPIRATION = 123
loadGitFrom(cfg)
assert.Equal(t, "false", GitConfig.GetOption("core.logAllRefUpdates"))
assert.Equal(t, "123", GitConfig.GetOption("gc.reflogExpire"))
assert.Equal(t, "123.days", GitConfig.GetOption("gc.reflogExpire"))
}
+1 -1
View File
@@ -303,7 +303,7 @@ func loadRepositoryFrom(rootCfg ConfigProvider) {
sec := rootCfg.Section("repository")
Repository.DisableHTTPGit = sec.Key("DISABLE_HTTP_GIT").MustBool()
Repository.UseCompatSSHURI = sec.Key("USE_COMPAT_SSH_URI").MustBool()
Repository.GoGetCloneURLProtocol = sec.Key("GO_GET_CLONE_URL_PROTOCOL").MustString("https")
Repository.GoGetCloneURLProtocol = sec.Key("GO_GET_CLONE_URL_PROTOCOL").String()
// MAX_CREATION_LIMIT is a shortcut that sets the default for the two per-type limits below.
// USER_/ORG_MAX_CREATION_LIMIT take precedence when explicitly set.
Repository.MaxCreationLimit = sec.Key("MAX_CREATION_LIMIT").MustInt(-1) // FIXME: INI-MUST-SIDE-EFFECT
+11 -7
View File
@@ -14,8 +14,6 @@ import (
"gitea.dev/modules/glob"
"gitea.dev/modules/setting"
"golang.org/x/net/idna"
)
type globalVarsStruct struct {
@@ -24,6 +22,8 @@ type globalVarsStruct struct {
invalidUsernamePattern *regexp.Regexp
validBadgeSlugPattern *regexp.Regexp
invalidBadgeSlugPattern *regexp.Regexp
validEmailHostName *regexp.Regexp
validEmailHostIP *regexp.Regexp
}
var globalVars = sync.OnceValue(func() *globalVarsStruct {
@@ -33,6 +33,8 @@ var globalVars = sync.OnceValue(func() *globalVarsStruct {
invalidUsernamePattern: regexp.MustCompile(`[-._]{2,}|[-._]$`), // No consecutive or trailing non-alphanumeric chars
validBadgeSlugPattern: regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`),
invalidBadgeSlugPattern: regexp.MustCompile(`[-._]{2,}|[-._]$`),
validEmailHostName: regexp.MustCompile(`^[a-zA-Z0-9][-.\w]*$`),
validEmailHostIP: regexp.MustCompile(`(?i)^\[([0-9.]+|ipv6:[0-9a-f:.]+)\]$`),
}
})
@@ -124,10 +126,12 @@ func IsEmailAddressValid(email string) bool {
return false
}
_, domain, _ := strings.Cut(email, "@")
if strings.HasPrefix(domain, "[") {
// address like "foo@[192.168.1.2]"
return true
if !globalVars().validEmailHostName.MatchString(domain) && !globalVars().validEmailHostIP.MatchString(domain) {
return false
}
_, err = idna.Registration.ToASCII(domain)
return err == nil
if strings.HasPrefix(domain, "-") || strings.HasSuffix(domain, "-") ||
strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") {
return false
}
return true
}
+2 -2
View File
@@ -135,8 +135,8 @@ func shortenFilename(filename, fallback string) string {
if filename == "" {
return fallback
}
if lastIndex := strings.LastIndexByte(filename, '/'); lastIndex >= 0 {
if secondLastIndex := strings.LastIndexByte(filename[:lastIndex], '/'); secondLastIndex >= 0 {
if dir, _, ok := strings.CutLast(filename, "/"); ok {
if secondLastIndex := strings.LastIndexByte(dir, '/'); secondLastIndex >= 0 {
return filename[secondLastIndex+1:]
}
}
+4 -3
View File
@@ -646,9 +646,10 @@ func (r *artifactV4Routes) listArtifacts(ctx *ArtifactContext) {
Size: artifact.FileSize,
}
}
for _, artifact := range table {
if artifact != nil {
list = append(list, artifact)
for _, artifact := range artifacts {
if item := table[artifact.ArtifactName]; item != nil {
list = append(list, item)
delete(table, artifact.ArtifactName)
}
}
+1 -1
View File
@@ -186,7 +186,7 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64, workflowID string)
if actor := ctx.FormString("actor"); actor != "" {
user, err := user_model.GetUserByName(ctx, actor)
if err != nil {
ctx.APIErrorInternal(err)
ctx.APIErrorAuto(err)
return
}
opts.TriggerUserID = user.ID
+3 -2
View File
@@ -5,6 +5,7 @@ package private
import (
"net/http"
"time"
"gitea.dev/models/db"
"gitea.dev/modules/graceful"
@@ -34,7 +35,7 @@ func FlushQueues(ctx *context.PrivateContext) {
// Save the hammer ctx here - as a new one is created each time you call this.
baseCtx := graceful.GetManager().HammerContext()
go func() {
err := queue.GetManager().FlushAll(baseCtx, opts.Timeout)
err := queue.GetManager().FlushAll(baseCtx, time.Duration(opts.Timeout))
if err != nil {
log.Error("Flushing request timed-out with error: %v", err)
}
@@ -44,7 +45,7 @@ func FlushQueues(ctx *context.PrivateContext) {
})
return
}
err := queue.GetManager().FlushAll(ctx, opts.Timeout)
err := queue.GetManager().FlushAll(ctx, time.Duration(opts.Timeout))
if err != nil {
ctx.PrivateUserErrorf(http.StatusRequestTimeout, "%v", err)
return
-1
View File
@@ -31,7 +31,6 @@ func OIDCWellKnown(ctx *context.Context) {
"introspection_endpoint": oidcBaseUrl + "/login/oauth/introspect",
"response_types_supported": []string{
"code",
"id_token",
},
"id_token_signing_alg_values_supported": []string{
oauth2_provider.DefaultSigningKey.SigningMethod().Alg(),
+1 -7
View File
@@ -69,13 +69,7 @@ func goGet(ctx *context.Context) {
goGetImport := context.ComposeGoGetImport(ctx, ownerName, trimmedRepoName)
var cloneURL string
if setting.Repository.GoGetCloneURLProtocol == "ssh" {
cloneURL = repo_model.ComposeSSHCloneURL(ctx.Doer, ownerName, repoName)
} else {
cloneURL = repo_model.ComposeHTTPSCloneURL(ctx, ownerName, repoName)
}
goImportContent := fmt.Sprintf("%s git %s", goGetImport, cloneURL /*CloneLink*/)
goImportContent := fmt.Sprintf("%s git %s", goGetImport, context.ComposeGoGetCloneURL(ctx, ownerName, trimmedRepoName))
goSourceContent := fmt.Sprintf("%s _ %s %s", goGetImport, prefix+"{/dir}" /*GoDocDirectory*/, prefix+"{/dir}/{file}#L{line}" /*GoDocFile*/)
goGetCli := fmt.Sprintf("go get %s%s", insecure, goGetImport)
+1 -1
View File
@@ -853,7 +853,7 @@ func convertToViewModel(ctx context.Context, locale translation.Locale, cursors
}
for _, cursor := range cursors {
if !cursor.Expanded {
if !cursor.Expanded || cursor.Step < 0 || cursor.Step >= len(steps) {
continue
}
+7
View File
@@ -88,6 +88,13 @@ func TestConvertToViewModel(t *testing.T) {
viewJobSteps, _, err := convertToViewModel(t.Context(), translation.MockLocale{}, nil, task)
require.NoError(t, err)
t.Run("out of range cursor step", func(t *testing.T) {
cursors := []LogCursor{{Step: -1, Expanded: true}, {Step: 3, Expanded: true}}
_, logs, err := convertToViewModel(t.Context(), translation.MockLocale{}, cursors, task)
require.NoError(t, err)
assert.Empty(t, logs)
})
expectedViewJobs := []*ViewJobStep{
{
Summary: "Set up job",
-1
View File
@@ -197,7 +197,6 @@ func verifyAuthWithOptionsWeb(options *common.VerifyOptions) func(ctx *context.C
ctx.HTTPError(http.StatusUnauthorized, ctx.Locale.TrString("auth.must_change_password"))
return
}
middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI())
ctx.Redirect(setting.AppSubURL + "/user/settings/change_password")
return
}
+7
View File
@@ -11,6 +11,7 @@ import (
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/log"
)
// CancelRun cancels a run's cancellable jobs and returns the run's post-cancellation state.
@@ -52,6 +53,12 @@ func cancelRun(ctx context.Context, run *actions_model.ActionRun, jobs []*action
if len(updatedJobs) > 0 || reloaded.Status != run.Status {
NotifyWorkflowRunStatusUpdate(ctx, reloaded)
}
if len(updatedJobs) == 0 && reloaded.Status != run.Status {
// the run's status was updated, so emit it for the emitter to check
if err := EmitJobsIfReadyByRun(run.ID); err != nil {
log.Error("Check jobs of run %d: %v", run.ID, err)
}
}
return reloaded, nil
}
+76
View File
@@ -0,0 +1,76 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
"gitea.dev/modules/test"
"gitea.dev/modules/timeutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestForceCancelRun_SettledRunWakesConcurrencyWaiters(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
// a run left cancelling although its caller and child are all cancelled already
run := &actions_model.ActionRun{
Title: "settled-run",
RepoID: 4,
Index: 9821,
OwnerID: 1,
WorkflowID: "test.yaml",
TriggerUserID: 1,
Ref: "refs/heads/master",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
EventPayload: "{}",
Status: actions_model.StatusCancelling,
}
require.NoError(t, db.Insert(ctx, run))
attempt := &actions_model.ActionRunAttempt{RepoID: run.RepoID, RunID: run.ID, Attempt: 1, TriggerUserID: 1, Status: actions_model.StatusCancelling}
require.NoError(t, db.Insert(ctx, attempt))
run.LatestAttemptID = attempt.ID
require.NoError(t, actions_model.UpdateRun(ctx, run, "latest_attempt_id"))
newJob := func(name string, parentID int64, isCaller bool) *actions_model.ActionRunJob {
job := &actions_model.ActionRunJob{
RunID: run.ID,
RunAttemptID: attempt.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
Name: name,
JobID: name,
Attempt: 1,
Status: actions_model.StatusCancelled,
Stopped: timeutil.TimeStampNow(),
ParentJobID: parentID,
IsReusableCaller: isCaller,
IsExpanded: isCaller,
}
require.NoError(t, db.Insert(ctx, job))
return job
}
caller := newJob("caller", 0, true)
child := newJob("child", caller.ID, false)
var emitted []int64
defer test.MockVariableValue(&EmitJobsIfReadyByRun, func(runID int64) error {
emitted = append(emitted, runID)
return nil
})()
got, err := ForceCancelRun(ctx, run, []*actions_model.ActionRunJob{caller, child})
require.NoError(t, err)
assert.Equal(t, actions_model.StatusCancelled, got.Status)
assert.Equal(t, []int64{run.ID}, emitted)
}
+4 -1
View File
@@ -188,7 +188,10 @@ func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt
if err := workflowJob.SetJob(id, job.EraseNeeds()); err != nil {
return nil, nil, false, err
}
payload, _ := workflowJob.Marshal()
payload, err := workflowJob.Marshal()
if err != nil {
return nil, nil, false, fmt.Errorf("insertRunJob: marshal job %q: %w", id, err)
}
isReusableWorkflowCaller := job.Uses != ""
status := util.Iif(runAttempt.Status == actions_model.StatusBlocked || run.NeedApproval, actions_model.StatusBlocked, actions_model.StatusWaiting)
+10 -7
View File
@@ -389,6 +389,15 @@ func ComposeGoGetImport(ctx context.Context, owner, repo string) string {
return path.Join(curAppURL.Host, setting.AppSubURL, url.PathEscape(owner), url.PathEscape(repo))
}
// ComposeGoGetCloneURL returns the clone URL for the go-import meta content.
func ComposeGoGetCloneURL(ctx *Context, owner, repo string) string {
useSSH := setting.Repository.GoGetCloneURLProtocol == "ssh" || (setting.Repository.DisableHTTPGit && !setting.SSH.Disabled)
if useSSH {
return repo_model.ComposeSSHCloneURI(ctx.Doer, owner, repo)
}
return repo_model.ComposeHTTPSCloneURL(ctx, owner, repo)
}
// EarlyResponseForGoGetMeta responses appropriate go-get meta with status 200
// if user does not have actual access to the requested repository,
// or the owner or repository does not exist at all.
@@ -402,13 +411,7 @@ func EarlyResponseForGoGetMeta(ctx *Context) {
return
}
var cloneURL string
if setting.Repository.GoGetCloneURLProtocol == "ssh" {
cloneURL = repo_model.ComposeSSHCloneURL(ctx.Doer, username, reponame)
} else {
cloneURL = repo_model.ComposeHTTPSCloneURL(ctx, username, reponame)
}
goImportContent := fmt.Sprintf("%s git %s", ComposeGoGetImport(ctx, username, reponame), cloneURL)
goImportContent := fmt.Sprintf("%s git %s", ComposeGoGetImport(ctx, username, reponame), ComposeGoGetCloneURL(ctx, username, reponame))
htmlMeta := fmt.Sprintf(`<meta name="go-import" content="%s">`, html.EscapeString(goImportContent))
ctx.PlainText(http.StatusOK, htmlMeta)
}
+1 -1
View File
@@ -65,6 +65,6 @@ type AdminEditUserForm struct {
// AdminDashboardForm form for admin dashboard operations
type AdminDashboardForm struct {
middleware.FormDefaultValidator
Op string `binding:"required"`
Op string `binding:"Required"`
From string
}
-6
View File
@@ -571,12 +571,6 @@ type AddTimeManuallyForm struct {
Minutes int `binding:"Range(0,1000)"`
}
// SaveTopicForm form for save topics for repository
type SaveTopicForm struct {
middleware.FormDefaultValidator
Topics []string `binding:"topics;Required;"`
}
// AddDeployTokenForm form for adding a deploy token to a repository
type AddDeployTokenForm struct {
middleware.FormDefaultValidator
+4 -1
View File
@@ -201,9 +201,12 @@ func (telegramConvertor) WorkflowJob(p *api.WorkflowJobPayload) (TelegramPayload
func createTelegramPayloadHTML(msgHTML string) TelegramPayload {
// https://core.telegram.org/bots/api#formatting-options
sanitized := strings.TrimSpace(string(markup.Sanitize(msgHTML)))
// Rich messages collapse bare newlines like HTML, so keep line breaks as <br>.
sanitized = strings.NewReplacer("\r\n", "<br>", "\r", "<br>", "\n", "<br>").Replace(sanitized)
return TelegramPayload{
RichMessage: InputRichMessage{
HTML: strings.TrimSpace(string(markup.Sanitize(msgHTML))),
HTML: sanitized,
},
}
}
+13 -17
View File
@@ -27,6 +27,12 @@ func TestTelegramPayload(t *testing.T) {
}, p)
})
t.Run("Line breaks are kept in rich messages", func(t *testing.T) {
// Rich messages collapse bare newlines, so they must become <br>.
p := createTelegramPayloadHTML("first line\r\nsecond line\nthird line")
assert.Equal(t, "first line<br>second line<br>third line", p.RichMessage.HTML)
})
t.Run("Create", func(t *testing.T) {
p := createTestPayload()
@@ -60,9 +66,7 @@ func TestTelegramPayload(t *testing.T) {
pl, err := tc.Push(p)
require.NoError(t, err)
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>:<a href="http://localhost:3000/test/repo/src/test" rel="nofollow">test</a>] 2 new commits
[<a href="http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778" rel="nofollow">2020558</a>] commit message - user1
[<a href="http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778" rel="nofollow">2020558</a>] commit message - user1`, pl.RichMessage.HTML)
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>:<a href="http://localhost:3000/test/repo/src/test" rel="nofollow">test</a>] 2 new commits<br>[<a href="http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778" rel="nofollow">2020558</a>] commit message - user1<br>[<a href="http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778" rel="nofollow">2020558</a>] commit message - user1`, pl.RichMessage.HTML)
})
t.Run("Issue", func(t *testing.T) {
@@ -72,9 +76,7 @@ func TestTelegramPayload(t *testing.T) {
pl, err := tc.Issue(p)
require.NoError(t, err)
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] Issue opened: <a href="http://localhost:3000/test/repo/issues/2" rel="nofollow">#2 crash</a> by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a>
issue body`, pl.RichMessage.HTML)
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] Issue opened: <a href="http://localhost:3000/test/repo/issues/2" rel="nofollow">#2 crash</a> by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a><br><br>issue body`, pl.RichMessage.HTML)
p.Action = api.HookIssueClosed
pl, err = tc.Issue(p)
@@ -89,8 +91,7 @@ issue body`, pl.RichMessage.HTML)
pl, err := tc.IssueComment(p)
require.NoError(t, err)
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] New comment on issue <a href="http://localhost:3000/test/repo/issues/2" rel="nofollow">#2 crash</a> by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a>
more info needed`, pl.RichMessage.HTML)
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] New comment on issue <a href="http://localhost:3000/test/repo/issues/2" rel="nofollow">#2 crash</a> by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a><br>more info needed`, pl.RichMessage.HTML)
})
t.Run("PullRequest", func(t *testing.T) {
@@ -99,8 +100,7 @@ more info needed`, pl.RichMessage.HTML)
pl, err := tc.PullRequest(p)
require.NoError(t, err)
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] Pull request opened: <a href="http://localhost:3000/test/repo/pulls/12" rel="nofollow">#12 Fix bug</a> by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a>
fixes bug #2`, pl.RichMessage.HTML)
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] Pull request opened: <a href="http://localhost:3000/test/repo/pulls/12" rel="nofollow">#12 Fix bug</a> by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a><br>fixes bug #2`, pl.RichMessage.HTML)
})
t.Run("PullRequestComment", func(t *testing.T) {
@@ -109,8 +109,7 @@ fixes bug #2`, pl.RichMessage.HTML)
pl, err := tc.IssueComment(p)
require.NoError(t, err)
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] New comment on pull request <a href="http://localhost:3000/test/repo/pulls/12" rel="nofollow">#12 Fix bug</a> by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a>
changes requested`, pl.RichMessage.HTML)
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>] New comment on pull request <a href="http://localhost:3000/test/repo/pulls/12" rel="nofollow">#12 Fix bug</a> by <a href="https://try.gitea.io/user1" rel="nofollow">user1</a><br>changes requested`, pl.RichMessage.HTML)
})
t.Run("Review", func(t *testing.T) {
@@ -120,8 +119,7 @@ changes requested`, pl.RichMessage.HTML)
pl, err := tc.Review(p, webhook_module.HookEventPullRequestReviewApproved)
require.NoError(t, err)
assert.Equal(t, `[test/repo] Pull request review approved: #12 Fix bug
good job`, pl.RichMessage.HTML)
assert.Equal(t, `[test/repo] Pull request review approved: #12 Fix bug<br>good job`, pl.RichMessage.HTML)
})
t.Run("Repository", func(t *testing.T) {
@@ -206,7 +204,5 @@ func TestTelegramJSONPayload(t *testing.T) {
var body TelegramPayload
err = json.NewDecoder(req.Body).Decode(&body)
assert.NoError(t, err)
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>:<a href="http://localhost:3000/test/repo/src/test" rel="nofollow">test</a>] 2 new commits
[<a href="http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778" rel="nofollow">2020558</a>] commit message - user1
[<a href="http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778" rel="nofollow">2020558</a>] commit message - user1`, body.RichMessage.HTML)
assert.Equal(t, `[<a href="http://localhost:3000/test/repo" rel="nofollow">test/repo</a>:<a href="http://localhost:3000/test/repo/src/test" rel="nofollow">test</a>] 2 new commits<br>[<a href="http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778" rel="nofollow">2020558</a>] commit message - user1<br>[<a href="http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778" rel="nofollow">2020558</a>] commit message - user1`, body.RichMessage.HTML)
}
+6 -5
View File
@@ -16,13 +16,14 @@ else
fi
# How it works:
# * snapcraft.io checks out the default branch (e.g.: main during 1.27 dev period)
# * release-nightly-snapcraft.yml builds the snap on every push to the default branch (e.g.: main during 1.27 dev period)
# * "override-pull" step gets the latest tag by date (e.g.: v1.26.1)
# * use "snap info gitea" to get the latest released tag
# * if the latest tag is not released to stable, checkout that tag and build it for "stable"
# * otherwise, build the main branch for "devel"
# * use "snap info gitea" to get the latest released tag, which is the version in the "latest/candidate" channel
# * if the latest tag is not released yet, checkout that tag and build it with grade "stable",
# the workflow publishes it to "latest/stable" and "latest/candidate"
# * otherwise, build the main branch with grade "devel", the workflow publishes it to "latest/edge"
# * "override-build" step uses build script from the checked out commit to build
# This approach highly depends on the "main" branch's push.
# This approach highly depends on the "main" branch's push: a new tag is only published by the next push after it.
# To debug the logic:
# * last_committed_tag=v1.26.1 last_released_tag=v1.26.0 ./snap/part-gitea-pull.sh
+2 -2
View File
@@ -44,8 +44,8 @@
<div class="navbar-right">
{{if and .IsSigned .MustChangePassword}}
<div class="ui dropdown jump item" data-tooltip-content="{{ctx.Locale.Tr "user_profile_and_more"}}">
<span class="text">
{{ctx.AvatarUtils.Avatar .SignedUser 24 "tw-mr-1"}}
<span class="flex-text-block">
{{ctx.AvatarUtils.Avatar .SignedUser 24}}
<span class="only-mobile">{{.SignedUser.Name}}</span>
<span class="not-mobile flex-text-block">{{svg "octicon-triangle-down"}}</span>
</span>
+1 -1
View File
@@ -1,6 +1,6 @@
{{- $itemExtraClass := .ItemExtraClass -}}
{{- $data := .PageGlobalData -}}
{{if and $data $data.IsSigned}}{{/* data may not exist, for example: rendering 503 page before the PageGlobalData middleware */}}
{{if and $data $data.IsSigned (not ctx.RootData.MustChangePassword)}}{{/* data may not exist, for example: rendering 503 page before the PageGlobalData middleware */}}
{{- $activeStopwatch := call $data.GetActiveStopwatch -}}
{{- $notificationUnreadCount := call $data.GetNotificationUnreadCount -}}
{{/* always rendered so a real-time push can reveal it without a reload */}}
@@ -682,7 +682,7 @@ jobs:
taskToken3 := task3.Context.GetFields()["gitea_runtime_token"].GetStringValue()
// the new attempt inherits what the previous attempt uploaded
assert.ElementsMatch(t, []string{"job1-only", "job1-shared"}, listArtifactNamesForRunV4(t, run.ID, job3.ID, taskToken3))
assert.Equal(t, []string{"job1-only", "job1-shared"}, listArtifactNamesForRunV4(t, run.ID, job3.ID, taskToken3))
assert.Equal(t, strings.Repeat("A", 32), downloadArtifactContentV4ByTask(t, run.ID, job3.ID, taskToken3, "job1-only"))
assert.Contains(t, listArtifactNamesForRun(t, run.ID, taskToken3), "job1-v3")
@@ -699,7 +699,7 @@ jobs:
inheritedSharedID := listArtifactIDForRunV4(t, run.ID, job3.ID, taskToken3, "job1-shared")
require.Len(t, listArtifactsByIDV4(t, run.ID, job3.ID, inheritedSharedID, taskToken3), 1)
uploadTestArtifactFileV4(t, run.ID, job3.ID, taskToken3, "job1-shared", strings.Repeat("C", 32))
assert.ElementsMatch(t, []string{"job1-only", "job1-shared"}, listArtifactNamesForRunV4(t, run.ID, job3.ID, taskToken3))
assert.Equal(t, []string{"job1-only", "job1-shared"}, listArtifactNamesForRunV4(t, run.ID, job3.ID, taskToken3))
assert.Equal(t, strings.Repeat("C", 32), downloadArtifactContentV4ByTask(t, run.ID, job3.ID, taskToken3, "job1-shared"))
// a shadowed artifact is not listed by id either: a download resolves by name
@@ -899,7 +899,7 @@ func testActionRunAttemptArtifactV4(t *testing.T, repo *repo_model.Repository, s
uploadTestArtifactFileV4(t, run.ID, job1.ID, taskToken1, "artifact-attempt-1", strings.Repeat("A", 32))
uploadTestArtifactFileV4(t, run.ID, job1.ID, taskToken1, "artifact-shared", strings.Repeat("C", 32))
attempt1Names := listArtifactNamesForRunV4(t, run.ID, job1.ID, taskToken1)
assert.ElementsMatch(t, []string{"artifact-attempt-1", "artifact-shared"}, attempt1Names)
assert.Equal(t, []string{"artifact-attempt-1", "artifact-shared"}, attempt1Names)
runner.execTask(t, task1, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
@@ -915,7 +915,7 @@ func testActionRunAttemptArtifactV4(t *testing.T, repo *repo_model.Repository, s
uploadTestArtifactFileV4(t, run.ID, job2.ID, taskToken2, "artifact-attempt-2", strings.Repeat("B", 32))
uploadTestArtifactFileV4(t, run.ID, job2.ID, taskToken2, "artifact-shared", strings.Repeat("D", 32))
attempt2Names := listArtifactNamesForRunV4(t, run.ID, job2.ID, taskToken2)
assert.ElementsMatch(t, []string{"artifact-attempt-2", "artifact-shared"}, attempt2Names)
assert.Equal(t, []string{"artifact-attempt-2", "artifact-shared"}, attempt2Names)
assert.NotContains(t, attempt2Names, "artifact-attempt-1")
// "artifact-attempt-1" belongs to the first attempt, so the rerun token cannot access it
@@ -69,6 +69,12 @@ func testAPIActionsGetWorkflowRun(t *testing.T) {
MakeRequest(t, req, http.StatusOK)
})
t.Run("ListRunsUnknownActor", func(t *testing.T) {
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs?actor=no-such-user", repo.FullName())).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
})
t.Run("GetJobSteps", func(t *testing.T) {
// Insert task steps for task_id 53 (job 198) so the API can return them once the backend loads them
_, err := db.GetEngine(t.Context()).Insert(&actions_model.ActionTaskStep{
+5
View File
@@ -58,6 +58,11 @@ func TestGoGetForSSH(t *testing.T) {
</html>`, setting.AppDomain, setting.HTTPPort, setting.AppURL, setting.SSH.Domain, setting.SSH.Port)
assert.Equal(t, expected, resp.Body.String())
// go rejects scp-style addresses, so the standard port must still produce an ssh:// URL
defer test.MockVariableValue(&setting.SSH.Port, 22)()
resp = MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), fmt.Sprintf(`git ssh://git@%s/blah/glah.git">`, setting.SSH.Domain))
}
// TestGoGetPrivateRepoBranchNotLeaked ensures the go-get meta endpoint does not disclose a
+11
View File
@@ -108,6 +108,7 @@ func TestOAuth2(t *testing.T) {
t.Run("AuthorizeNoClientID", testAuthorizeNoClientID)
t.Run("AuthorizeUnregisteredRedirect", testAuthorizeUnregisteredRedirect)
t.Run("AuthorizeUnsupportedResponseType", testAuthorizeUnsupportedResponseType)
t.Run("AuthorizeUnsupportedResponseTypeIDToken", testAuthorizeUnsupportedResponseTypeIDToken)
t.Run("AuthorizeUnsupportedCodeChallengeMethod", testAuthorizeUnsupportedCodeChallengeMethod)
t.Run("AuthorizeLoginRedirect", testAuthorizeLoginRedirect)
t.Run("AuthorizeShow", testAuthorizeShow)
@@ -163,6 +164,16 @@ func testAuthorizeUnsupportedResponseType(t *testing.T) {
assert.Equal(t, "Only code response type is supported.", u.Query().Get("error_description"))
}
func testAuthorizeUnsupportedResponseTypeIDToken(t *testing.T) {
req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=da7da3ba-9a13-4167-856f-3899de0b0138&redirect_uri=https://example.com&response_type=id_token&state=thestate")
ctx := loginUser(t, "user1")
resp := ctx.MakeRequest(t, req, http.StatusSeeOther)
u, err := resp.Result().Location()
assert.NoError(t, err)
assert.Equal(t, "unsupported_response_type", u.Query().Get("error"))
assert.Equal(t, "Only code response type is supported.", u.Query().Get("error_description"))
}
func testAuthorizeUnsupportedCodeChallengeMethod(t *testing.T) {
req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=da7da3ba-9a13-4167-856f-3899de0b0138&redirect_uri=https://example.com&response_type=code&state=thestate&code_challenge_method=UNEXPECTED")
ctx := loginUser(t, "user1")
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
set -euo pipefail
# Prints the revert commits in the given range and the commits they revert, for git-cliff's .cliffignore
range="${1:?usage: $0 <revision-range>}"
commits=$(git log --format='%H %s' "$range")
shopt -s nocasematch
while read -r sha subject; do
[[ "$subject" == revert* ]] || continue
echo "$sha"
for pr in $(git show -s --format=%B "$sha" | grep -i revert | grep -oE '(#|/pull/)[0-9]+' | grep -oE '[0-9]+' || true); do
grep -F "(#$pr)" <<< "$commits" | cut -d' ' -f1 || true
done
done <<< "$commits"
+2 -1
View File
@@ -125,7 +125,8 @@
.markup ol,
.markup dl,
.markup table,
.markup pre {
.markup pre,
.markup math[display="block" i] {
margin-top: 0;
margin-bottom: 16px;
}