Compare commits

..
Author SHA1 Message Date
c46216033c test: fix flaky TestPullView_CodeOwner racing the push hook (#38751)
`TestPullView_CodeOwner/First Pull Request` called
`PullRequestCodeOwnersReview` directly and asserted it returned one
notifier. That return value holds only the review requests that call
creates. But the push (by `files_service.ChangeRepoFiles`) to the PR
branch already triggers the same function asynchronously through
`AddTestPullRequestTask`, which makes `PullRequestCodeOwnersReview`
return an empty slice and the test then fails with
```
    Error: "[]" should have 1 item(s), but has 0
```

Fix: make `require.Eventually` wait for the review request to exist.

---------

Co-authored-by: silverwind <me@silverwind.io>
2026-08-26 14:59:37 -07:00
wxiaoguangandGitHub 068355cabd fix(repo): prevent MarkAsBrokenEmpty when repository is being migrated (#39091) (#39092) 2026-08-25 20:59:05 +00:00
3f93080f48 fix(asymkey): do not verify OpenPGP signatures with an SSH instance key (#39073) (#39086)
Co-authored-by: Artem Lytkin <iprintercanon@gmail.com>
2026-08-25 19:24:22 +00:00
d2572c7342 chore: repo compare link (#39088) (#39119)
Signed-off-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-25 18:52:54 +00:00
b70f7aeda1 fix(pull): keep the merged state in sync with git (#39062) (#39118)
Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-25 17:33:09 +00:00
3af1ee88a5 fix(pull): name the head repository in default compare links (#39075) (#39079)
Backport #39075 by @4RH1T3CT0R7

The "New Pull Request" buttons and the `/pulls/new/{branch}` redirect
build their compare link as `{owner}:{branch}`. If a fork and its parent
share an owner, through ALLOW_FORK_INTO_SAME_OWNER, or after a transfer,
that head resolves back to the base repo, so the link compares the base
against itself and 404s on a branch that only exists in the fork.

Switching to `{owner}/{repo}:{branch}` names the head repo
unambiguously, and it's what the compare page's own links already use.

Also clears the 404 in #37649; the archived-parent half of that report
is separate.

Co-authored-by: Artem Lytkin <146867384+4RH1T3CT0R7@users.noreply.github.com>
2026-08-24 20:26:27 +00:00
6d348e57bc fix(git): parse co-author trailers that are not RFC 5322 addresses (#39076) (#39081)
Backport #39076 by @4RH1T3CT0R7

Bot co-authors like `dependabot[bot]` render as one long string with the
email inside the name, and never get truncated, so they overflow the
column.

Co-author idents are parsed with `net/mail`, but a git ident isn't an
email address. `mail.ParseAddress` rejects the whole value when the name
holds characters RFC 5322 reserves, like a `[bot]` suffix or a comma, so
the error branch keeps the raw trailer as the display name and throws
the address away. No address means no `mailto:` link, and the anchor is
what `.avatar-stack-names` truncates.

So parse the angle-addr ourselves when `net/mail` won't take it.
Splitting on the last `<` is safe because git strips angle brackets from
idents. The bare-name branch gets the class too.

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

Co-authored-by: Artem Lytkin <146867384+4RH1T3CT0R7@users.noreply.github.com>
2026-08-24 22:01:07 +02:00
565e12d47b fix(attachments): enforce owning repository path (#39048) (#39077)
Backport #39048 by @bircni

Reject attachment requests routed through a repository other than the
attachment owner.

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-24 11:11:53 -07:00
25fff4c043 fix(markup): enforce same-repository issue access (#39045) (#39054)
Backport #39045 by @bircni

Enforce Issues and Pull Requests access for references within the
current repository.

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-08-22 20:06:24 +00:00
6e2cf10687 fix(actions): verify raw artifact signatures first (#39049) (#39053)
Backport #39049 by @bircni

Validate raw-artifact signatures before resolving the requested
artifact.

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-22 12:26:09 -07:00
1e86e1e45a fix(api): hide limited users from restricted viewers (#39004) (#39039)
Backport #39004 by @bircni

Use the canonical profile-visibility check for user API content and
prevent restricted users from enumerating public repositories owned by
limited users.

This keeps feeds, heatmaps, keys, and issue search consistent with
profile visibility.

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-22 10:55:16 +00:00
0d946be8be fix(repo): limit gitignore template selections (#39027) (#39040)
Backport #39027 by @bircni

Bound gitignore template selections at both web and API request
boundaries before repository initialization.

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-08-22 10:26:37 +00:00
3f01f06d3b fix(migrations): cancel GitLab version probes (#39023) (#39035)
Backport #39023 by @bircni

Bind the GitLab version probe to the migration context so a cancelled
migration does not remain blocked on a remote response.

Co-authored-by: bircni <bircni@icloud.com>
2026-08-22 08:52:31 +00:00
9fe8653e45 fix(packages): limit Swift package manifests (#39025) (#39032)
Backport #39025 by @bircni

Bound the number and aggregate size of Swift manifests retained from an
uploaded archive.

Co-authored-by: bircni <bircni@icloud.com>
2026-08-22 08:22:39 +00:00
1350cf8a38 enhance: add permalinks to pull request reviews (#38849) (#39036)
Backport #38849 by @silverwind

1. Make review threads linkable via `#pullrequestreview-<reviewID>`
2. Improve CSS so username and timestamp go colored on hover.
3. CSS cleanup, remove dead rules, nonexistant class name, make
`.suppressed` actually do what it says in the doc above.

<img width="351" height="84" alt="image"
src="https://github.com/user-attachments/assets/6ddf15d4-3d6e-4846-ab82-0ad0b19b372f"
/>

Co-authored-by: silverwind <me@silverwind.io>
2026-08-22 07:48:11 +00:00
f64ee9d1ed fix(migrations): bound OneDev version responses (#39024) (#39033)
Backport #39024 by @bircni

Limit OneDev version responses before parsing so a remote server cannot
make a migration retain an unbounded response.

Co-authored-by: bircni <bircni@icloud.com>
2026-08-21 19:41:16 -07:00
b5c6b80790 fix(packages): limit Maven checksum uploads (#39028) (#39031)
Backport #39028 by @bircni

Bound checksum uploads to the maximum usable digest length before
buffering their content.

Co-authored-by: bircni <bircni@icloud.com>
2026-08-22 00:33:24 +00:00
c9da6553a2 fix(packages): bound Alpine metadata entries (#39026) (#39029)
Backport #39026 by @bircni

Limit retained dependency and provision metadata while parsing Alpine
package information.

Co-authored-by: bircni <bircni@icloud.com>
2026-08-21 23:41:14 +00:00
dbc17db0c2 fix(actions): enforce fork pull request trust boundaries (#39005) (#39018)
Backport #39005 by @bircni

Preserve fork pull request restrictions across review-triggered
workflows, reusable workflow access, job scheduling, and filtered
workflow statuses.

This prevents untrusted fork workflow content from bypassing approval,
accessing private reusable workflows, or satisfying protected status
checks.


_Assisted-by: Codex:GPT-5_

Co-authored-by: bircni <bircni@icloud.com>
2026-08-21 14:25:59 +00:00
bf7b6f8bd4 fix(git): restrict hook permissions (#39008) (#39016)
Backport #39008 by @bircni

Create delegate hook files and directories without group or other write
access, including correcting existing hook directories.

_Assisted-by: Codex:GPT-5_

---------

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-21 12:40:30 +00:00
76c768edca fix(api): enforce repository creation token authorization (#39007) (#39014)
Backport #39007 by @bircni

Reject public-only tokens for repository migrations and require
repository scope for canonical organization repository creation. This
aligns both routes with the existing token authorization boundaries.

_Assisted-by: Codex:GPT-5_

Co-authored-by: bircni <bircni@icloud.com>
2026-08-21 09:44:25 +00:00
320b44df58 fix(api): enforce public-only scope for compare heads (#39006) (#39013)
Backport #39006 by @bircni

Enforce public-only token scope for repositories resolved as compare
heads.

_Assisted-by: Codex:GPT-5_

Co-authored-by: bircni <bircni@icloud.com>
2026-08-21 09:15:02 +00:00
078bb4fa07 fix(repo): hide repositories of hidden owners (#39009) (#39012)
Backport #39009 by @bircni

Exclude public repositories owned by hidden individual accounts from
broad repository listings, while preserving visibility through explicit
access and ownership.

_Assisted-by: Codex:GPT-5_

Co-authored-by: bircni <bircni@icloud.com>
2026-08-21 01:51:30 -07:00
883ed3b0bd fix(actions): show "Complete job" logs when the last step is skipped (#38939) (#39003)
Backport #38939 by @bircni

`FullSteps` only gave the synthetic "Complete job" step the remaining
log range when the last step that had run was also the final step of the
job. A skipped step does not count as having run, so any job ending in a
skipped step left the post step with an empty range: its logs were
stored but never rendered, and the duration showed as `0s`.

Reproducible with any job whose last step is skipped, which is common
for failure notifications:

```yaml
steps:
  - run: echo hello
  - run: echo never
    if: failure()
```

The gate now checks whether the final step is done, which preserves the
behaviour from https://github.com/go-gitea/gitea/pull/29926 of showing
the post step as waiting while steps are still pending.
--> Regression from https://github.com/go-gitea/gitea/pull/29926

Signed-off-by: bircni <bircni@icloud.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-08-20 19:37:30 +00:00
eee37e0560 fix: avoid enumerating every public repository in issue search (#38992) (#39000)
Backport #38992 by @lunny

Both issue search endpoints resolve their repository filter with
`SearchRepositoryIDs` and pass the result to the indexer as `RepoIDs`.
They mean
to leave public repositories to the indexer, but
`SearchRepoOptions.AllPublic` is
only read when `OwnerID > 0`, so without an `owner` filter the flag does
nothing
and every public repository is enumerated, without a `LIMIT`, into
`repo_id IN (...)`.

Those IDs are redundant, as `allPublic` is passed to the indexer, which
already
matches every public repository. On a large instance this binds tens of
thousands
of parameters and can fail in the driver, making the endpoint return 500
for every
filter. Admins are worst hit, as `SearchRepositoryCondition` skips their
accessible-repository condition and enumerates the whole table.

Restrict the enumeration to private repositories. The result set is
unchanged, as
the dropped IDs are a subset of what `allPublic` matches.

Both endpoints held copies of this block, so it moves to
`routers/common`.

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-08-20 19:51:20 +02:00
4e515cce99 fix(actions): Fix how jobs in matrixes are grouped (#38980) (#38998)
Backport #38980 

The workflow graph decided which job rows belonged to the same matrix by
parsing display names: it stripped a trailing `" (...)"` off `name` and
grouped rows sharing the prefix. That guesses at a string the user
controls, and it fails both ways. `jobparser` only appends the `
(<combination>)` suffix when `name:` contains no `${{ }}`, so a leg
named `E2E on ${{ matrix.browser }}` never grouped, while two unrelated
jobs `build (fast)` and `build (slow)` folded into one bogus matrix
panel.

Matrix legs already have a real identity: expansion clones one row per
combination, all sharing the workflow's `JobID` and differing only in
`Name`. Group on that instead, so a matrix is whatever the backend says
it is. Matrix expansion state is keyed on the graph node id for the same
reason.

Closes https://github.com/go-gitea/gitea/issues/38975, though that
report's own example already groups on main, since `explicit (${{
matrix.leg }})` interpolates to a name that still ends in a suffix. The
interpolated shapes above are the broken ones.

## Screenshots:

Before:
<img width="1268" height="618" alt="image"
src="https://github.com/user-attachments/assets/2d98dd1f-5454-423c-b610-8db920f1e99c"
/>

after:
<img width="1145" height="607" alt="image"
src="https://github.com/user-attachments/assets/25da1804-8386-4f5b-a4e3-f63380010907"
/>


_Assisted-by: Claude Code:claude-opus-5_

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-20 17:39:47 +02:00
silverwindandGitHub d8e179f28f fix: resolve YAML anchors and aliases in Actions workflows (#38984) (#38996)
Backport https://github.com/go-gitea/gitea/pull/38984

Workflows using YAML anchors are rejected as invalid, because a workflow
is split into one document per job and an alias whose anchor lands in
another job's document no longer resolves. Node walkers such as `on:`
parsing have no alias case either.

Aliases are now expanded once, right after the workflow is parsed and
before anything reads or splits it, bounded like GitHub's parser so
nested aliases cannot expand without limit. Merge keys stay unsupported,
as they are upstream.

Fixes: https://github.com/go-gitea/gitea/issues/38983
2026-08-20 11:16:33 +02:00
wxiaoguangandGitHub 616dbddda4 fix: honor environment variables during install (#38974) (#38976)
backport #38974
2026-08-19 18:35:49 +02:00
wxiaoguangandGitHub 6024280a23 refactor: private endpoints (#38964) (#38965)
backport #38964
2026-08-18 11:19:55 +00:00
96c651382f fix: grant limited-org unit read access to authenticated non-members (#38871) (#38963)
backport #38871

Co-authored-by: silverwind <me@silverwind.io>
2026-08-18 08:16:14 +00:00
50be834d24 fix: allow anonymous theme switching when REQUIRE_SIGNIN_VIEW is set (#38956) (#38961)
Backport #38956 by @bircni

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

`POST /-/web-theme/apply` used the `optSignIn` middleware, which forces
sign-in when `REQUIRE_SIGNIN_VIEW` is enabled. This made theme switching
unusable for anonymous users (e.g. on the sign-in page), even though the
handler already supports anonymous users by storing the choice in a
cookie.

This was an unintended regression from
https://github.com/go-gitea/gitea/pull/36183, which replaced the route's
CSRF-only middleware with `optSignIn`, incidentally pulling in the
sign-in requirement meant for content routes.

The fix drops the sign-in requirement for this route while keeping
cross-origin protection and the usual signed-in-user checks
(inactive/prohibited login, forced password change).

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-08-18 07:24:18 +02:00
f4bff43d46 fix(actions): drop wrapper span around the action status icon (#38957) (#38959)
Backport #38957

Co-authored-by: silverwind <me@silverwind.io>
2026-08-18 12:05:20 +08:00
11de54dd41 fix(issues): sort scoped labels by exclusive order in dropdowns (#38893) (#38954)
Backport #38893 by @HsukqiLee

Closes #38872

Labels in the label selection dropdown (issue/PR sidebar, new issue
form) were always listed alphabetically, so a scoped set like the
default Priority labels showed up as Critical, High, Low, Medium even
though each label carries an exclusive order.

This adds `CompareLabelForDisplay`/`SortLabelsForDisplay` in
`models/issues`: labels are grouped by their exclusive scope and sorted
by exclusive order within a scope (unordered ones last), falling back to
name order. The sorting is applied to the issue page sidebar data and
the shared label filter data, so the filter dropdown on the issue list
gets the same ordering.

Unscoped labels are unaffected and still sort by name. Includes a unit
test covering the default Priority label set.

Co-authored-by: Hsukqi Lee <team@tsinbei.com>
2026-08-17 19:23:37 +00:00
1c3ae57f35 fix(indexer): correct bleve indexer token filters (#38853) (#38951)
Backport #38853 by @gomitrah

* fix #36228
* fix #37221

Co-authored-by: Mitrahsoft <bala.c@mitrahsoft.in>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-08-17 19:51:24 +02:00
cbcf36cded fix: make "login_name" field optional for API edit user (#38917) (#38945)
Backport #38917

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-08-16 14:03:57 +00:00
bircniandGitHub 5a665c0b0e fix(actions): reject non-mapping matrix include/exclude (#38933) 2026-08-15 20:47:11 +02:00
0aa0ea17bc fix(ui): respect FEED_PAGING_NUM on the dashboard feed (#38935) (#38936)
Backport #38935 by @bircni


The dashboard activity feed was paginated with `[ui.user]
REPO_PAGING_NUM`
instead of `[ui] FEED_PAGING_NUM`.

The wrong setting was picked up when the page size was hoisted into a
local
variable in https://github.com/go-gitea/gitea/pull/34994, most likely
copied
from the `dashboardRepoList` block a few lines above. `REPO_PAGING_NUM`
should
only control repository lists.

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

Co-authored-by: bircni <bircni@icloud.com>
2026-08-15 14:15:05 +02:00
Lunny XiaoandGitHub 11cd8ff69a ci: remove AWS S3 uploads from release workflows (#38928) (#38929)
Backport #38928 to `release/v1.27`.

The automatic backport failed because `release/v1.27` still pins an
older `aws-actions/configure-aws-credentials` SHA, so the change is
applied manually here.

Release binaries have been served from Cloudflare R2 for a while, so the
`configure aws` and `upload binaries to s3` steps are removed from the
nightly, RC and version release workflows. Since
`configure-aws-credentials` no longer runs in those jobs, the
`AWS_REGION: auto` workaround in the R2 step is dropped as well.
2026-08-14 23:55:21 +00:00
33498f828d chore: Pre-register a builtin OAuth2 application for the official Gitea mobile app (#38880) (#38922)
Backport #38880 by @lunny

This is a prepare and required step for upcoming Gitea Official Mobile
APP which supports login with OAuth2.

Gitea already pre-registers OAuth2 applications for a few universally
useful clients (`git-credential-oauth`, Git Credential Manager and
`tea`), so those tools can run an Authorization Code + PKCE login
against any instance without the user having to create an OAuth
application by hand.

The official Gitea mobile app needs the same mechanism. This adds a
builtin application for it:

| | |
|---|---|
| client ID | `b757811a-05c8-4c76-8d74-a5ee3d2073f2` |
| config name | `gitea-app` |
| display name | `Gitea App` |
| redirect URI | `com.gitea.app://oauth/callback` |

Unlike the existing entries, which are CLIs and can therefore use a
loopback `http://127.0.0.1` redirect, a mobile app authorises through a
system browser session (`ASWebAuthenticationSession` on iOS, Custom Tabs
on Android) that can only receive a custom-scheme callback, hence the
custom scheme here.

Notes:

* Builtin applications are inserted directly by `auth.Init`, so they do
not pass through `DetectInvalidOAuth2ApplicationRedirectURI`, which is
only applied to user- and API-created applications. No `[oauth2]
CUSTOM_SCHEMES` configuration is required for this to work, and
`ContainsRedirectURI` matches the URI by normalised string comparison.
* Instances that do not want the application pre-registered can drop
`gitea-app` from `[oauth2] DEFAULT_APPLICATIONS`, exactly as with the
existing entries; `auth.Init` then deletes it again.
* The client is public: no client secret, PKCE `S256` required.

---
Generated by Codet

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-08-14 15:17:59 +02:00
bircniandGitHub 1dac1bb2f8 docs: Add Changelog for 1.27.2 (#38910) 2026-08-13 17:34:13 +00:00
38cf2a2cfb fix(migrations): use all configured GitHub tokens (#38841) (#38846)
Backport of https://github.com/go-gitea/gitea/pull/38841

The test import needs `go-github/v88` here, because this branch pins v88
while main is on v89. The rest applies unchanged.

---------

Co-authored-by: linnuo <3092544409@qq.com>
2026-08-13 17:27:12 +00:00
bircniandGitHub 3604189b08 fix(actions): keep github.event.inputs as strings for workflow_dispatch (#38899) (#38908)
Backport #38899 

`github.event.inputs` must mirror the raw `workflow_dispatch` payload,
where
GitHub keeps every input as a string. Only the separate `inputs` context
preserves declared types, e.g. booleans. A previous fix coerced boolean
inputs in the single map that fed both contexts, so
`github.event.inputs.someBool` became a real boolean and comparisons
like
`== 'true'` stopped matching.

`github.event.inputs` now stays string-only again. The `inputs` context
used
for server-side `if:` evaluation of needs-gated/matrix-deferred jobs
re-coerces booleans independently, from the job's own workflow
declaration,
so that path keeps working correctly.

Fixes https://github.com/go-gitea/gitea/issues/38896
2026-08-13 14:15:03 +00:00
bircniandGitHub ba4db8a2d9 fix(actions): let a rerun of selected jobs read the previous attempt's artifacts (#38857) (#38901)
Backport #38857

Fixes #38773

## Background

Artifacts became attempt-scoped in #37119, and the runner-facing
artifact APIs filter strictly by the attempt of the running job. "Re-run
failed jobs" creates a new attempt whose passed-through jobs never
upload their artifacts again, so a re-run job that downloads one of them
fails with "artifact not found".

## Fix

The read paths (v3 and v4 list and download) now resolve artifacts
across the running job's attempt plus the attempts it inherits from, and
an inherited artifact is shadowed by a same-named one from a newer
attempt.

## Note

GitHub's documentation does not document these behaviors. The
conclusions below are based on manual testing, so consistency with
GitHub cannot be guaranteed.

- In a "partial re-run", a job can download artifacts uploaded by an
earlier attempt, every attempt keeps its own copy of a name, and a
lookup by name resolves to the newest one.
- A full "Re-run all jobs" never downloads artifacts from earlier
attempts.
2026-08-13 13:31:42 +00:00
wxiaoguangandGitHub 0acbcc58a7 fix: update collaborator access mode and httpsign (#38894, #38862) (#38895)
backport #38894, partially #38862
2026-08-13 09:59:07 +00:00
88b56d408d refactor: external render (#38885) (#38898)
Backport #38885 by @wxiaoguang

make the "command variable replacement" more accurate and
OS-independent, add a test for it.

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-08-12 23:17:39 -07:00
1c92062c69 fix(actions): resolve pull_request_target reusable workflows at the base commit (#38886) (#38897)
Backport #38886

For a `pull_request_target` (PRT) run, Gitea loads the top-level
workflow from the trusted base branch, but any local reusable workflow
it calls (`uses: ./...`) was read from the PR **head** commit, which the
fork author controls.

**Record the source commit where the content is read.**
`DetectedWorkflow` now carries a `SourceCommitSHA` filled in next to
`Content`, so the PRT detection pass at the base commit records the base
SHA automatically.

**Defense in depth.** `loadReusableWorkflowSource` pins the PR base
commit for a PRT run's local `uses: ./...` rather than trusting the
stored SHA. This also covers runs recorded before this change, whose
rows still hold the head SHA and would otherwise resolve from the fork
on rerun.

Existing run rows are not migrated.

Co-authored-by: bircni <bircni@icloud.com>
2026-08-12 21:12:39 +02:00
51938de973 fix(lfs): accept successful transfer responses (#38866) (#38875)
Backport #38866 by @Pachinko0

Accept all 2xx LFS transfer responses so uploads returning 201 Created
are not handled as errors and decoded as empty error bodies.

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

----

Co-authored-by: waterWang <waterWang@users.noreply.github.com>

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Pachink0 <55147665+Pachinko0@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-08-11 09:43:05 -07:00
wxiaoguangandGitHub 21fda8f5be refactor: markup render (#38864) (#38869)
backport #38864

1. add missing CSP header to api & web render endpoints.
2. make jupyter render skip post-processors, nothing to process
2026-08-11 18:00:47 +02:00
9ab9c18919 enhance: add missing npm package metadata properties (#38826) (#38831)
Backport #38826 by @silverwind

The npm packument left `time`, `keywords` and `maintainers` empty
although the data was available. `created` and `modified` are derived
from the versions currently served, as there is no package-level
timestamp to read them from.

Co-authored-by: silverwind <me@silverwind.io>
2026-08-08 16:50:10 +00:00
e2a0a87ae0 fix(packages): ignore nested Package.swift (#38788) (#38836)
Backport #38788 by @terriblegoodday

Nested `Package.swift` files overwrote the real package manifest. The
parser matched on the base name and kept the last entry in ZIP order.

`apple/swift-collections` ships `Benchmarks/Package.swift` and
`Utils/Debugger/FormatterFixtures/Package.swift`. The latter sorts after
the root `Package.swift`, so the registry stored a fixture manifest with
the wrong `swift-tools-version`, causing a toolchain mismatch and a
failed build. GRDB, swift-markdown, swift-syntax, sentry-cocoa and
SDWebImage share this layout.

The parser now keeps only manifests from the shallowest directory
holding one. That covers both a package at the archive root and the
single top level directory `swift package archive-source` produces. At
equal depth the first directory by name wins, so an archive always
yields the same metadata.

A nested manifest above the size limit no longer rejects the upload.

Assisted by Claude Opus 5 and Claude Fable.

Co-authored-by: Eduard Dzhumagaliev <ed_dzhumagaliev@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-08 08:43:21 -07:00
silverwindandGitHub 92044649a0 fix(auth): set WebAuthn user verification per request (#38810)
Backport #38805

Registration omitted `userVerification`, so Chromium raised the
credential to credProtect level 3 and the authenticator then hid it from
the second-factor login, which asked for `discouraged`. Registration and
each login now set their own value, with `preferred` on the second
factor so credentials already registered at level 3 keep working without
re-enrollment.

Also add relevant e2e test coverage for webauthn, one test is chromium
only because Firefox lacks the APIs needed.

Fixes https://github.com/go-gitea/gitea/issues/33531
Fixes https://github.com/go-gitea/gitea/issues/36019
Fixes https://github.com/go-gitea/gitea/issues/38139
2026-08-08 10:11:04 +00:00
silverwindandGitHub 94011d2850 fix(ui): change underlines to default browser style (#38819) (#38823)
Remove all underline style customization on links, letting the browser defaults apply
2026-08-08 01:49:41 +00:00
wxiaoguangandGitHub fe252be0ae fix(storage): fix Azure Blob dump failing with file does not exist (#38814) (#38828)
backport #38814
2026-08-07 23:08:46 +00:00
cca0c65a6c fix: drop newline-bearing member names in arch ParsePackage (#38102) (#38830)
Backport #38102 by @metsw24-max

The arch parser keeps tar member names verbatim. The index writer joins
those values one per line into the pacman database. So a member name
with a newline adds lines to that package's own `files` entry, which
libalpm reads as further fields.

The scope is one package record. An uploader cannot forge entries for
another package, and can set the same fields in `.PKGINFO` anyway. This
is input validation, not a privilege boundary.

`ParsePackage` now drops names that contain CR or LF. `joinFields` drops
such values again when writing the index, which also covers packages
that are already stored. Real packages never carry newlines in file
paths, so well-formed uploads are unaffected.

Co-authored-by: metsw24-max <metsw24@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-08 00:46:08 +02:00
6387c8ba6e fix(migration): migration deletion returned json redirection (#38796) (#38825)
Backport #38796 by @lunny

Fix #38596

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-08-07 14:00:02 +00:00
silverwindandGitHub 6eab271921 fix(deps): update dependency mermaid to v11.16.1 [security] (#38816)
Backport #38813
2026-08-07 09:53:50 +02:00
Zettat123andGitHub eab225f095 fix(actions): allow cancelling runs without running jobs (#35842) (#38812) 2026-08-07 06:54:08 +02:00
silverwindandGitHub 00a637295e fix(actions): evaluate each ${{ }} part on its own (#38754) (#38797)
Backport of https://github.com/go-gitea/gitea/pull/38754

Every `${{ }}` part was spliced as raw text into a synthesized
`format('...', <raw>)` call and re-parsed, so unbalanced parentheses
restructured the whole expression:

```yaml
run-name: ${{ 1) && (2 }}          # panicked, aborting workflow parsing for the push
if: ${{ 1 }} ${{ 0) && (0 }}       # silently skipped the job
runs-on: ${{ nosuchcontext.x }}    # silently queued the job against the label ""
```

One scanner shaped like GitHub's template reader now splits every value
and each part is evaluated on its own, so nothing builds an expression
out of text. A part that fails is an error instead of an empty string.

`expressionCallsFunction` is self-contained here, since this branch has
no `expressionsMatch` to build it on. That makes
`github.com/rhysd/actionlint` a direct dependency, which it already is
on `main`.
2026-08-06 23:28:48 +00:00
wxiaoguangandGitHub 4e64b3a65d fix: render highlight language (#38793) (#38795)
backport #38793
2026-08-06 10:34:28 +00:00
b71adfe1ad fix(actions): write an action task report in one transaction (#38792) (#38794)
Backport #38792 by @silverwind

`UpdateTaskByState` wrote the task, its job and its steps in separate
statements. An interruption in between left the task finished with a
running job, so the run stayed in progress, and the "state is final"
early return made every retry, cancel and cleanup a no-op.

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

Co-authored-by: silverwind <me@silverwind.io>
2026-08-06 06:45:42 +00:00
e5c6669751 fix: markup link (#38764) (#38765)
Backport #38764

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-08-03 15:04:52 +00:00
e0e10052e0 fix: set a minio part size when the content size is unknown (#38753) (#38755)
Backport #38753 by @Zettat123

## Background

`MinioStorage.Save` is called with `size = -1` on several paths,
including Actions logs, Actions artifacts, repository archives, avatars
and attachments. With an unknown size (-1) minio-go assumes a 5TiB
object and allocates a single part-sized buffer of 528MiB per upload,
regardless of the real payload size, which can exhaust the memory of
small instances.

Measured against a local S3 stub, five sequential uploads of a 4KiB
payload grew RSS by 1041MiB before the fix and by 34MiB after it.

## Fix

Pass an explicit 16MiB part size in that case, the same value minio-go
uses as minimum part size
(https://github.com/minio/minio-go/blob/v7.2.1/constants.go#L28).

Uploads with a known size are left untouched, since minio-go already
derives a part size proportional to the real object size.

## Note:

One behaviour change: with an unknown size the object is now limited to
16MiB * 10000 parts = 156.25GiB
(https://github.com/minio/minio-go/blob/v7.2.1/api-put-object-common.go#L112-L116).
`putObjectMultipartStreamNoLength` completes the upload once it runs out
of parts without checking that the reader was drained, so a stream past
that limit is silently truncated rather than rejected. The previous
limit was 5TiB. No payload Gitea uploads comes close to either.

Parts are also uploaded serially, so a smaller part size means
proportionally more round trips for large unknown-size uploads.

Co-authored-by: Zettat123 <zettat123@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-03 00:17:13 +00:00
c461575af3 fix: bad path escape in subpath archive download (#38749) (#38750)
Backport #38749

Co-authored-by: TheFox0x7 <thefox0x7@gmail.com>
2026-08-02 19:30:05 +00:00
wxiaoguangandGitHub 7fb9602961 fix: remove the pull merge box from UI when the refreshed page doesn't contain it (#38742) (#38744)
backport #38742
2026-08-02 16:55:17 +02:00
wxiaoguangandGitHub a8e80ebc23 fix(lfs): failed upload deletes a concurrent upload's meta object (#38693) (#38722)
backport #38693
2026-07-31 13:41:26 +00:00
8ab5d31cf3 fix(markdown): fix double strikethough on code (#38707) (#38729)
Backport #38707 by @silverwind

`.markup del code { text-decoration: inherit }` makes inline code inside
`<del>` paint its own line-through in addition to the one already
propagating from the parent. Since `code` is `font-size: 85%`, the two
land at different heights and render as a doubled strikethrough.

The rule was inherited from primer-markdown, where it was added in
https://github.com/primer/css/commit/762b8b8264aa4c4beed0a7f842f90c142eb2b310
("so that `<del>` or `<a>` has the same effects on `<code>` tags") at a
time when inline `code` was `display: inline-block`, a box that text
decorations do not propagate into. That `display: inline-block` was
removed 11 days later in
https://github.com/primer/css/commit/f1131d5ab618ac41d4097823b511ca0b373b2ee2,
which made the rule redundant, but it survived the squashed import into
primer/css and every copy downstream of it.

No other popular markdown stylesheet carries an equivalent rule, GitHub
still ships it and shows the same doubled line.

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

Co-authored-by: silverwind <me@silverwind.io>
2026-07-31 06:12:20 -07:00
b2af380d66 fix: correct full url when using sub-path (#38712) (#38716)
Backport #38712

fix #38708

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-07-30 19:34:26 +00:00
wxiaoguangandGitHub d2603a8b4a fix: avoid markup render panic (#38698) (#38703)
backport #38698, fix #38697
2026-07-30 13:41:12 +08:00
9eac9bd032 fix(ui): too many participants shown in commit avatar stacks (#38689) (#38700)
Backport #38689

Show only author and co-authors without committer, and deduplicate the
same user with multiple email addresses.

The commit list "Author" column should not show the committer. This is
somewhat misleading, and arguably showing it on the individual commit
page is sufficient and consistent with other forges.

The same user with multiple email addresses often happens when
DEFAULT_KEEP_EMAIL_PRIVATE is enabled and Gitea does not use an actual
email address by default for edits. Showing the same avatar and name
twice is not helpful then.

Ref #37594
Fix #38488

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-29 21:39:09 +02:00
784d88814f fix: support HEAD requests on Alpine registry APKINDEX.tar.gz (#38686) (#38688)
Backport #38686 by @waterWang

### Description

Fixes #38676

The Alpine package registry registers for only, so a request returns .
Clients that probe the index with before fetching it (like and other
-based tools) fail outright.

**Fix:** Change to for the APKINDEX.tar.gz route, matching the pattern
already used by the i386 registry a few lines below.

### Related issue

Closes #38676

Co-authored-by: water <672684719@qq.com>
Co-authored-by: waterWang <waterWang@users.noreply.github.com>
2026-07-28 14:46:13 +00:00
bircniandGitHub a62dfffbe7 docs: Clean up build section in CHANGELOG.md (#38671) 2026-07-27 20:43:03 +02:00
bircniandGitHub 035dd58664 docs: update changelog for 1.27.1 (#38668)
added the changelog for 1.27.1

---------

Signed-off-by: bircni <bircni@icloud.com>
2026-07-27 20:08:22 +02:00
0d9ce64f76 fix: skip OIDC end-session after password login for OAuth2 users (#38439) (#38666)
Backport #38439 by @Otto-Deviant1904

Fixes #38209

OAuth2-linked accounts that sign in via the password form were still
redirected to the provider end_session_endpoint on logout because the
redirect was keyed off account LoginType.

Store the session sign-in method (password vs oauth2) and only use
RP-initiated OIDC logout when this session was authenticated via OAuth2.
Sessions without the new key keep the previous LoginType behavior.


Co-authored-by: Harsh Satyajit Thakur <f20240223@goa.bits-pilani.ac.in>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-27 16:23:49 +00:00
4a77fbce28 ci: set AWS_REGION for Cloudflare R2 upload steps (#38658) (#38665)
Backport #38658 by @lunny

## What to build

Fix the Cloudflare R2 upload steps added in #38635, which currently fail
on every release run:

```
fatal error: An error occurred (InvalidRegionName) when calling the ListObjectsV2 operation:
The region name '***' is not valid. Must be one of: wnam, enam, weur, eeur, apac, oc, auto
```

### Root cause

The `configure-aws-credentials` step earlier in the same job exports
**both** `AWS_DEFAULT_REGION` and `AWS_REGION` into `$GITHUB_ENV`
(verified in `exportRegion()` at the pinned SHA `517a711`), so
`secrets.AWS_REGION` (the real AWS region) stays set for every later
step in that job.

The AWS CLI v2 region resolution order is `--region` > `AWS_REGION` >
`AWS_DEFAULT_REGION`. The R2 step only set `AWS_DEFAULT_REGION: auto`,
so the leaked `AWS_REGION` won and R2 rejected it, since R2 only accepts
`wnam`, `enam`, `weur`, `eeur`, `apac`, `oc` or `auto`.

The failure log shows both `AWS_DEFAULT_REGION: auto` and `AWS_REGION:
***` in the step env, which confirms the leak.

### Fix

Set `AWS_REGION: auto` explicitly in the R2 upload step env of all three
release workflows. Step-level `env:` is applied after
`GITHUB_ENV`-derived variables, so this reliably overrides the leaked
value.

No other variable leaks from `configure-aws-credentials` matter here:
`AWS_SESSION_TOKEN` is only exported when a session token exists, and
these workflows use static access keys without `role-to-assume`;
`AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` are already overridden
in the R2 step.

Consolidating the three duplicated upload steps into a composite action
remains a follow-up, as noted in #38635.

## Acceptance criteria

- [x] `AWS_REGION: auto` set in the R2 upload step of
`release-nightly.yml`, `release-tag-rc.yml` and
`release-tag-version.yml`
- [x] No other behaviour changed; the existing S3 upload steps are
untouched
- [x] All three workflows still pass `actionlint` and YAML parsing
- [ ] The next nightly release run syncs to R2 without
`InvalidRegionName`

## Blocked by

- None -- can start immediately.

---
Generated by Codet

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-07-27 15:24:53 +00:00
2b37732d5f fix: make Actions log parser support multiple line message encoding (#38659) (#38664)
Backport #38659

fix #38652


UI part (`.log-msg`) uses "white-space: break-spaces;" so the new line
can be correctly rendered.

Signed-off-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-07-27 14:47:14 +00:00
2695b47887 fix(actions): use base branch ref for pull_request_target context (#38636) (#38657)
Backport #38636 by @SudhanshuMatrix

Fixes a bug in Actions context generation for `pull_request_target`
workflows where `github.ref` / `gitea.ref` was incorrectly populated
with `refs/heads/owner:branch` instead of `refs/heads/branch`.

### Problem Statement
In `services/actions/context.go`, when constructing the `ref` string for
`pull_request_target` events:
```go
ref = git.BranchPrefix + pullPayload.PullRequest.Base.Name

Signed-off-by: Sudhanshu Singh <sudhanshuwriterblc@gmail.com>
Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Shudhanshu Singh <sudhanshuwriterblc@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-27 07:35:03 +00:00
9c685dedbb fix(actions): skip already-approved runs in ApproveRuns (#38653) (#38654)
Backport #38653 by @Zettat123

The handler of `/actions/runs/{run}/approve` doesn't check if the run is
already approved. If a run is re-approved, its jobs' status will be
reset to `StatusWaiting`, causing incorrect job status.

Co-authored-by: Zettat123 <zettat123@gmail.com>
2026-07-27 06:35:25 +00:00
e9b3917042 chore(build): upload release to Cloudflare R2 (#38635) (#38651)
Backport #38635

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-07-27 13:07:23 +08:00
wxiaoguangandGitHub d7bc52beea refactor: git patch apply (#38637) (#38638)
Backport #38637
2026-07-26 17:32:02 +00:00
ccd38f9a70 fix: orgmode render include path (#38642) (#38645)
Backport #38642

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: TheFox0x7 <thefox0x7@gmail.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-07-26 16:58:22 +00:00
f47e3d930d fix(actions): cancel tasks immediately when the runner stopped reporting (#38616) (#38644)
Backport #38616 by @bircni

Fixes jobs that get stuck in `cancelling` after Gitea is restarted while
a job is running.

Reproduction:

1. Run a job with `sleep 100`
2. Stop Gitea and wait 120s (> 100s)
3. Start Gitea — the job is still `running`; cancel it, and it stays in
`cancelling`

## Cause

A cancellation is only ever delivered to a runner as the *response* to
its `UpdateTask`
RPC. A runner that has already given up on the task — it crashed, or it
failed to report
the final state while Gitea was unreachable — never calls `UpdateTask`
again, so it never
learns about the cancellation. The task then sits in `cancelling` until
`stop_zombie_tasks`
reaps it, which needs `ZOMBIE_TASK_TIMEOUT` (10m) of silence and only
runs every 5 minutes.

Cancelling actually made this worse. xorm rewrites the `updated` column
on every `UPDATE`,
even when `Cols()` restricts the update to `status`, so persisting the
`cancelling` status
reset the zombie clock. Pressing cancel pushed the cleanup a full
`ZOMBIE_TASK_TIMEOUT`
into the future instead of bringing it forward.

## Change

`StopTask` now skips the `cancelling` handshake when the task has had no
state report from
its runner for longer than `TaskReportTimeout` (1 minute) and cancels it
directly. This
joins the two existing fallbacks — runner deleted, and runner without
cancelling support —
so every cancel path (web UI, API, concurrency, rerun) is covered.

Runners report the state of a running task every few seconds, so a
minute of silence means
the runner is gone. The value is a constant rather than a setting
because it only decides
whether the runner is still reachable, not whether a task should be
killed —
`ZOMBIE_TASK_TIMEOUT` still owns that.

### Tradeoff

If a runner is alive but has been silent for over a minute and is
cancelled in that window,
it skips the graceful post-step cleanup added in #37275. No work is
lost: the runner still
learns the outcome on its next report, because `UpdateTask` returns the
task status as the
result and the runner stops there. That is the behaviour that existed
before #37275.

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-07-26 14:38:54 +00:00
9972bee41f fix(issues): fix label bulk-load key and reduce log noise in LoadLabel (#38632) (#38643)
Backport #38632 by @eliroca

CommentList.loadLabels keyed the result map by label.ID but looked up by
comment.ID, so every label event fell back to individual DB queries.

This caused log spam for any comment where the label was deleted, since
ToTimelineComment calls LoadLabel unconditionally for all comment types
including those with LabelID=0.

Fix the map key, skip the query when LabelID=0, demote the now rarely
triggered orphaned-label log to Debug, and fix a typo ("Commit" ->
"Comment") in that message.

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Elisei Roca <eroca@suse.de>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-26 13:20:17 +00:00
375e6ea038 fix(actions): improve runner list status sorting, labels and task job links (#38586) (#38633)
Backport #38586 by @bircni

Several small fixes to the Actions runner management UI.

### Runner task list links to the job, not the workflow run
Relabeled the first column from "Run" to "Job"; it now shows the job ID
and links to the specific job (`/actions/runs/{runID}/jobs/{jobID}`).
Renamed locale key `task_list.run` to `task_list.job`.

### Missing "Disabled" translation
The runner list rendered a grey label via `actions.runners.disabled`,
but that key
did not exist in `locale_en-US.json`, so the raw key string leaked into
the UI.
Replaced `"actions.runners.disabled"` with `"disabled"`.

### Status column sorting ignored active vs idle
Sorting by status ordered purely on `last_online`, but the displayed
status is
computed from both `last_online` (offline) and `last_active` (idle vs
active).
As a result idle runners were interleaved with active ones. Sorting now
ranks by
the computed status (active → idle → offline). Disabled runners sink to
the bottom
of their status group (`is_disabled` as a secondary key), with
`last_online`/`id`
as stable tiebreakers so pagination stays deterministic.

### Status label colors
Active and idle both rendered green. Idle is now yellow, active green,
and
offline/unknown grey; the separate grey "Disabled" badge is unchanged.
This keeps
connectivity visible even for disabled runners (e.g. a disabled runner
still shows
whether it is idle or offline).

<img width="715" height="406" alt="image"
src="https://github.com/user-attachments/assets/9ef06aa8-a870-4de5-9d94-603a58186908"
/>

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
2026-07-25 22:02:40 +00:00
31c435454b fix(actions): correctness and hardening fixes (#38518) (#38631)
Backport #38518 by @bircni

Various fixes to actions

1. **Cap total jobs per run in reusable-workflow expansion** — only
nesting depth was capped, so fan-out + nested reusable workflows could
explode job-row inserts and exhaust the DB from a single push. Now
enforces `MaxJobNumPerRun` in the insert path.
2. **Reject rerun-failed when a run has no failed jobs** — an empty job
list meant "re-run everything", so `rerun-failed` on a green run re-ran
all jobs. Now errors (web + API).
3. **Don't adopt external commit statuses into the legacy hash** — the
pre-#35699 Context-only hash matched API-posted statuses too, collapsing
two same-named workflows into one check. Now limited to Actions-user
rows.
4. **Don't cut post-cancel cleanup short in `StopEndlessTasks`** — the
sweep force-stopped just-cancelled jobs mid-cleanup. Now targets
`StatusRunning` only; stalled cancels stay covered by `StopZombieTasks`.
5. **Avoid redundant run reload in `GenerateGiteaContext`** — resolving
`github.triggering_actor` reloaded the run already passed in. Now loads
only the trigger user via new `ActionRunAttempt.LoadTriggerUser`.

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-25 19:49:33 +02:00
a54324e2b7 fix(repo): prevent double-write redirect collisions on dependency errors, fix ui (#38627) (#38628)
Backport #38627

Co-authored-by: Sudhanshu Singh <sudhanshuwriterblc@gmail.com>
2026-07-25 08:48:56 -07:00
d141dc729c fix: delete repo-scoped rows of seven more tables when deleting a repository (#38534) (#38618)
Backport #38534 by @luuuc

Fixes #38494

`DeleteRepositoryDirectly` left rows behind in seven registered tables
carrying a repo-scoped key: `action_variable`, `action_run_attempt`,
`action_tasks_version`, `renamed_branch`, `commit_status_summary`,
`commit_status_index` and `repo_transfer`. Repository IDs are `pk
autoincr` and never reissued, so the orphaned rows were unreachable, but
they accumulated forever (unbounded table growth, referential
inconsistency; not a security issue, see the issue discussion).

This adds all seven to the `deleteBeans` cascade, each placed next to
its sibling bean
(`CommitStatus`/`CommitStatusIndex`/`CommitStatusSummary`,
`Branch`/`RenamedBranch`, `Secret`/`ActionVariable`,
`ActionRun`/`ActionRunAttempt`). `repo_transfer` goes through the same
cascade rather than `DeleteRepositoryTransfer` so teardown stays one
mechanism; the existing reaper remains for the transfer flows.

The test inserts one row per previously-orphaned table (the fixture
files ask test cases to prepare their own data), deletes the repository,
and asserts each table is purged. Without the fix it fails on all seven
tables.

As discussed in the issue, this is the first of the two suggested steps;
the reflection-driven enforcement sweep over registered repo-keyed
models (with an explicit exemption list, e.g. head-repo PRs) can follow
in a separate PR.

PS: this was found while benchmarking
[Sense](https://github.com/luuuc/sense) against the gitea codebase (the
cross-reference method is described in the issue).

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Signed-off-by: Luc <luuuc@users.noreply.github.com>
Co-authored-by: Luc <luuuc@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-24 21:25:31 +02:00
27fed5a83a fix(webhook): remove slack channel name check (#38608) (#38612)
Backport #38608 by @chlee1001

Users should know what they are doing, don't check everything,
especially for that we don't understand.

Fixes #23840

Co-authored-by: Chaehyeon Lee <chlee1001@naver.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-24 08:09:05 +00:00
6cdd02bdad fix: download dropdown menu clipped on the branches page (#38604) (#38609)
Backport #38604

Fixes #38603

Co-authored-by: Richard Mahn <richmahn@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-24 12:47:37 +08:00
cc4ee6387b fix(oauth2): enforce mandatory 2FA policy on OAuth2 authorize/grant endpoints (#38591) (#38606)
Backport #38591

Co-authored-by: Mitrahsoft <bala.c@mitrahsoft.in>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-23 20:13:13 -07:00
4780cffe08 fix(project): prevent database mutations on invalid MoveIssues payload (#38600) (#38602)
Backport #38600

Signed-off-by: Sudhanshu Singh <sudhanshuwriterblc@gmail.com>
Co-authored-by: Shudhanshu Singh <sudhanshuwriterblc@gmail.com>
2026-07-23 16:14:57 +00:00
483fb19b37 fix(actions): make SingleWorkflow.Marshal round-trip multi-line run blocks (stop silent job stranding) (#38520) (#38599)
Backport #38520 by @darklight147

## Problem
Jobs that call a reusable workflow (`uses:`) whose steps contain a `run:
|` block that **starts with blank lines** never start — the child jobs
stay `Blocked` forever, the run never finishes, and nothing is shown to
the user (the error is only logged at DEBUG). The reusable is valid YAML
and worked on 1.26.

## Root cause
`jobparser` serializes each expanded job via `SingleWorkflow.SetJob()`,
which uses `yaml.NewEncoder(...).SetIndent(2)`, but
`SingleWorkflow.Marshal()` used `yaml.Marshal`, whose default
indentation is **4**. Re-emitting a multi-line literal block scalar at a
different indentation makes the encoder write a wrong explicit
indentation indicator (`run: |4`) whose declared indent doesn't match
the actual content indent. The stored `workflow_payload` is then
unparseable:

```
run: |4


                while ...
```

`jobparser.Parse` / `model.ReadWorkflow` (both go.yaml.in/yaml/v4)
reject it: `did not find expected key`. This surfaces in
`services/actions/job_emitter.go` `resolve()` →
`updateConcurrencyEvaluationForJobWithNeeds` → `ParseJob`, where the
error is swallowed at `log.Debug` and the job is left `Blocked`.

Encoding at indent 4 triggers the bad indicator; indent 2 does not —
matching the value already used by `SetJob`.

## Fix
Encode `SingleWorkflow.Marshal()` with `SetIndent(2)` so both encoders
agree and the serialized single workflow round-trips. Adds a regression
test (`Parse → Marshal → Parse` on a `run:` block with leading blank
lines) that fails before the change with `did not find expected key`.

## Notes
- This is the correctness fix. Related: #37116 added
`ValidateWorkflowContent` to *report* such content for top-level
workflows, but the reusable-expansion/concurrency-eval path is not
covered and strands silently — this fix removes the failure mode
entirely.
- Consider a follow-up to elevate the swallowed error in
`job_emitter.resolve()` from `log.Debug` to a user-visible job failure
(there is an existing `// TODO`).

Signed-off-by: quasimodo <mohamed.belkamel@intelcia.com>
Co-authored-by: Mohamed Belkamel <39389636+darklight147@users.noreply.github.com>
Co-authored-by: quasimodo <mohamed.belkamel@intelcia.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
2026-07-23 14:23:32 +00:00
560535a97f fix(api): align Swagger schemas for UserSettings and TopicListResponse (#38590) (#38592)
Backport #38590

Signed-off-by: Sudhanshu Singh <sudhanshuwriterblc@gmail.com>
Co-authored-by: Shudhanshu Singh <sudhanshuwriterblc@gmail.com>
2026-07-23 07:27:47 +00:00
62c61aa8ce fix(file-tree): handle submodule links and missing view container (#38033) (#38589)
Backport #38033

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: yszl666 <941131649@qq.com>
Co-authored-by: dzf <douzf@sparkspacetech.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-23 10:17:34 +08:00
337fa5a950 fix(actions): fail unexpandable reusable workflow callers and decouple the job emitter's cross-run processing (#38565) (#38587)
Backport #38565 by @Zettat123

## Changes

### 1. Handle reusable workflow expansion failures

If a reusable workflow caller job is invalid (e.g. uses a workflow with
syntax error), the job emitter should mark it as failed instead of
retrying.

Related:
https://github.com/go-gitea/gitea/pull/38518#discussion_r3608882095

### 2. No longer process concurrent run inline

**Before**: If a run(R1)'s status change unlocks another blocked run(R2)
via concurrency group, the job emitter will process R2 in R1's
transaction.

**Current**: No longer process R2 inline and emit the ID of R2 to let
another pass process it.

Co-authored-by: Zettat123 <zettat123@gmail.com>
2026-07-22 23:35:28 +02:00
ad84c14d53 fix: keep serving valid ACME cert when renewal fails at startup (#38554) (#38583)
Backport #38554 by @Otto-Deviant1904

Fix #38519

When `ENABLE_ACME` renew fails during startup (e.g. CA unreachable),
`ManageSync` currently aborts even if a still-valid certificate is on
disk, so HTTPS never comes up.

If `CacheManagedCertificate` finds a non-expired cert, log the manage
error, continue with that cert, and kick `ManageAsync` for background
retries. First-time install / expired-or-missing cert still fails
closed.

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Harsh Satyajit Thakur <f20240223@goa.bits-pilani.ac.in>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-22 19:59:56 +00:00
65ea4079ce fix: branch protection user list (#38570) (#38584)
Backport #38570 by @wxiaoguang

fix #38569, also fix the UI

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-22 21:36:12 +02:00
1cf54fed70 fix(pulls): respect diff.orderFile in diff file tree (#38566) (#38578)
Backport #38566 by @eliroca

Co-authored-by: Elisei Roca <eroca@suse.de>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-22 18:14:16 +00:00
1ae686696e fix(issue): make issue action (issue list batch operation) elements have correct attributes (#38575) (#38580)
Backport #38575 by @SudhanshuMatrix

The "Clear projects" action in the issue list batch operations doesn't
work. When users select multiple issues and choose to clear their
project assignments, the operation fails because:

1. The frontend sends a project ID of `0` to represent "no project"
2. The backend passes this invalid ID directly to
`IssueAssignOrRemoveProject` without filtering
3. The backend tries to look up a project with ID `0`, which doesn't
exist, resulting in a `project 0 not found` error
4. Selected issues remain assigned to their projects instead of being
removed

Fixes #38571 

## Root Cause

The issue is a regression from the multi-project feature (#36784). The
frontend was using `data-element-id="0"` to represent "clear" actions,
but the backend doesn't filter out this invalid ID before validation.

## Solution

### Template Changes (`templates/repo/issue/filter_actions.tmpl`)
- Changed `data-element-id="0"` to `data-element-id=""` for the "Clear
projects" action (line 78)
- Changed `data-element-id="0"` to `data-element-id=""` for the "Clear
milestone" action (line 47)
- Removed the duplicate "no select" assignee option that was using
`data-element-id="0"` (lines 116-118)

### Frontend Logic Changes (`web_src/js/features/repo-issue-list.ts`)
- Made `elementId` a `const` instead of `let` (line 60) to prevent
mutations
- Removed the workaround code that was trying to handle
`data-element-id="0"` for assignees (lines 65-69)
- Updated comment from "for toggle" to "for label toggle" for clarity
(line 71)

Signed-off-by: Sudhanshu Singh <sudhanshuwriterblc@gmail.com>
Co-authored-by: Shudhanshu Singh <sudhanshuwriterblc@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-22 17:33:43 +00:00
e2f0358368 enhance: improve diff contrast in light and dark themes (#37477) (#38574)
Backport #37477 by @cyphercodes

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

Adjust the diff stat counter and syntax colors in both light and dark
themes to github-like colors that meet a >= 5:1 contrast floor (>= 7:1
for syntax names on diff rows), and make the counters semibold.

Signed-off-by: cyphercodes <cyphercodes@users.noreply.github.com>
Co-authored-by: Rayan Salhab <r.salhab@aiyexpertsolutions.com>
Co-authored-by: cyphercodes <cyphercodes@users.noreply.github.com>
Co-authored-by: Hermes Agent (GPT-5.5) <hermes-agent@nousresearch.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Hermes Agent <hermes@noreply.local>
Co-authored-by: silverwind <me@silverwind.io>
2026-07-22 12:07:42 +00:00
d88bbfd0db fix(actions): support matrix when evaluating workflow if expression (#38474) (#38557)
Backport #38474 by @Zettat123

Partially fixes #38466

Added `matrix` to the evaluator for `if` expressions

Signed-off-by: Zettat123 <zettat123@gmail.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-07-21 13:28:58 +00:00
5e494f9cad fix(actions): align status icon span for Safari rendering (#38558) (#38562)
Backport #38558

Fixes #38553

Co-authored-by: okxint <130782884+okxint@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-21 18:43:11 +08:00
wxiaoguangandGitHub 9731ad7c3c fix: revert git clone http redirection forbidden (#38530) (#38545)
backport #38530
2026-07-20 17:21:17 +02:00
GiteabotandGitHub 148d528814 fix: clean up orphaned user-keyed tables in deleteUser (#38511) (#38514) 2026-07-20 08:41:59 +00:00
1af5277aba fix(actions): coerce workflow_dispatch boolean inputs to native types (#38472) (#38521)
Backport #38472 by @bircni

A `workflow_dispatch` job that has `needs:` **and** an `if:` comparing a
boolean
input against a boolean literal never runs — it stays `Blocked` forever
even
though its needs succeed. Minimal repro:

```yaml
on:
  workflow_dispatch:
    inputs:
      deploy:
        type: boolean
        default: true
jobs:
  build:
    runs-on: ubuntu-latest
    steps: [{ run: echo build }]
  deploy:
    needs: build
    if: ${{ inputs.deploy == true }}   # never true on Gitea
    runs-on: ubuntu-latest
    steps: [{ run: echo deploy }]
```

On GitHub this runs; on Gitea `deploy` is stuck. Jobs **without**
`needs` are
unaffected, which makes it look like a matrix/`needs` bug — it isn't.

## Root cause

`workflow_dispatch` stores boolean inputs as the strings
`"true"`/`"false"`
(`ctx.FormBool` → `strconv.FormatBool` in the web path, plain strings in
the API
path). Since #37478 (shipped in **v1.27.0**), `evaluateJobIf` runs
**server-side**
as part of the job-emitter resolver passes. For a `needs`-gated job the
server
therefore evaluates `inputs.deploy == true` with `inputs.deploy` being
the string
`"true"`; comparing a string to a boolean coerces to `NaN == 1` →
`false`, so the
job is never dispatched.

Jobs without `needs` skip this server-side gate and are evaluated by the
runner,
which coerces the string to a real bool — that's why they keep working,
and why
the same input yields opposite results in the two paths.

## Fix

Normalize `type: boolean` dispatch inputs to native JSON booleans in the
shared
`DispatchActionWorkflow` path, so it covers both the web and API entry
points in
one place. The coerced value flows into both the run's `EventPayload`
(read back
by the server-side `if` evaluation and by the runner) and the
creation-time
parse, so all consumers agree.

This matches GitHub, whose `inputs` context "preserves Boolean values as
Booleans
instead of converting them to strings", and mirrors the native JSON
types Gitea
already sends for `workflow_call`. Only booleans are coerced; other
input types
are left untouched, and a value that is already a bool (JSON API
request) passes
through.

Note: this makes dispatch payloads carry native booleans, which the
runner
consumes correctly as of gitea/runner#1087 (it accepts a native bool and
keeps
the string fallback) — the same expectation `workflow_call` already
relies on.

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

Co-authored-by: bircni <bircni@icloud.com>
2026-07-18 23:49:43 +02:00
6d41731184 fix: make the merge box button red if some checks fail (#38508) (#38516)
Backport #38508

fix #38506

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-18 04:10:50 -07:00
Eyüp Can AkmanandGitHub cfc9f4c685 fix(pull): sign the commit when updating a branch by merge (#38441) (#38499)
Updating a branch by merge produced an unsigned commit even when merges
are configured to be signed. Update by rebase was unaffected.

`Update()` builds a fake reverse PR to switch head and base, and it has
no `Index`, so `pr.GetGitHeadRefName()` resolves `refs/pull/0/head`.
Since #36186 `SignMerge` looks that ref up in the base repository
instead of the temporary merge repo. That lookup fails. The caller
dropped the error, so `sign` stayed false.

Sync fork goes through the same fake-PR path.

Pass both sides of the merge to `SignMerge` as refs and evaluate them in
the temp repo, where `base` and `tracking` always exist.

Tests cover a signed and an unsigned update by merge.

Fixes #38066
Backport #38441
2026-07-17 11:05:05 +00:00
895d848ff4 fix: make commit message merge correctly (#38490) (#38502)
Backport #38490

fix #38487

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: bircni <bircni@icloud.com>
2026-07-17 08:09:01 +00:00
7199547218 fix(actions): explain why a blocked or waiting job has not started (#38476) (#38498)
Backport #38476 by @bircni

When an Actions job is blocked or waiting, the job view only shows the
generic **Blocked** / **Waiting** label. Users have no way to tell *why*
a job is stuck — whether it's waiting on dependencies, waiting for a
runner that doesn't exist, waiting for a runner whose labels don't
match, or simply queued behind busy runners.

## Change

The current-job detail line now surfaces the actual cause:

- **Blocked** → lists the dependency jobs (`needs`) that haven't
finished yet, e.g. *"Waiting for the following jobs to complete:
build."*
- **Waiting**, no online runner → *"No runner is online to pick up this
job."*
- **Waiting**, online runners but none match `runs-on` → *"No matching
online runner with label: X"* (reuses the existing string)
- **Waiting**, a matching runner exists but hasn't claimed the job →
*"Waiting for a matching runner to become available."*

The runner lookup reuses the same available-online-runner query the run
list already performs, and only runs while a selected job is actually
pending. Dependency resolution is scoped to the same parent job and
treats matrix expansions of a `need` as pending until all of them
complete.

Co-authored-by: bircni <bircni@icloud.com>
2026-07-16 21:45:11 +00:00
7cb4201f8e fix(actions): make cancelled() work in job if evaluation (#38495) (#38497)
Backport #38495 by @Zettat123

Fix #38485

#37478 moved `if` evaluation from runner to server. But `NewInterpeter`
always provides a nil `JobContext` for the evaluator, which makes
[`cancelled()`](https://gitea.com/gitea/runner/src/commit/ad967330a8788c9b8ab723abbc1a86d53c3bc5e6/act/exprparser/functions.go#L299)
panic on `Job.Status` because `Job` is a nil pointer.

Co-authored-by: Zettat123 <zettat123@gmail.com>
2026-07-16 20:54:26 +00:00
5f017302bf fix(actions): show retention info on hover for expired artifacts (#38477) (#38493)
Backport #38477 by @bircni

The artifact info hover tooltip (#37100) only worked for live artifacts.
Hovering an expired/deleted artifact showed nothing, and even on live
artifacts the retention date was missing on real runs.

Two root causes:

1. **Expired artifacts had no tooltip.** In the run view sidebar,
expired artifacts render in a separate branch that omitted
`data-tooltip-content` entirely, so there was nothing to hover.
2. **`ExpiresUnix` was no longer sent.** The `ActionRunAttempt` refactor
(#37119) dropped `ExpiresUnix` from `fillViewRunResponseArtifacts`, so
real runs always returned `0` and the tooltip fell back to size-only.
Only the devtest mock still populated it, which masked the regression.

Artifacts without a recorded expiry (`expiresUnix <= 0`) still degrade
gracefully to a size-only tooltip. Both states can be previewed on the
`/devtest/mock/*` actions run page.

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-16 17:01:56 +00:00
59c619660c fix(actions): group reusable-workflow matrix legs in the workflow graph (#38475) (#38492)
Backport #38475

Co-authored-by: bircni <bircni@icloud.com>
2026-07-16 15:58:06 +00:00
8599289459 fix: full file highlighting for git diff with CR char (#38484) (#38491)
Backport #38484

fix #38481

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-16 06:11:20 -07:00
da9f0a3726 fix(packages): serve noarch Alpine index for any requested architecture (#38479) (#38486)
Backport #38479 by @gaurav0107

Fixes #38456

## Problem
The Alpine package registry serves one `APKINDEX.tar.gz` per
architecture. When a repository contains only `noarch` packages (no
architecture-specific packages), only the `noarch` index is built.
Because `apk` substitutes `$ARCH` with the host architecture and
requests e.g. `x86_64/APKINDEX.tar.gz`, such a repository returned HTTP
404 and was unusable, matching the report in #38456.

## Fix
`GetRepositoryFile` now falls back to the `noarch` index when the
requested architecture has no index of its own, mirroring the fallback
already present in the sibling `DownloadPackageFile` handler. `noarch`
packages are installable on every architecture, so serving them for any
requested architecture is correct. The index-build side is unchanged;
only the serving path gains the fallback, so mixed repositories (which
already merge `noarch` into each per-architecture index) are unaffected.

## AI assistance disclosure
This change was implemented with the help of an AI coding assistant,
which gitea's CONTRIBUTING.md explicitly welcomes when disclosed. I have
reviewed the change, understand it, and can explain and defend it.

## Tests
Added a `NoArchOnly` subtest to `TestPackageAlpine` that publishes only
a `noarch` package to a fresh repository and asserts that `GET
.../x86_64/APKINDEX.tar.gz` now returns `200` (previously `404`) and
that the served index lists the noarch package. Verified locally with
`go build`/`go vet` on the changed package and a compile of the
integration test package (`go test -c`); the full integration run relies
on CI.

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Gaurav Dubey <gauravdubey0107@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-16 12:28:39 +02:00
08fd59959e fix: 500 error when updating user visibility (#38480) (#38483)
Backport #38480 by @Zettat123

## How to reproduce this bug

1. Create a user with `visibility=public`
2. Update `ALLOWED_USER_VISIBILITY_MODES` setting to `limited, private`
3. Modify any field other than "User visibility" (e.g. "Full Name")
4. UI shows 500 error

## Fix

Only update the visibility field when it actually changes.

Co-authored-by: Zettat123 <zettat123@gmail.com>
2026-07-16 11:10:54 +02:00
d60215c2a2 fix(actions): make job list item fully clickable (#38462) (#38471)
Backport #38462 by @SudhanshuMatrix

Clicking the empty space to the right of a job in the Actions sidebar
didn't switch jobs: the interactive `<a>`/`<button>` used `display:
contents` and so generated no clickable box.

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

Drop the wrapper `div` and `display: contents`, moving the `.item` and
layout styles directly onto the `<a>`/`<button>` so the whole row is
clickable. Reusable-caller `<button>` rows also get `width: 100%` and
`line-height: inherit` to match the `<a>` rows.

Co-authored-by: Shudhanshu Singh <sudhanshuwriterblc@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-07-15 17:18:05 +00:00
wxiaoguangandGitHub bf594690db fix: mail template for push event (#38467) (#38468)
Backport #38467
2026-07-15 18:57:03 +02:00
5cb7ec9304 fix: make "test push webhook" always work (#38425) (#38455)
Backport #38425

* fix #38309
* fix #26238
* fix #37886

Co-authored-by: Harsh Satyajit Thakur <f20240223@goa.bits-pilani.ac.in>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-15 05:51:44 +00:00
6e86c4cde8 fix(actions): prevent bulk actions from affecting all runners (#38453) (#38457)
Backport #38453

Co-authored-by: xkm <fzc_study@163.com>
2026-07-14 20:06:17 -07:00
c32af046a2 fix(org): align follow button and wrap description (#38448) (#38454)
Backport #38448

Co-authored-by: Shudhanshu Singh <sudhanshuwriterblc@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-07-15 10:12:39 +08:00
a98468da30 fix(actions): populate github.event for scheduled runs (#38446) (#38452)
Backport #38446 by @silverwind

Scheduled runs stored a `null` event payload, so
`github.event.repository`/`sender`/`organization` were empty in
scheduled workflows. Every other event carries them, and so does GitHub
Actions. `CreateScheduleTask` now synthesizes them.

Co-authored-by: silverwind <me@silverwind.io>
2026-07-14 19:32:08 +02:00
335 changed files with 6786 additions and 2771 deletions
+8 -10
View File
@@ -51,7 +51,7 @@ jobs:
cosign sign-blob "$f" --bundle "$f.sigstore.json" --yes
echo "$GPG_PASSPHRASE" | gpg --pinentry-mode loopback --passphrase-fd 0 --batch --yes --detach-sign -u "$GPG_FINGERPRINT" --output "$f.asc" "$f"
done
# clean branch name to get the folder name in S3
# clean branch name to get the folder name in the object storage
- name: Get cleaned branch name
id: clean_name
env:
@@ -60,18 +60,16 @@ jobs:
REF_NAME=$(echo "$REF" | sed -e 's/refs\/heads\///' -e 's/refs\/tags\///' -e 's/release\/v//')
echo "Cleaned name is ${REF_NAME}"
echo "branch=${REF_NAME}-nightly" >> "$GITHUB_OUTPUT"
- name: configure aws
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0
with:
aws-region: ${{ secrets.AWS_REGION }}
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: upload binaries to s3
- name: upload binaries to cloudflare r2
env:
AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }}
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}
CLOUDFLARE_R2_BUCKET: ${{ secrets.CLOUDFLARE_R2_BUCKET }}
BRANCH: ${{ steps.clean_name.outputs.branch }}
run: |
aws s3 sync dist/release "s3://$AWS_S3_BUCKET/gitea/$BRANCH" --no-progress
aws s3 sync dist/release "s3://$CLOUDFLARE_R2_BUCKET/gitea/$BRANCH" --endpoint-url "https://$CLOUDFLARE_R2_ACCOUNT_ID.r2.cloudflarestorage.com" --no-progress
nightly-container:
runs-on: namespace-profile-gitea-release-docker
+8 -10
View File
@@ -52,7 +52,7 @@ jobs:
cosign sign-blob "$f" --bundle "$f.sigstore.json" --yes
echo "$GPG_PASSPHRASE" | gpg --pinentry-mode loopback --passphrase-fd 0 --batch --yes --detach-sign -u "$GPG_FINGERPRINT" --output "$f.asc" "$f"
done
# clean branch name to get the folder name in S3
# clean branch name to get the folder name in the object storage
- name: Get cleaned branch name
id: clean_name
env:
@@ -61,18 +61,16 @@ jobs:
REF_NAME=$(echo "$REF" | sed -e 's/refs\/heads\///' -e 's/refs\/tags\/v//' -e 's/release\/v//')
echo "Cleaned name is ${REF_NAME}"
echo "branch=${REF_NAME}" >> "$GITHUB_OUTPUT"
- name: configure aws
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0
with:
aws-region: ${{ secrets.AWS_REGION }}
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: upload binaries to s3
- name: upload binaries to cloudflare r2
env:
AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }}
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}
CLOUDFLARE_R2_BUCKET: ${{ secrets.CLOUDFLARE_R2_BUCKET }}
BRANCH: ${{ steps.clean_name.outputs.branch }}
run: |
aws s3 sync dist/release "s3://$AWS_S3_BUCKET/gitea/$BRANCH" --no-progress
aws s3 sync dist/release "s3://$CLOUDFLARE_R2_BUCKET/gitea/$BRANCH" --endpoint-url "https://$CLOUDFLARE_R2_ACCOUNT_ID.r2.cloudflarestorage.com" --no-progress
- name: Install GH CLI
uses: dev-hanz-ops/install-gh-cli-action@af38ce09b1ec248aeb08eea2b16bbecea9e059f8 # v0.2.1
with:
+8 -10
View File
@@ -55,7 +55,7 @@ jobs:
cosign sign-blob "$f" --bundle "$f.sigstore.json" --yes
echo "$GPG_PASSPHRASE" | gpg --pinentry-mode loopback --passphrase-fd 0 --batch --yes --detach-sign -u "$GPG_FINGERPRINT" --output "$f.asc" "$f"
done
# clean branch name to get the folder name in S3
# clean branch name to get the folder name in the object storage
- name: Get cleaned branch name
id: clean_name
env:
@@ -64,18 +64,16 @@ jobs:
REF_NAME=$(echo "$REF" | sed -e 's/refs\/heads\///' -e 's/refs\/tags\/v//' -e 's/release\/v//')
echo "Cleaned name is ${REF_NAME}"
echo "branch=${REF_NAME}" >> "$GITHUB_OUTPUT"
- name: configure aws
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0
with:
aws-region: ${{ secrets.AWS_REGION }}
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: upload binaries to s3
- name: upload binaries to cloudflare r2
env:
AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }}
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}
CLOUDFLARE_R2_BUCKET: ${{ secrets.CLOUDFLARE_R2_BUCKET }}
BRANCH: ${{ steps.clean_name.outputs.branch }}
run: |
aws s3 sync dist/release "s3://$AWS_S3_BUCKET/gitea/$BRANCH" --no-progress
aws s3 sync dist/release "s3://$CLOUDFLARE_R2_BUCKET/gitea/$BRANCH" --endpoint-url "https://$CLOUDFLARE_R2_ACCOUNT_ID.r2.cloudflarestorage.com" --no-progress
- name: Install GH CLI
uses: dev-hanz-ops/install-gh-cli-action@af38ce09b1ec248aeb08eea2b16bbecea9e059f8 # v0.2.1
with:
+96
View File
@@ -4,6 +4,102 @@ 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
* fix(oauth2): enforce mandatory 2FA policy on OAuth2 authorize/grant endpoints (#38591) (#38606)
* API
* fix(api): align Swagger schemas for UserSettings and TopicListResponse (#38590) (#38592)
* ENHANCEMENTS
* enhance: improve diff contrast in light and dark themes (#37477) (#38574)
* BUGFIXES
* fix: skip OIDC end-session after password login for OAuth2 users (#38439) (#38666)
* fix: make Actions log parser support multiple line message encoding (#38659) (#38664)
* fix(actions): use base branch ref for pull_request_target context (#38636) (#38657)
* fix(actions): skip already-approved runs in `ApproveRuns` (#38653) (#38654)
* fix: orgmode render include path (#38642) (#38645)
* fix(actions): cancel tasks immediately when the runner stopped reporting (#38616) (#38644)
* fix(issues): fix label bulk-load key and reduce log noise in LoadLabel (#38632) (#38643)
* fix(actions): improve runner list status sorting, labels and task job links (#38586) (#38633)
* fix(actions): correctness and hardening fixes (#38518) (#38631)
* fix(repo): prevent double-write redirect collisions on dependency errors, fix ui (#38627) (#38628)
* fix: delete repo-scoped rows of seven more tables when deleting a repository (#38534) (#38618)
* fix(webhook): remove slack channel name check (#38608) (#38612)
* fix: download dropdown menu clipped on the branches page (#38604) (#38609)
* fix(project): prevent database mutations on invalid MoveIssues payload (#38600) (#38602)
* fix(actions): make SingleWorkflow.Marshal round-trip multi-line run blocks (stop silent job stranding) (#38520) (#38599)
* fix(file-tree): handle submodule links and missing view container (#38033) (#38589)
* fix(actions): fail unexpandable reusable workflow callers and decouple the job emitter's cross-run processing (#38565) (#38587)
* fix: keep serving valid ACME cert when renewal fails at startup (#38554) (#38583)
* fix: branch protection user list (#38570) (#38584)
* fix(pulls): respect diff.orderFile in diff file tree (#38566) (#38578)
* fix(issue): make issue action (issue list batch operation) elements have correct attributes (#38575) (#38580)
* fix(actions): support `matrix` when evaluating workflow `if` expression (#38474) (#38557)
* fix(actions): align status icon span for Safari rendering (#38558) (#38562)
* fix: revert git clone http redirection forbidden (#38530) (#38545)
* fix: clean up orphaned user-keyed tables in deleteUser (#38511) (#38514)
* fix(actions): coerce workflow_dispatch boolean inputs to native types (#38472) (#38521)
* fix: make the merge box button red if some checks fail (#38508) (#38516)
* fix(pull): sign the commit when updating a branch by merge (#38441) (#38499)
* fix: make commit message merge correctly (#38490) (#38502)
* fix(actions): explain why a blocked or waiting job has not started (#38476) (#38498)
* fix(actions): make `cancelled()` work in job `if` evaluation (#38495) (#38497)
* fix(actions): show retention info on hover for expired artifacts (#38477) (#38493)
* fix(actions): group reusable-workflow matrix legs in the workflow graph (#38475) (#38492)
* fix: full file highlighting for git diff with CR char (#38484) (#38491)
* fix(packages): serve noarch Alpine index for any requested architecture (#38479) (#38486)
* fix: 500 error when updating user visibility (#38480) (#38483)
* fix(actions): make job list item fully clickable (#38462) (#38471)
* fix: mail template for push event (#38467) (#38468)
* fix: make "test push webhook" always work (#38425) (#38455)
* fix(actions): prevent bulk actions from affecting all runners (#38453) (#38457)
* fix(org): align follow button and wrap description (#38448) (#38454)
* fix(actions): populate `github.event` for scheduled runs (#38446) (#38452)
* MISC
* refactor: git patch apply (#38637) (#38638)
## [1.27.0](https://github.com/go-gitea/gitea/releases/tag/v1.27.0) - 2026-07-13
* BREAKING
-233
View File
@@ -5,60 +5,14 @@ package cmd
import (
"context"
"errors"
"fmt"
"os"
"gitea.dev/modules/log"
"gitea.dev/modules/private"
"github.com/urfave/cli/v3"
)
func defaultLoggingFlags() []cli.Flag {
return []cli.Flag{
&cli.StringFlag{
Name: "logger",
Usage: `Logger name - will default to "default"`,
},
&cli.StringFlag{
Name: "writer",
Usage: "Name of the log writer - will default to mode",
},
&cli.StringFlag{
Name: "level",
Usage: "Logging level for the new logger",
},
&cli.StringFlag{
Name: "stacktrace-level",
Aliases: []string{"L"},
Usage: "Stacktrace logging level",
},
&cli.StringFlag{
Name: "flags",
Aliases: []string{"F"},
Usage: "Flags for the logger",
},
&cli.StringFlag{
Name: "expression",
Aliases: []string{"e"},
Usage: "Matching expression for the logger",
},
&cli.StringFlag{
Name: "prefix",
Aliases: []string{"p"},
Usage: "Prefix for the logger",
},
&cli.BoolFlag{
Name: "color",
Usage: "Use color in the logs",
},
&cli.BoolFlag{
Name: "debug",
},
}
}
func newLoggingCommand() *cli.Command {
return &cli.Command{
Name: "logging",
@@ -91,92 +45,6 @@ func newLoggingCommand() *cli.Command {
},
},
Action: runReleaseReopenLogging,
}, {
Name: "remove",
Usage: "Remove a logger",
ArgsUsage: "[name] Name of logger to remove",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "debug",
}, &cli.StringFlag{
Name: "logger",
Usage: `Logger name - will default to "default"`,
},
},
Action: runRemoveLogger,
}, {
Name: "add",
Usage: "Add a logger",
Commands: []*cli.Command{
{
Name: "file",
Usage: "Add a file logger",
Flags: append(defaultLoggingFlags(), []cli.Flag{
&cli.StringFlag{
Name: "filename",
Aliases: []string{"f"},
Usage: "Filename for the logger - this must be set.",
},
&cli.BoolFlag{
Name: "rotate",
Aliases: []string{"r"},
Usage: "Rotate logs",
},
&cli.Int64Flag{
Name: "max-size",
Aliases: []string{"s"},
Usage: "Maximum size in bytes before rotation",
},
&cli.BoolFlag{
Name: "daily",
Aliases: []string{"d"},
Usage: "Rotate logs daily",
},
&cli.IntFlag{
Name: "max-days",
Aliases: []string{"D"},
Usage: "Maximum number of daily logs to keep",
},
&cli.BoolFlag{
Name: "compress",
Aliases: []string{"z"},
Usage: "Compress rotated logs",
},
&cli.IntFlag{
Name: "compression-level",
Aliases: []string{"Z"},
Usage: "Compression level to use",
},
}...),
Action: runAddFileLogger,
}, {
Name: "conn",
Usage: "Add a net conn logger",
Flags: append(defaultLoggingFlags(), []cli.Flag{
&cli.BoolFlag{
Name: "reconnect-on-message",
Aliases: []string{"R"},
Usage: "Reconnect to host for every message",
},
&cli.BoolFlag{
Name: "reconnect",
Aliases: []string{"r"},
Usage: "Reconnect to host when connection is dropped",
},
&cli.StringFlag{
Name: "protocol",
Aliases: []string{"P"},
Usage: "Set protocol to use: tcp, unix, or udp (defaults to tcp)",
},
&cli.StringFlag{
Name: "address",
Aliases: []string{"a"},
Usage: "Host address and port to connect to (defaults to :7020)",
},
}...),
Action: runAddConnLogger,
},
},
}, {
Name: "log-sql",
Usage: "Set LogSQL",
@@ -195,107 +63,6 @@ func newLoggingCommand() *cli.Command {
}
}
func runRemoveLogger(ctx context.Context, c *cli.Command) error {
setup(ctx, c.Bool("debug"))
logger := c.String("logger")
if len(logger) == 0 {
logger = log.DEFAULT
}
writer := c.Args().First()
extra := private.RemoveLogger(ctx, logger, writer)
return handleCliResponseExtra(extra)
}
func runAddConnLogger(ctx context.Context, c *cli.Command) error {
setup(ctx, c.Bool("debug"))
vals := map[string]any{}
mode := "conn"
vals["net"] = "tcp"
if c.IsSet("protocol") {
switch c.String("protocol") {
case "udp":
vals["net"] = "udp"
case "unix":
vals["net"] = "unix"
}
}
if c.IsSet("address") {
vals["address"] = c.String("address")
} else {
vals["address"] = ":7020"
}
if c.IsSet("reconnect") {
vals["reconnect"] = c.Bool("reconnect")
}
if c.IsSet("reconnect-on-message") {
vals["reconnectOnMsg"] = c.Bool("reconnect-on-message")
}
return commonAddLogger(ctx, c, mode, vals)
}
func runAddFileLogger(ctx context.Context, c *cli.Command) error {
setup(ctx, c.Bool("debug"))
vals := map[string]any{}
mode := "file"
if c.IsSet("filename") {
vals["filename"] = c.String("filename")
} else {
return errors.New("filename must be set when creating a file logger")
}
if c.IsSet("rotate") {
vals["rotate"] = c.Bool("rotate")
}
if c.IsSet("max-size") {
vals["maxsize"] = c.Int64("max-size")
}
if c.IsSet("daily") {
vals["daily"] = c.Bool("daily")
}
if c.IsSet("max-days") {
vals["maxdays"] = c.Int("max-days")
}
if c.IsSet("compress") {
vals["compress"] = c.Bool("compress")
}
if c.IsSet("compression-level") {
vals["compressionLevel"] = c.Int("compression-level")
}
return commonAddLogger(ctx, c, mode, vals)
}
func commonAddLogger(ctx context.Context, c *cli.Command, mode string, vals map[string]any) error {
if len(c.String("level")) > 0 {
vals["level"] = log.LevelFromString(c.String("level")).String()
}
if len(c.String("stacktrace-level")) > 0 {
vals["stacktraceLevel"] = log.LevelFromString(c.String("stacktrace-level")).String()
}
if len(c.String("expression")) > 0 {
vals["expression"] = c.String("expression")
}
if len(c.String("prefix")) > 0 {
vals["prefix"] = c.String("prefix")
}
if len(c.String("flags")) > 0 {
vals["flags"] = log.FlagsFromString(c.String("flags"))
}
if c.IsSet("color") {
vals["colorize"] = c.Bool("color")
}
logger := log.DEFAULT
if c.IsSet("logger") {
logger = c.String("logger")
}
writer := mode
if c.IsSet("writer") {
writer = c.String("writer")
}
extra := private.AddLogger(ctx, logger, writer, mode, vals)
return handleCliResponseExtra(extra)
}
func runPauseLogging(ctx context.Context, c *cli.Command) error {
setup(ctx, c.Bool("debug"))
userMsg := private.PauseLogging(ctx)
+15 -3
View File
@@ -6,6 +6,7 @@ package cmd
import (
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"net"
"net/http"
@@ -93,10 +94,21 @@ func runACME(listenAddr string, m http.Handler) error {
myACME := certmagic.NewACMEIssuer(magic, certmagic.DefaultACME)
magic.Issuers = []certmagic.Issuer{myACME}
// this obtains certificates or renews them if necessary
err := magic.ManageSync(graceful.GetManager().HammerContext(), []string{setting.Domain})
// Obtain certificates or renew them if necessary. ManageSync fails closed on
// renewal errors even when a still-valid certificate is already on disk, which
// takes HTTPS down on restart (https://github.com/go-gitea/gitea/issues/38519).
// Prefer keeping the existing cert and retrying renewals asynchronously.
ctx := graceful.GetManager().ShutdownContext()
err := magic.ManageSync(ctx, []string{setting.Domain})
if err != nil {
return err
cert, cacheErr := magic.CacheManagedCertificate(ctx, setting.Domain)
if cacheErr != nil || cert.Expired() {
return errors.Join(err, cacheErr)
}
log.Error("ACME certificate manage failed; continuing with existing certificate: %v", err)
if err := magic.ManageAsync(ctx, []string{setting.Domain}); err != nil {
log.Error("Failed to start async ACME management: %v", err)
}
}
tlsConfig := magic.TLSConfig()
+2 -2
View File
@@ -606,7 +606,8 @@ ENABLED = true
;; * https://github.com/hickford/git-credential-oauth
;; * https://github.com/git-ecosystem/git-credential-manager
;; * https://gitea.com/gitea/tea
;DEFAULT_APPLICATIONS = git-credential-oauth, git-credential-manager, tea
;; * Gitea App (the official Gitea mobile app)
;DEFAULT_APPLICATIONS = git-credential-oauth, git-credential-manager, tea, gitea-app
;;
;; By default, OAuth2 applications can only use "http" and "https" as their redirect URI schemes.
;; If you need to use other schemes (e.g. for desktop applications), you can specify them here as a comma-separated list.
@@ -1122,7 +1123,6 @@ LEVEL = Info
;ALLOW_FORK_WITHOUT_MAXIMUM_LIMIT = true
;; Allow to fork repositories into the same owner (user or organization)
;; This feature is experimental, not fully tested, and may be changed in the future
;ALLOW_FORK_INTO_SAME_OWNER = false
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+1 -1
View File
@@ -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
+25 -4
View File
@@ -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
+27
View File
@@ -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)
}
+60 -7
View File
@@ -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"
@@ -62,14 +63,16 @@ func (attempt *ActionRunAttempt) LoadAttributes(ctx context.Context) (err error)
attempt.Run = run
}
if attempt.TriggerUser == nil {
attempt.TriggerUserID, attempt.TriggerUser, err = user_model.GetPossibleUserByID(ctx, attempt.TriggerUserID)
if err != nil {
return err
}
}
return attempt.LoadTriggerUser(ctx)
}
return nil
// LoadTriggerUser loads the attempt's trigger user if not already loaded.
func (attempt *ActionRunAttempt) LoadTriggerUser(ctx context.Context) (err error) {
if attempt.TriggerUser != nil {
return nil
}
attempt.TriggerUserID, attempt.TriggerUser, err = user_model.GetPossibleUserByID(ctx, attempt.TriggerUserID)
return err
}
func GetRunAttemptByRepoAndID(ctx context.Context, repoID, attemptID int64) (*ActionRunAttempt, error) {
@@ -94,6 +97,56 @@ func GetRunAttemptByRunIDAndAttemptNum(ctx context.Context, runID, attemptNum in
return &attempt, nil
}
// GetArtifactAttemptIDs returns the IDs of the attempts whose artifacts the job may read, newest first,
// always including the job's own attempt.
// An attempt that re-ran only some of the run's jobs keeps the artifacts of the attempt it re-ran from,
// because the jobs it passed through never upload them again; a rerun of the whole run starts over.
func GetArtifactAttemptIDs(ctx context.Context, job *ActionRunJob) ([]int64, error) {
if job.Attempt <= 1 || job.RunAttemptID == 0 {
return []int64{job.RunAttemptID}, nil
}
attempts, err := ListRunAttemptsByRunID(ctx, job.RunID)
if err != nil {
return nil, err
}
// a newer attempt is never readable, and attempt 1 has nothing older to continue into
candidateIDs := container.FilterSlice(attempts, func(a *ActionRunAttempt) (int64, bool) {
return a.ID, a.Attempt > 1 && a.Attempt <= job.Attempt
})
passThroughAttemptIDs, err := findPassThroughAttemptIDs(ctx, candidateIDs)
if err != nil {
return nil, err
}
ids := make([]int64, 0, len(attempts))
for _, attempt := range attempts {
if attempt.Attempt > job.Attempt {
continue
}
ids = append(ids, attempt.ID)
if !slices.Contains(passThroughAttemptIDs, attempt.ID) {
// stops at the first attempt that passed no job through
break
}
}
return ids, nil
}
// findPassThroughAttemptIDs narrows the given attempts to those that were a rerun of selected jobs:
// only such a rerun clones jobs carrying a source task.
// TODO: best-effort. Needs a better way to distinguish between "partial re-run" and "full re-run".
func findPassThroughAttemptIDs(ctx context.Context, attemptIDs []int64) ([]int64, error) {
passThroughAttemptIDs := make([]int64, 0, len(attemptIDs))
return passThroughAttemptIDs, db.GetEngine(ctx).
Table("action_run_job").
Cols("run_attempt_id").
In("run_attempt_id", attemptIDs).
Where("source_task_id <> 0").
Distinct("run_attempt_id").
Find(&passThroughAttemptIDs)
}
// FindConcurrentRunAttempts returns attempts in the given concurrency group and status set.
// Results are unordered; callers must not depend on any particular row order.
func FindConcurrentRunAttempts(ctx context.Context, repoID int64, concurrencyGroup string, statuses []Status) ([]*ActionRunAttempt, error) {
+83 -47
View File
@@ -333,6 +333,14 @@ func GetDirectChildJobsByParent(ctx context.Context, parentJob *ActionRunJob) (A
return jobs, nil
}
// DeleteDirectChildJobsByParent deletes the direct child jobs of a parent job.
func DeleteDirectChildJobsByParent(ctx context.Context, parentJob *ActionRunJob) error {
_, err := db.GetEngine(ctx).
Where("run_id=? AND parent_job_id=?", parentJob.RunID, parentJob.ID).
Delete(new(ActionRunJob))
return err
}
// CollectAllDescendantJobs returns every job in `allJobs` that lives under parent's subtree (recursively), excluding `parent` itself
func CollectAllDescendantJobs(parent *ActionRunJob, allJobs []*ActionRunJob) []*ActionRunJob {
parents := map[int64]bool{parent.ID: true}
@@ -440,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.
//
@@ -652,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))
@@ -676,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() {
+9
View File
@@ -159,3 +159,12 @@ func (opts FindRunJobOptions) ToOrders() string {
}
var _ db.FindOptionsOrder = FindRunJobOptions{}
// CountRunJobsByRunAndAttemptID counts the jobs belonging to the given run attempt.
// It is used to enforce MaxJobNumPerRun when reusable-workflow expansion inserts new jobs.
func CountRunJobsByRunAndAttemptID(ctx context.Context, runID, runAttemptID int64) (int64, error) {
return db.Count[ActionRunJob](ctx, FindRunJobOptions{
RunID: runID,
RunAttemptID: optional.Some(runAttemptID),
})
}
+89
View File
@@ -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)
})
}
}
+21 -5
View File
@@ -270,15 +270,31 @@ func (opts FindRunnerOptions) ToConds() builder.Cond {
return cond
}
// runnerStatusOrderExpr builds an ORDER BY fragment that ranks runners by their
// computed status (see ActionRunner.Status): active (0), idle (1), offline (2).
// The thresholds are evaluated against the current time, mirroring ToConds, so
// sorting by status groups active and idle runners instead of interleaving them
// by raw last_online.
func runnerStatusOrderExpr() string {
now := time.Now()
offlineThreshold := now.Add(-RunnerOfflineTime).Unix()
idleThreshold := now.Add(-RunnerIdleTime).Unix()
return fmt.Sprintf("CASE WHEN last_online <= %d THEN 2 WHEN last_active <= %d THEN 1 ELSE 0 END", offlineThreshold, idleThreshold)
}
func (opts FindRunnerOptions) ToOrders() string {
// A unique tiebreaker (id) is appended so that runners sharing the same
// last_online or name keep a deterministic order across paginated queries,
// otherwise the same runner may appear on more than one page.
// status, last_online or name keep a deterministic order across paginated
// queries, otherwise the same runner may appear on more than one page.
statusRank := runnerStatusOrderExpr()
switch opts.Sort {
case "online":
return "last_online DESC, id ASC"
// Rank by computed status first so idle runners are not interleaved with
// active ones; disabled runners sink to the bottom of their status group
// (is_disabled ASC), then last_online breaks ties within a group.
return statusRank + " ASC, is_disabled ASC, last_online DESC, id ASC"
case "offline":
return "last_online ASC, id ASC"
return statusRank + " DESC, is_disabled ASC, last_online ASC, id ASC"
case "alphabetically":
return "name ASC, id ASC"
case "reversealphabetically":
@@ -288,7 +304,7 @@ func (opts FindRunnerOptions) ToOrders() string {
case "oldest":
return "id ASC"
}
return "last_online DESC, id ASC"
return statusRank + " ASC, is_disabled ASC, last_online DESC, id ASC"
}
// GetRunnerByUUID returns a runner via uuid
-66
View File
@@ -4,16 +4,12 @@
package actions
import (
"fmt"
"testing"
"time"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
"gitea.dev/modules/timeutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestShouldPersistLastOnline(t *testing.T) {
@@ -85,65 +81,3 @@ func TestShouldPersistLastActive(t *testing.T) {
})
}
}
func TestFindRunnerOptions_ToOrders_StableTiebreaker(t *testing.T) {
// Sorts on a non-unique column must end with the unique id tiebreaker so
// pagination is deterministic; without it, runners sharing the same
// last_online or name can appear on more than one page. Sorts already on
// the unique id need no tiebreaker.
expected := map[string]string{
"": "last_online DESC, id ASC",
"online": "last_online DESC, id ASC",
"offline": "last_online ASC, id ASC",
"alphabetically": "name ASC, id ASC",
"reversealphabetically": "name DESC, id ASC",
"newest": "id DESC",
"oldest": "id ASC",
}
for sort, want := range expected {
assert.Equal(t, want, FindRunnerOptions{Sort: sort}.ToOrders(), "sort %q", sort)
}
}
func TestFindRunners_PaginationNoDuplicates(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
// Create several runners that all share the same last_online value so the
// primary sort key (last_online) is tied for all of them.
const ownerID = 1000
const count = 6
for i := range count {
runner := &ActionRunner{
Name: "paginated-runner",
UUID: fmt.Sprintf("PAGINATE-TEST-0000-0000-00000000000%d", i),
TokenHash: fmt.Sprintf("paginate-test-token-hash-%d", i),
OwnerID: ownerID,
RepoID: 0,
LastOnline: 42,
}
require.NoError(t, db.Insert(ctx, runner))
}
// Page through the runners and ensure every id is returned exactly once.
seen := make(map[int64]int)
const pageSize = 2
for page := 1; ; page++ {
runners, err := db.Find[ActionRunner](ctx, FindRunnerOptions{
ListOptions: db.ListOptions{Page: page, PageSize: pageSize},
OwnerID: ownerID,
})
require.NoError(t, err)
if len(runners) == 0 {
break
}
for _, r := range runners {
seen[r.ID]++
}
}
assert.Len(t, seen, count, "each runner should be returned exactly once across all pages")
for id, n := range seen {
assert.Equal(t, 1, n, "runner %d appeared on %d pages", id, n)
}
}
+26 -5
View File
@@ -60,6 +60,12 @@ type ActionTask struct {
Updated timeutil.TimeStamp `xorm:"updated index"`
}
// taskReportTimeout is how long a task may go without contact from its runner before the
// runner is assumed gone. Runners report state and stream logs every few seconds, both of
// which refresh ActionTask.Updated. Shorter than setting.Actions.ZombieTaskTimeout because
// it only decides whether the runner is reachable, not whether the task should be killed.
const taskReportTimeout = time.Minute
var successfulTokenTaskCache *lru.Cache[string, any]
func init() {
@@ -85,11 +91,15 @@ func (task *ActionTask) IsStopped() bool {
return task.Stopped > 0
}
func (task *ActionTask) GetRunLink() string {
if task.Job == nil || task.Job.Run == nil {
func (task *ActionTask) GetRunJobLink() string {
// Run.Repo can be nil when the repository was deleted while task/run rows remain
// (TaskList.LoadAttributes copies job.Repo into run.Repo, leaving it nil on a miss).
// Run.Link() already returns "" in that case, so guard here to avoid emitting a
// broken relative "/jobs/N" link from the Sprintf below.
if task.Job == nil || task.Job.Run == nil || task.Job.Run.Repo == nil {
return ""
}
return task.Job.Run.Link()
return fmt.Sprintf("%s/jobs/%d", task.Job.Run.Link(), task.Job.ID)
}
func (task *ActionTask) GetCommitLink() string {
@@ -468,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 {
@@ -533,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
}
@@ -563,6 +577,10 @@ func StopTask(ctx context.Context, taskID int64, status Status) error {
status = StatusCancelled
} else if !runner.HasCancellingSupport {
status = StatusCancelled
} else if task.Updated.AddDuration(taskReportTimeout) < now {
// A runner that stopped reporting will never acknowledge the cancellation either,
// so skip the handshake instead of waiting for the zombie task cleanup.
status = StatusCancelled
}
}
@@ -577,7 +595,10 @@ func StopTask(ctx context.Context, taskID int64, status Status) error {
return err
}
return UpdateTask(ctx, task, "status")
// NoAutoTime keeps "updated" at the runner's last contact: re-cancelling an already
// cancelling task must not defer the timeout above or the zombie task cleanup.
_, err := e.ID(task.ID).Cols("status").NoAutoTime().Update(task)
return err
}
task.Status = status
+177 -171
View File
@@ -4,11 +4,15 @@
package actions
import (
"context"
"errors"
"strings"
"sync/atomic"
"testing"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/timeutil"
@@ -16,8 +20,24 @@ 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) {
repo := &repo_model.Repository{OwnerName: "org", Name: "consumer"}
run := &ActionRun{ID: 10, Repo: repo}
job := &ActionRunJob{ID: 42, Run: run}
// a task with a loaded job links to that specific job, not just the run
task := &ActionTask{Job: job}
assert.Equal(t, run.Link()+"/jobs/42", task.GetRunJobLink())
// missing job, run or repo yields an empty link instead of a broken URL
assert.Empty(t, (&ActionTask{}).GetRunJobLink())
assert.Empty(t, (&ActionTask{Job: &ActionRunJob{ID: 42}}).GetRunJobLink())
assert.Empty(t, (&ActionTask{Job: &ActionRunJob{ID: 42, Run: &ActionRun{ID: 10}}}).GetRunJobLink())
}
func TestMakeTaskStepDisplayName(t *testing.T) {
tests := []struct {
name string
@@ -83,68 +103,11 @@ func TestMakeTaskStepDisplayName(t *testing.T) {
}
func TestTaskCancellingFinalizesToCancelled(t *testing.T) {
newRunningTask := func(t *testing.T) (*ActionTask, *ActionRunJob) {
t.Helper()
run := &ActionRun{
Title: "cancelling-test-run",
RepoID: 1,
OwnerID: 2,
WorkflowID: "test.yaml",
Index: 999,
TriggerUserID: 2,
Ref: "refs/heads/master",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
Status: StatusRunning,
Started: timeutil.TimeStampNow(),
}
require.NoError(t, db.Insert(t.Context(), run))
job := &ActionRunJob{
RunID: run.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
Name: "cancelling-finalization-job",
Attempt: 1,
JobID: "cancelling-finalization-job",
Status: StatusRunning,
}
require.NoError(t, db.Insert(t.Context(), job))
runner := &ActionRunner{
UUID: "runner-cancelling-supported",
Name: "runner-cancelling-supported",
HasCancellingSupport: true,
}
require.NoError(t, db.Insert(t.Context(), runner))
task := &ActionTask{
JobID: job.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(t.Context(), task))
job.TaskID = task.ID
_, err := UpdateRunJob(t.Context(), job, nil, "task_id")
require.NoError(t, err)
return task, job
}
testResult := func(t *testing.T, result runnerv1.Result) {
t.Helper()
require.NoError(t, unittest.PrepareTestDatabase())
task, job := newRunningTask(t)
task, job := newRunningTaskForCancelling(t, "cancelling-finalization-job", true)
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
taskAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
@@ -174,137 +137,92 @@ func TestTaskCancellingFinalizesToCancelled(t *testing.T) {
})
}
func TestStopTaskCancellingFallsBackForLegacyRunner(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
// TestStopTaskCancellingFallsBackToCancelled covers the cases where the cancelling handshake can
// never complete, so StopTask must cancel right away instead of waiting for the zombie task cleanup.
func TestStopTaskCancellingFallsBackToCancelled(t *testing.T) {
assertCancelled := func(t *testing.T, task *ActionTask, job *ActionRunJob) {
t.Helper()
run := &ActionRun{
Title: "cancelling-test-run",
RepoID: 1,
OwnerID: 2,
WorkflowID: "test.yaml",
Index: 999,
TriggerUserID: 2,
Ref: "refs/heads/master",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
Status: StatusRunning,
Started: timeutil.TimeStampNow(),
taskAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
assert.Equal(t, StatusCancelled, taskAfterStop.Status)
assert.NotZero(t, taskAfterStop.Stopped)
jobAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
assert.Equal(t, StatusCancelled, jobAfterStop.Status)
assert.NotZero(t, jobAfterStop.Stopped)
}
require.NoError(t, db.Insert(t.Context(), run))
job := &ActionRunJob{
RunID: run.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
Name: "legacy-cancelling-job",
Attempt: 1,
JobID: "legacy-cancelling-job",
Status: StatusRunning,
}
require.NoError(t, db.Insert(t.Context(), job))
// A runner too old to know the cancelling state can only be stopped by a final status.
t.Run("legacy runner", func(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
task, job := newRunningTaskForCancelling(t, "legacy-cancelling-job", false)
runner := &ActionRunner{
UUID: "runner-legacy-no-cancelling",
Name: "runner-legacy-no-cancelling",
HasCancellingSupport: false,
}
require.NoError(t, db.Insert(t.Context(), runner))
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
assertCancelled(t, task, job)
})
task := &ActionTask{
JobID: job.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(t.Context(), task))
// The runner is gone, e.g. an ephemeral runner was cleaned up, so nobody can acknowledge.
t.Run("missing runner", func(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
task, job := newRunningTaskForCancelling(t, "missing-runner-cancelling-job", true)
job.TaskID = task.ID
_, err := UpdateRunJob(t.Context(), job, nil, "task_id")
require.NoError(t, err)
_, err := db.DeleteByID[ActionRunner](t.Context(), task.RunnerID)
require.NoError(t, err)
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
assertCancelled(t, task, job)
})
taskAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
assert.Equal(t, StatusCancelled, taskAfterStop.Status)
assert.NotZero(t, taskAfterStop.Stopped)
// The runner went silent, e.g. it gave up while Gitea was restarting, so it never picks up the request.
t.Run("silent runner", func(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
task, job := newRunningTaskForCancelling(t, "silent-runner-cancelling-job", true)
jobAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
assert.Equal(t, StatusCancelled, jobAfterStop.Status)
assert.NotZero(t, jobAfterStop.Stopped)
// NoAutoTime because the point of the test is an "updated" older than xorm would write
task.Updated = timeutil.TimeStampNow().AddDuration(-2 * taskReportTimeout)
_, err := db.GetEngine(t.Context()).ID(task.ID).Cols("updated").NoAutoTime().Update(task)
require.NoError(t, err)
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
assertCancelled(t, task, job)
// A runner coming back still learns the outcome from the UpdateTask response,
// and its late result does not overwrite the cancellation.
late, err := UpdateTaskByState(t.Context(), task.RunnerID, &runnerv1.TaskState{
Id: task.ID,
Result: runnerv1.Result_RESULT_SUCCESS,
StoppedAt: timestamppb.Now(),
})
require.NoError(t, err)
assert.Equal(t, StatusCancelled, late.Status)
assert.Equal(t, runnerv1.Result_RESULT_CANCELLED, late.Status.AsResult())
})
}
func TestStopTaskCancellingFallsBackForMissingRunner(t *testing.T) {
// TestStopTaskCancellingKeepsReportTime makes sure persisting the cancelling status does not refresh
// "updated": re-cancelling would otherwise defer both the fallback to cancelled and the zombie task
// cleanup, no matter how long the runner has been silent.
func TestStopTaskCancellingKeepsReportTime(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
task, _ := newRunningTaskForCancelling(t, "repeated-cancelling-job", true)
run := &ActionRun{
Title: "cancelling-test-run",
RepoID: 1,
OwnerID: 2,
WorkflowID: "test.yaml",
Index: 999,
TriggerUserID: 2,
Ref: "refs/heads/master",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
Status: StatusRunning,
Started: timeutil.TimeStampNow(),
}
require.NoError(t, db.Insert(t.Context(), run))
job := &ActionRunJob{
RunID: run.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
Name: "missing-runner-cancelling-job",
Attempt: 1,
JobID: "missing-runner-cancelling-job",
Status: StatusRunning,
}
require.NoError(t, db.Insert(t.Context(), job))
runner := &ActionRunner{
UUID: "runner-cleaned-up-before-cancel",
Name: "runner-cleaned-up-before-cancel",
HasCancellingSupport: true,
}
require.NoError(t, db.Insert(t.Context(), runner))
task := &ActionTask{
JobID: job.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(t.Context(), task))
job.TaskID = task.ID
_, err := UpdateRunJob(t.Context(), job, nil, "task_id")
require.NoError(t, err)
_, err = db.DeleteByID[ActionRunner](t.Context(), runner.ID)
// NoAutoTime because the point of the test is an "updated" older than xorm would write
lastReport := timeutil.TimeStampNow().AddDuration(-taskReportTimeout / 2)
task.Updated = lastReport
_, err := db.GetEngine(t.Context()).ID(task.ID).Cols("updated").NoAutoTime().Update(task)
require.NoError(t, err)
// the runner reported recently enough, so the task waits for it to acknowledge
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
taskAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
assert.Equal(t, StatusCancelled, taskAfterStop.Status)
assert.NotZero(t, taskAfterStop.Stopped)
assert.Equal(t, StatusCancelling, taskAfterStop.Status)
assert.Equal(t, lastReport, taskAfterStop.Updated)
jobAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
assert.Equal(t, StatusCancelled, jobAfterStop.Status)
assert.NotZero(t, jobAfterStop.Stopped)
// cancelling it again does not reset the clock either
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
taskAfterSecondStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
assert.Equal(t, StatusCancelling, taskAfterSecondStop.Status)
assert.Equal(t, lastReport, taskAfterSecondStop.Updated)
}
// TestReleaseTaskForRunner verifies that releasing a freshly-claimed task returns
@@ -442,3 +360,91 @@ func TestCreateTaskForRunnerPagination(t *testing.T) {
assert.Equal(t, StatusRunning, claimed.Status)
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) {
t.Helper()
run := &ActionRun{
Title: "cancelling-test-run",
RepoID: 1,
OwnerID: 2,
WorkflowID: "test.yaml",
Index: 999,
TriggerUserID: 2,
Ref: "refs/heads/master",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
Status: StatusRunning,
Started: timeutil.TimeStampNow(),
}
require.NoError(t, db.Insert(t.Context(), run))
job := &ActionRunJob{
RunID: run.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
Name: name,
Attempt: 1,
JobID: name,
Status: StatusRunning,
}
require.NoError(t, db.Insert(t.Context(), job))
runner := &ActionRunner{
UUID: name,
Name: name,
HasCancellingSupport: hasCancellingSupport,
}
require.NoError(t, db.Insert(t.Context(), runner))
task := &ActionTask{
JobID: job.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(t.Context(), task))
job.TaskID = task.ID
_, err := UpdateRunJob(t.Context(), job, nil, "task_id")
require.NoError(t, err)
return task, job
}
+5
View File
@@ -83,6 +83,11 @@ func BuiltinApplications() map[string]*BuiltinOAuth2Application {
DisplayName: "tea",
RedirectURIs: []string{"http://127.0.0.1", "https://127.0.0.1"},
}
m["b757811a-05c8-4c76-8d74-a5ee3d2073f2"] = &BuiltinOAuth2Application{
ConfigName: "gitea-app",
DisplayName: "Gitea App",
RedirectURIs: []string{"com.gitea.app://oauth/callback"},
}
return m
}
+1 -1
View File
@@ -507,7 +507,7 @@ func updateApprovalWhitelist(ctx context.Context, repo *repo_model.Repository, c
return currentWhitelist, nil
}
prUserIDs, err := access_model.GetUserIDsWithUnitAccess(ctx, repo, perm.AccessModeRead, unit.TypePullRequests)
prUserIDs, err := access_model.GetUserIDsWithAnyUnitAccess(ctx, repo, perm.AccessModeRead, unit.TypePullRequests)
if err != nil {
return nil, err
}
+8
View File
@@ -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
+2 -3
View File
@@ -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)
+5 -2
View File
@@ -544,6 +544,9 @@ func (c *Comment) GetSanitizedContentHTML() template.HTML {
// LoadLabel if comment.Type is CommentTypeLabel, then load Label
func (c *Comment) LoadLabel(ctx context.Context) error {
if c.LabelID == 0 {
return nil
}
var label Label
has, err := db.GetEngine(ctx).ID(c.LabelID).Get(&label)
if err != nil {
@@ -551,8 +554,8 @@ func (c *Comment) LoadLabel(ctx context.Context) error {
} else if has {
c.Label = &label
} else {
// Ignore Label is deleted, but not clear this table
log.Warn("Commit %d cannot load label %d", c.ID, c.LabelID)
// label was deleted but comment rows referencing it were not cleaned up
log.Debug("Comment %d references deleted label %d", c.ID, c.LabelID)
}
return nil
+1 -1
View File
@@ -81,7 +81,7 @@ func (comments CommentList) loadLabels(ctx context.Context) error {
}
for _, comment := range comments {
comment.Label = commentLabels[comment.ID]
comment.Label = commentLabels[comment.LabelID]
}
return nil
}
+1 -1
View File
@@ -220,7 +220,7 @@ func applyRepoConditions(sess db.Session, opts *IssuesOptions) {
if opts.RepoCond == nil {
opts.RepoCond = builder.NewCond()
}
opts.RepoCond = opts.RepoCond.Or(builder.In("issue.repo_id", builder.Select("id").From("repository").Where(builder.Eq{"is_private": false})))
opts.RepoCond = opts.RepoCond.Or(builder.In("issue.repo_id", builder.Select("id").From("repository").Where(repo_model.PublicRepoUnderPublicOwnerCond())))
}
if opts.RepoCond != nil {
sess.And(opts.RepoCond)
+38
View File
@@ -5,9 +5,11 @@
package issues
import (
"cmp"
"context"
"errors"
"fmt"
"math"
"slices"
"strconv"
"strings"
@@ -191,6 +193,42 @@ func (l *Label) ExclusiveScope() string {
return l.Name[:lastIndex]
}
// CompareLabelForDisplay compares labels for displaying them in dropdowns or lists.
// Labels are grouped by their exclusive scope, and labels within the same scope
// are sorted by their exclusive order, where unordered labels (order 0) come last.
// Labels without a scope are listed first and everything else falls back to name order.
func CompareLabelForDisplay(a, b *Label) int {
scopeA, scopeB := a.ExclusiveScope(), b.ExclusiveScope()
if scopeA != scopeB {
if scopeA == "" {
return -1
}
if scopeB == "" {
return 1
}
return strings.Compare(scopeA, scopeB)
}
if scopeA != "" {
orderA, orderB := a.ExclusiveOrder, b.ExclusiveOrder
if orderA <= 0 {
orderA = math.MaxInt
}
if orderB <= 0 {
orderB = math.MaxInt
}
if orderA != orderB {
return cmp.Compare(orderA, orderB)
}
}
return strings.Compare(a.Name, b.Name)
}
// SortLabelsForDisplay sorts labels in place for displaying them in dropdowns or lists,
// grouping them by their exclusive scope and respecting the exclusive order within each scope.
func SortLabelsForDisplay(labels []*Label) {
slices.SortStableFunc(labels, CompareLabelForDisplay)
}
// NewLabel creates a new label
func NewLabel(ctx context.Context, l *Label) error {
color, err := label.NormalizeColor(l.Color)
+41
View File
@@ -54,6 +54,47 @@ func TestLabel_ExclusiveScope(t *testing.T) {
assert.Equal(t, "scope/subscope", label.ExclusiveScope())
}
func TestSortLabelsForDisplay(t *testing.T) {
labels := []*issues_model.Label{
{Name: "priority/low", Exclusive: true, ExclusiveOrder: 4},
{Name: "priority/critical", Exclusive: true, ExclusiveOrder: 1},
{Name: "priority/medium", Exclusive: true, ExclusiveOrder: 3},
{Name: "priority/high", Exclusive: true, ExclusiveOrder: 2},
{Name: "bug"},
{Name: "enhancement"},
{Name: "kind/question", Exclusive: true},
}
issues_model.SortLabelsForDisplay(labels)
names := make([]string, 0, len(labels))
for _, l := range labels {
names = append(names, l.Name)
}
assert.Equal(t, []string{
"bug",
"enhancement",
"kind/question",
"priority/critical",
"priority/high",
"priority/medium",
"priority/low",
}, names)
// labels without an exclusive order in the same scope are listed last, ordered by name
labels = []*issues_model.Label{
{Name: "scope/unordered-b", Exclusive: true},
{Name: "scope/ordered", Exclusive: true, ExclusiveOrder: 1},
{Name: "scope/unordered-a", Exclusive: true},
}
issues_model.SortLabelsForDisplay(labels)
names = names[:0]
for _, l := range labels {
names = append(names, l.Name)
}
assert.Equal(t, []string{"scope/ordered", "scope/unordered-a", "scope/unordered-b"}, names)
}
func TestNewLabels(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
labels := []*issues_model.Label{
+13 -9
View File
@@ -1065,19 +1065,23 @@ func (r *Review) GetCodeCommentsCount(ctx context.Context) int {
return int(count)
}
// HTMLURL formats a URL-string to the related review issue-comment
// HashTag returns unique hash tag for review.
func (r *Review) HashTag() string {
return fmt.Sprintf("pullrequestreview-%d", r.ID)
}
// HTMLURL formats a URL-string to the review on the pull request page
func (r *Review) HTMLURL(ctx context.Context) string {
opts := FindCommentsOptions{
Type: CommentTypeReview,
IssueID: r.IssueID,
ReviewID: r.ID,
if r.Type != ReviewTypeApprove && r.Type != ReviewTypeComment && r.Type != ReviewTypeReject {
return "" // only submitted reviews get a timeline block carrying the anchor
}
comment := new(Comment)
has, err := db.GetEngine(ctx).Where(opts.ToConds()).Get(comment)
if err != nil || !has {
if err := r.LoadIssue(ctx); err != nil {
return ""
}
return comment.HTMLURL(ctx)
if err := r.Issue.LoadRepo(ctx); err != nil {
return ""
}
return r.Issue.HTMLURL(ctx) + "#" + r.HashTag()
}
// RemapExternalUser ExternalUserRemappable interface
+10
View File
@@ -12,6 +12,7 @@ import (
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
"github.com/stretchr/testify/assert"
)
@@ -28,6 +29,15 @@ func TestGetReviewByID(t *testing.T) {
assert.True(t, issues_model.IsErrReviewNotExist(err), "IsErrReviewNotExist")
}
func TestReview_HTMLURL(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
review := unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 1})
assert.Equal(t, setting.AppURL+"user2/repo1/pulls/2#pullrequestreview-1", review.HTMLURL(t.Context()))
pendingReview := unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 4})
assert.Empty(t, pendingReview.HTMLURL(t.Context()))
}
func TestReview_LoadAttributes(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
review := unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 1})
+9 -7
View File
@@ -303,7 +303,7 @@ func (org *Organization) UnitPermission(ctx context.Context, doer *user_model.Us
}
}
if org.Visibility.IsPublic() {
if ownerVisibilitySatisfiesDoer(org.AsUser(), doer) {
return perm.AccessModeRead
}
@@ -445,8 +445,7 @@ func GetUsersWhoCanCreateOrgRepo(ctx context.Context, orgID int64) (map[int64]*u
And("team_user.org_id = ?", orgID).Find(&users)
}
// HasOrgOrUserVisible tells if the given user can see the given org or user
func HasOrgOrUserVisible(ctx context.Context, orgOrUser, user *user_model.User) bool {
func ownerVisibilitySatisfiesDoer(orgOrUser, user *user_model.User) bool {
// If user is nil, it's an anonymous user/request.
// The Ghost user is handled like an anonymous user.
if user == nil || user.IsGhost() {
@@ -461,10 +460,13 @@ func HasOrgOrUserVisible(ctx context.Context, orgOrUser, user *user_model.User)
return true
}
if (orgOrUser.Visibility == structs.VisibleTypePrivate || user.IsRestricted) && !OrgFromUser(orgOrUser).hasMemberWithUserID(ctx, user.ID) {
return false
}
return true
return orgOrUser.Visibility != structs.VisibleTypePrivate && !user.IsRestricted
}
// HasOrgOrUserVisible tells if the given user can see the given org or user
func HasOrgOrUserVisible(ctx context.Context, owner, doer *user_model.User) bool {
return ownerVisibilitySatisfiesDoer(owner, doer) ||
(doer != nil && OrgFromUser(owner).HasMemberWithUserID(ctx, doer.ID))
}
// HasOrgsVisible tells if the given user can see at least one of the orgs provided
+9
View File
@@ -10,7 +10,9 @@ import (
"gitea.dev/models/db"
"gitea.dev/models/organization"
"gitea.dev/models/perm"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
@@ -626,3 +628,10 @@ func TestCreateOrganization4(t *testing.T) {
assert.True(t, db.IsErrNameReserved(err))
unittest.CheckConsistencyFor(t, &organization.Organization{}, &organization.Team{})
}
func TestOrAnyRepoUnitPermission(t *testing.T) {
defer test.MockVariableValue(&setting.Service.RequireSignInViewStrict, true)()
org := organization.Organization{Visibility: structs.VisibleTypeLimited}
assert.Equal(t, perm.AccessModeNone, org.UnitPermission(t.Context(), nil, unit.TypeWiki))
assert.Equal(t, perm.AccessModeRead, org.UnitPermission(t.Context(), &user_model.User{}, unit.TypeWiki))
}
@@ -122,6 +122,18 @@ func TestGetActionsUserRepoPermission(t *testing.T) {
require.NoError(t, err)
assert.False(t, perm.CanRead(unit.TypeCode))
// Reusable workflows use a separate authorization path and must enforce
// the same fork-PR restriction.
run := &actions_model.ActionRun{RepoID: repo2.ID, IsForkPullRequest: true}
allowed, err := CanReadWorkflowCrossRepo(ctx, repo15, run)
require.NoError(t, err)
assert.False(t, allowed)
run.IsForkPullRequest = false
allowed, err = CanReadWorkflowCrossRepo(ctx, repo15, run)
require.NoError(t, err)
assert.True(t, allowed)
// Restore state for subsequent subtests.
task53.IsForkPullRequest = false
require.NoError(t, actions_model.UpdateTask(ctx, task53, "is_fork_pull_request"))
+5 -5
View File
@@ -599,8 +599,8 @@ func HasAnyUnitAccess(ctx context.Context, userID int64, repo *repo_model.Reposi
return perm.HasAnyUnitAccess(), nil
}
func GetUsersWithUnitAccess(ctx context.Context, repo *repo_model.Repository, mode perm_model.AccessMode, unitType unit.Type) (users []*user_model.User, err error) {
userIDs, err := GetUserIDsWithUnitAccess(ctx, repo, mode, unitType)
func GetUsersWithAnyUnitAccess(ctx context.Context, repo *repo_model.Repository, mode perm_model.AccessMode, unitType unit.Type, moreUnitTypes ...unit.Type) (users []*user_model.User, err error) {
userIDs, err := GetUserIDsWithAnyUnitAccess(ctx, repo, mode, unitType, moreUnitTypes...)
if err != nil {
return nil, err
}
@@ -613,7 +613,7 @@ func GetUsersWithUnitAccess(ctx context.Context, repo *repo_model.Repository, mo
return users, nil
}
func GetUserIDsWithUnitAccess(ctx context.Context, repo *repo_model.Repository, mode perm_model.AccessMode, unitType unit.Type) (container.Set[int64], error) {
func GetUserIDsWithAnyUnitAccess(ctx context.Context, repo *repo_model.Repository, mode perm_model.AccessMode, unitType unit.Type, moreUnitTypes ...unit.Type) (container.Set[int64], error) {
userIDs := container.Set[int64]{}
e := db.GetEngine(ctx)
accesses := make([]*Access, 0, 10)
@@ -630,7 +630,7 @@ func GetUserIDsWithUnitAccess(ctx context.Context, repo *repo_model.Repository,
if !repo.Owner.IsOrganization() {
userIDs.Add(repo.Owner.ID)
} else {
teamUserIDs, err := organization.GetTeamUserIDsWithAccessToAnyRepoUnit(ctx, repo.OwnerID, repo.ID, mode, unitType)
teamUserIDs, err := organization.GetTeamUserIDsWithAccessToAnyRepoUnit(ctx, repo.OwnerID, repo.ID, mode, unitType, moreUnitTypes...)
if err != nil {
return nil, err
}
@@ -675,7 +675,7 @@ func CanReadWorkflowCrossRepo(ctx context.Context, targetRepo *repo_model.Reposi
// logs in a publicly visible run; requiring a private caller keeps private content flowing private -> private.
// This is intentionally stricter than GitHub, which gates on the target repo's access setting (introduced in #32562):
// https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository#allowing-access-to-components-in-a-private-repository
if run.Repo.IsPrivate {
if run.Repo.IsPrivate && !run.IsForkPullRequest {
if actionsUnit, err := targetRepo.GetUnit(ctx, unit.TypeActions); err == nil {
if actionsUnit.ActionsConfig().IsCollaborativeOwner(run.Repo.OwnerID) {
return true, nil
+3 -3
View File
@@ -226,12 +226,12 @@ func testGetIndividualUserRepoPermission(t *testing.T) {
assert.Equal(t, perm_model.AccessModeNone, perm.unitsMode[unit.TypeCode])
assert.Equal(t, perm_model.AccessModeRead, perm.unitsMode[unit.TypeIssues])
users, err := GetUsersWithUnitAccess(ctx, repo3, perm_model.AccessModeRead, unit.TypeIssues)
users, err := GetUsersWithAnyUnitAccess(ctx, repo3, perm_model.AccessModeRead, unit.TypeIssues)
require.NoError(t, err)
require.Len(t, users, 1)
assert.Equal(t, user.ID, users[0].ID)
users, err = GetUsersWithUnitAccess(ctx, repo3, perm_model.AccessModeWrite, unit.TypeIssues)
users, err = GetUsersWithAnyUnitAccess(ctx, repo3, perm_model.AccessModeWrite, unit.TypeIssues)
require.NoError(t, err)
require.Empty(t, users)
})
@@ -245,7 +245,7 @@ func testGetIndividualUserRepoPermission(t *testing.T) {
assert.Equal(t, perm_model.AccessModeWrite, perm.unitsMode[unit.TypeCode])
assert.Equal(t, perm_model.AccessModeWrite, perm.unitsMode[unit.TypeIssues])
users, err := GetUsersWithUnitAccess(ctx, repo3, perm_model.AccessModeWrite, unit.TypeIssues)
users, err := GetUsersWithAnyUnitAccess(ctx, repo3, perm_model.AccessModeWrite, unit.TypeIssues)
require.NoError(t, err)
require.Len(t, users, 1)
assert.Equal(t, user.ID, users[0].ID)
+12 -8
View File
@@ -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
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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
+6 -6
View File
@@ -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)
})
+3 -3
View File
@@ -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
+5 -3
View File
@@ -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)
})
}
-33
View File
@@ -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 {
-24
View File
@@ -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())
+12 -5
View File
@@ -655,14 +655,12 @@ func SearchRepositoryIDsByCondition(ctx context.Context, cond builder.Cond) ([]i
Find(&repoIDs)
}
func userAllPublicRepoCond(cond builder.Cond, orgVisibilityLimit []structs.VisibleType) builder.Cond {
func userAllPublicRepoCond(cond builder.Cond, ownerVisibilityLimit []structs.VisibleType) builder.Cond {
return cond.Or(builder.And(
builder.Eq{"`repository`.is_private": false},
// Aren't in a private organisation or limited organisation if we're not logged in
// Exclude owners who are not visible to the caller.
builder.NotIn("`repository`.owner_id", builder.Select("id").From("`user`").Where(
builder.And(
builder.Eq{"type": user_model.UserTypeOrganization},
builder.In("visibility", orgVisibilityLimit)),
builder.In("visibility", ownerVisibilityLimit),
))))
}
@@ -771,6 +769,15 @@ func PublicRepoUnderPublicOwnerCond() builder.Cond {
)
}
// NotPublicRepoUnderPublicOwnerCond complements PublicRepoUnderPublicOwnerCond. Spelled positively so
// the owner subquery hashes the limited/private minority, not every public user.
func NotPublicRepoUnderPublicOwnerCond() builder.Cond {
return builder.Or(
builder.Eq{"`repository`.is_private": true},
builder.In("`repository`.owner_id", builder.Select("id").From("`user`").Where(builder.Neq{"visibility": structs.VisibleTypePublic})),
)
}
// UserActionsAccessibleOwnerRepoCond selects the repos owned by ownerID whose Actions `user` may read.
// It is used to list an org/user's Actions runs and jobs (see the callers in routers/api/v1/shared).
// - owner_id = ownerID: only that owner's repos.
+17
View File
@@ -271,6 +271,23 @@ func testSearchRepositoryRestricted(t *testing.T) {
})
}
func TestSearchRepositoryExcludesHiddenIndividualOwners(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
hiddenOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
require.NoError(t, user_model.UpdateUserCols(t.Context(), &user_model.User{
ID: hiddenOwner.ID,
Visibility: structs.VisibleTypePrivate,
}, "visibility"))
repos, _, err := repo_model.SearchRepositoryByName(t.Context(), repo_model.SearchRepoOptions{
ListOptions: db.ListOptions{Page: 1, PageSize: 100},
Keyword: "repo1",
})
require.NoError(t, err)
assert.NotContains(t, repoIDs(repos), int64(1))
}
func testSearchRepositoryPrivate(t *testing.T) {
// test search private repository on explore page
repos, count, err := repo_model.SearchRepositoryByName(t.Context(), repo_model.SearchRepoOptions{
+120
View File
@@ -0,0 +1,120 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package jobparser
import (
"errors"
"io"
"gitea.com/gitea/runner/act/model"
"go.yaml.in/yaml/v4"
)
// maxExpandedNodes bounds how many nodes alias expansion may create. go-yaml's own alias guard does
// not cover us: it only counts while decoding into values, and a workflow is kept as raw yaml.Nodes.
const maxExpandedNodes = 50000
var errTooManyYamlNodes = errors.New("maximum YAML nodes exceeded")
// ReadWorkflow decodes a workflow file with its aliases expanded. Callers inspect the workflow's
// raw nodes by kind, and an alias is a kind none of them expect.
func ReadWorkflow(content []byte) (*model.Workflow, error) {
doc, err := resolveYamlAliases(content)
if err != nil {
return nil, err
}
return readWorkflowDoc(doc)
}
func readWorkflowDoc(doc *yaml.Node) (*model.Workflow, error) {
if doc.Kind == 0 {
return nil, io.EOF // what a yaml decoder reports for an empty file
}
w := new(model.Workflow)
return w, doc.Decode(w)
}
// decodeResolved is yaml.Unmarshal with aliases expanded first.
func decodeResolved(content []byte, out any) error {
doc, err := resolveYamlAliases(content)
if err != nil {
return err
}
return decodeYamlDoc(doc, out)
}
func decodeYamlDoc(doc *yaml.Node, out any) error {
if doc.Kind == 0 {
return nil // an empty document, as yaml.Unmarshal treats it
}
return doc.Decode(out)
}
// resolveYamlAliases parses content and replaces every alias with a copy of the node its anchor names.
func resolveYamlAliases(content []byte) (*yaml.Node, error) {
doc := &yaml.Node{}
if err := yaml.Unmarshal(content, doc); err != nil {
return nil, err
}
budget := maxExpandedNodes
return doc, expandAliases(doc, &budget)
}
// expandAliases replaces node's alias descendants in place.
func expandAliases(node *yaml.Node, budget *int) error {
node.Anchor = "" // a name for a node, not part of the workflow: keep it out of the payloads
if err := rejectMergeKeys(node); err != nil {
return err
}
for i, child := range node.Content {
if child.Kind != yaml.AliasNode {
if err := expandAliases(child, budget); err != nil {
return err
}
continue
}
copied, err := copyExpanded(child.Alias, budget)
if err != nil {
return err
}
node.Content[i] = copied
}
return nil
}
// copyExpanded deep copies a node expandAliases already expanded and validated, since an anchor is
// declared before the alias naming it. An anchor aliased from inside itself is the exception, and
// recurses here until it exhausts budget.
func copyExpanded(node *yaml.Node, budget *int) (*yaml.Node, error) {
if *budget--; *budget < 0 {
return nil, errTooManyYamlNodes
}
if node.Kind == yaml.AliasNode {
return copyExpanded(node.Alias, budget)
}
copied := *node
copied.Content = make([]*yaml.Node, len(node.Content))
for i, child := range node.Content {
child, err := copyExpanded(child, budget)
if err != nil {
return nil, err
}
copied.Content[i] = child
}
return &copied, nil
}
// rejectMergeKeys refuses `<<: *anchor`, same as GitHub does
func rejectMergeKeys(node *yaml.Node) error {
if node.Kind != yaml.MappingNode {
return nil
}
for i := 0; i < len(node.Content)-1; i += 2 {
if node.Content[i].Tag == "!!merge" {
return errors.New("merge keys (`<<`) are not supported, alias the whole value instead")
}
}
return nil
}
+85
View File
@@ -0,0 +1,85 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package jobparser
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseResolvesAliases(t *testing.T) {
got, err := Parse([]byte(`on: push
env: &common_env
SHARED: "1"
jobs:
a:
runs-on: linux
env: *common_env
steps: &common_steps [{run: echo hi}]
b:
runs-on: linux
env: *common_env
steps: *common_steps
`))
require.NoError(t, err)
require.Len(t, got, 2)
for _, workflow := range got {
_, job := workflow.Job()
var env map[string]string
require.NoError(t, job.Env.Decode(&env))
assert.Equal(t, map[string]string{"SHARED": "1"}, env)
require.Len(t, job.Steps, 1)
payload, err := workflow.Marshal()
require.NoError(t, err)
assert.NotContains(t, string(payload), "common_")
}
}
func TestParseRejectsAliases(t *testing.T) {
job := func(body string) []byte {
return []byte("on: push\njobs:\n a:\n runs-on: linux\n" + body)
}
for _, tt := range []struct {
name, wantErr string
content []byte
}{
{
name: "nested aliases exceed the node limit",
content: []byte(`on: push
x0: &x0 [1, 2, 3, 4, 5, 6, 7, 8, 9]
x1: &x1 [*x0, *x0, *x0, *x0, *x0, *x0, *x0, *x0, *x0]
x2: &x2 [*x1, *x1, *x1, *x1, *x1, *x1, *x1, *x1, *x1]
x3: &x3 [*x2, *x2, *x2, *x2, *x2, *x2, *x2, *x2, *x2]
x4: &x4 [*x3, *x3, *x3, *x3, *x3, *x3, *x3, *x3, *x3]
jobs: {a: {runs-on: linux, steps: [{run: echo}]}}
`),
wantErr: "maximum YAML nodes exceeded",
},
{
name: "anchor aliased from inside itself",
content: job(" steps: &s [{run: echo}, *s]\n"),
wantErr: "maximum YAML nodes exceeded",
},
{
name: "merge key",
content: job(" env: &e {X: \"1\"}\n container:\n image: alpine\n env:\n <<: *e\n"),
wantErr: "merge keys (`<<`) are not supported",
},
{
name: "alias before its anchor",
content: job(" env: *e\n container: {image: alpine, env: &e {X: \"1\"}}\n"),
wantErr: "unknown anchor 'e' referenced",
},
} {
t.Run(tt.name, func(t *testing.T) {
_, err := Parse(tt.content)
require.ErrorContains(t, err, tt.wantErr)
})
}
}
+161 -82
View File
@@ -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
}
+8 -3
View File
@@ -57,9 +57,14 @@ func NewInterpeter(
}
ee := &exprparser.EvaluationEnvironment{
Github: gitCtx,
Env: nil, // no need
Job: nil, // no need
Github: gitCtx,
Env: nil, // no need
// Job must be non-nil because cancelled() dereferences Job.Status unconditionally.
// See: https://gitea.com/gitea/runner/src/commit/ad967330a8788c9b8ab723abbc1a86d53c3bc5e6/act/exprparser/functions.go#L299
// TODO: The empty JobContext.Status is right for now because Gitea never checks `if` condition when the workflow run is cancelled.
// This is an implementation gap in Gitea Actions. When a workflow run is cancelled, Gitea should check the job's `if` condition,
// and if the condition is met (e.g. `if: ${{ cancelled() }}` ), the job should be executed rather than cancelled.
Job: &model.JobContext{},
Steps: nil, // no need
Runner: nil, // no need
Secrets: nil, // no need
+84 -12
View File
@@ -4,25 +4,33 @@
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"
)
func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
origin, err := model.ReadWorkflow(bytes.NewReader(content))
// The workflow is split into one document per job below, which would strand an alias whose
// anchor lands in another one.
doc, err := resolveYamlAliases(content)
if err != nil {
return nil, fmt.Errorf("model.ReadWorkflow: %w", err)
return nil, fmt.Errorf("resolve aliases: %w", err)
}
origin, err := readWorkflowDoc(doc)
if err != nil {
return nil, fmt.Errorf("read workflow: %w", err)
}
workflow := &SingleWorkflow{}
if err := yaml.Unmarshal(content, workflow); err != nil {
return nil, fmt.Errorf("yaml.Unmarshal: %w", err)
if err := decodeYamlDoc(doc, workflow); err != nil {
return nil, fmt.Errorf("decode workflow: %w", err)
}
pc := &parseContext{}
@@ -48,7 +56,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 +73,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 {
@@ -117,6 +131,9 @@ type parseContext struct {
type ParseOption func(c *parseContext)
func getMatrixes(job *model.Job) ([]map[string]any, error) {
if err := validateMatrixFilters(job); err != nil {
return nil, err
}
ret, err := job.GetMatrixes()
if err != nil {
return nil, fmt.Errorf("GetMatrixes: %w", err)
@@ -127,6 +144,32 @@ func getMatrixes(job *model.Job) ([]map[string]any, error) {
return ret, nil
}
// validateMatrixFilters rejects an `include`/`exclude` that is not a list of mappings, so that the
// usual way to get there, an unevaluated ${{ }} expression that is still a scalar, is named as such
// instead of panicking inside the expansion.
func validateMatrixFilters(job *model.Job) error {
if job.Strategy == nil || job.Strategy.RawMatrix.Kind != yaml.MappingNode {
return nil
}
content := job.Strategy.RawMatrix.Content
for i := 0; i+1 < len(content); i += 2 {
name, value := content[i].Value, content[i+1]
if name != "include" && name != "exclude" {
continue
}
entries := []*yaml.Node{value}
if value.Kind == yaml.SequenceNode {
entries = value.Content
}
for _, entry := range entries {
if entry.Kind != yaml.MappingNode {
return fmt.Errorf("matrix %s must be a list of mappings", name)
}
}
}
return nil
}
func encodeMatrix(matrix map[string]any) yaml.Node {
if len(matrix) == 0 {
return yaml.Node{}
@@ -150,16 +193,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 {
@@ -4,9 +4,11 @@
package jobparser
import (
"fmt"
"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 +109,86 @@ 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)
}
func TestRejectsUnevaluatedMatrixFilters(t *testing.T) {
// An unevaluated ${{ }} expression is still a scalar, which act cannot apply as a filter: it used
// to reach the expansion and panic there on an unchecked type assertion, taking down the file view
// and the push_update queue.
const workflow = `
name: t
on: push
jobs:
setup:
runs-on: ubuntu-latest
outputs:
m: ${{ steps.s.outputs.m }}
steps: [{id: s, run: echo}]
build:
needs: setup
runs-on: ubuntu-latest
strategy:
matrix:
%s
steps: [{run: echo}]
`
for _, tt := range []struct {
name string
matrix string
}{
{name: "include expression", matrix: "include: ${{ fromJson(needs.setup.outputs.m) }}"},
{name: "exclude expression", matrix: "os: [a, b]\n exclude: ${{ fromJson(vars.MATRIX) }}"},
{name: "include scalar", matrix: "include: whatever"},
{name: "include list of scalars", matrix: "include: [a, b]"},
} {
t.Run(tt.name, func(t *testing.T) {
require.NotPanics(t, func() {
_, err := Parse(fmt.Appendf(nil, workflow, tt.matrix))
require.ErrorContains(t, err, "must be a list of mappings")
})
})
}
// a well-formed include/exclude keeps expanding
planned, err := Parse(fmt.Appendf(nil, workflow, "os: [a, b]\n include:\n - os: c\n exclude:\n - os: b"))
require.NoError(t, err)
assert.Len(t, planned, 3) // setup, plus build for os a and c
}
+39 -12
View File
@@ -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 {
@@ -75,7 +81,22 @@ func (w *SingleWorkflow) SetJob(id string, job *Job) error {
}
func (w *SingleWorkflow) Marshal() ([]byte, error) {
return yaml.Marshal(w)
// Encode with the same indentation SetJob uses (2). yaml.Marshal's default
// indentation (4) makes the encoder emit multi-line block scalars (e.g. a
// `run:` step that begins with blank lines) with a wrong explicit indentation
// indicator (`run: |4`) that then fails to re-parse, which silently strands
// the job during concurrency evaluation. Keeping both encoders at indent 2
// makes the serialized single workflow round-trip.
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
if err := enc.Encode(w); err != nil {
return nil, err
}
if err := enc.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
type Job struct {
@@ -236,9 +257,11 @@ func (evt *Event) Inputs() []WorkflowDispatchInput {
}
func ReadWorkflowRawConcurrency(content []byte) (*model.RawConcurrency, error) {
w := new(model.Workflow)
err := yaml.NewDecoder(bytes.NewReader(content)).Decode(w)
return w.RawConcurrency, err
w, err := ReadWorkflow(content)
if err != nil {
return nil, err
}
return w.RawConcurrency, nil
}
func EvaluateConcurrency(rc *model.RawConcurrency, jobID string, job *Job, gitCtx map[string]any, results map[string]*JobResult, vars map[string]string, inputs map[string]any) (string, bool, error) {
@@ -277,7 +300,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 {
@@ -490,16 +513,20 @@ func EvaluateJobIfExpression(jobID string, job *Job, gitCtx map[string]any, resu
RawMatrix: job.Strategy.RawMatrix,
},
}
evaluator := NewExpressionEvaluator(NewInterpeter(jobID, actJob, nil, toGitContext(gitCtx), results, vars, inputs))
expr, err := rewriteSubExpression(job.If.Value, false)
// Each per-matrix job carries its single matrix combination in RawMatrix so resolve it and pass it in;
// otherwise `matrix.*` references in `if:` evaluate to null.
// GetMatrixes always returns at least one element (an empty map for a job without a matrix),
// so only a non-empty combination should populate `matrix.*`, leaving it nil otherwise.
var matrix map[string]any
matrixes, err := actJob.GetMatrixes()
if err != nil {
return false, err
}
result, err := evaluator.evaluate(expr, exprparser.DefaultStatusCheckSuccess)
if err != nil {
return false, err
if len(matrixes) > 0 && len(matrixes[0]) > 0 {
matrix = matrixes[0]
}
return exprparser.IsTruthy(result), nil
evaluator := NewExpressionEvaluator(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs))
return evaluator.evaluateCondition(job.If.Value)
}
// parseMappingNode parse a mapping node and preserve order.
+108
View File
@@ -4,6 +4,7 @@
package jobparser
import (
"fmt"
"strings"
"testing"
@@ -464,3 +465,110 @@ func TestParseMappingNode(t *testing.T) {
})
}
}
func TestEvaluateJobIfExpressionMatrix(t *testing.T) {
ifExprs := []string{
`${{ contains(fromJSON('["linux","windows"]'), matrix.target) }}`,
`${{ contains('["linux","windows"]', matrix.target) }}`,
}
want := map[string]bool{
"build (linux)": true,
"build (windows)": true,
"build (macos)": false,
}
for _, ifExpr := range ifExprs {
t.Run(ifExpr, func(t *testing.T) {
content := fmt.Sprintf(`
name: test
on: push
jobs:
build:
runs-on: ubuntu-latest
if: %s
strategy:
fail-fast: false
matrix:
target: [linux, windows, macos]
steps:
- run: echo ${{ matrix.target }}
`, ifExpr)
swfs, err := Parse([]byte(content))
require.NoError(t, err)
require.Len(t, swfs, 3)
got := make(map[string]bool, len(swfs))
for _, swf := range swfs {
id, job := swf.Job()
shouldRun, err := EvaluateJobIfExpression(id, job, map[string]any{}, map[string]*JobResult{id: {}}, nil, nil)
require.NoError(t, err)
got[job.Name] = shouldRun
}
assert.Equal(t, want, got)
})
}
}
func TestEvaluateJobIfExpression(t *testing.T) {
kases := []struct {
name string
ifCond string
needResult string
expected bool
}{
{name: "empty need success", ifCond: "${{ 1 == 1 }}", needResult: "success", expected: true},
{name: "always", ifCond: "${{ always() }}", needResult: "failure", expected: true},
{name: "failure true", ifCond: "${{ failure() }}", needResult: "failure", expected: true},
{name: "failure false", ifCond: "${{ failure() }}", needResult: "success", expected: false},
{name: "success true", ifCond: "${{ success() }}", needResult: "success", expected: true},
// cancelled() is always false on the server: a cancelled run never evaluates a blocked job's `if:`
{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) {
content := strings.ReplaceAll(`
name: test
on: push
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo job1
job2:
runs-on: ubuntu-latest
needs: [job1]
if: IF_COND
steps:
- run: echo job2
`, "IF_COND", kase.ifCond)
workflows, err := Parse([]byte(content))
require.NoError(t, err)
var job2 *Job
for _, wf := range workflows {
if id, job := wf.Job(); id == "job2" {
job2 = job
}
}
require.NotNil(t, job2)
// mirrors findJobNeedsAndFillJobResults: the needs' results plus a self entry carrying Needs
results := map[string]*JobResult{
"job1": {Result: kase.needResult},
"job2": {Needs: []string{"job1"}},
}
got, err := EvaluateJobIfExpression("job2", job2, map[string]any{}, results, nil, nil)
require.NoError(t, err)
assert.Equal(t, kase.expected, got)
})
}
}
@@ -0,0 +1,64 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package jobparser
import (
"testing"
"github.com/stretchr/testify/require"
)
// A step whose `run:` block starts with blank lines must still survive the
// Parse -> SingleWorkflow.Marshal -> Parse round-trip. Previously Marshal used a
// different indentation than SetJob, which made the encoder emit the block scalar
// with a wrong explicit indentation indicator (`run: |4`) that no longer parsed;
// the job then stayed silently blocked during concurrency evaluation.
func TestSingleWorkflowRoundTripRunBlockLeadingBlankLines(t *testing.T) {
const wf = `name: demo
on:
workflow_call:
inputs:
app_name:
type: string
required: true
jobs:
build:
name: build
env:
IMAGE_TAG: ${{ inputs.app_name }}
runs-on: ubuntu-latest
steps:
- if: ${{ inputs.app_name != '' }}
name: packages
run: |
echo start
echo done
`
sws, err := Parse([]byte(wf))
require.NoError(t, err)
require.Len(t, sws, 1)
// pin the original run block as the baseline
_, origJob := sws[0].Job()
require.Len(t, origJob.Steps, 1)
const wantRun = "\n\necho start\necho done\n"
require.Equal(t, wantRun, origJob.Steps[0].Run)
payload, err := sws[0].Marshal()
require.NoError(t, err)
// the serialized single workflow must be parseable again -- this is what the
// server does in EvaluateJobConcurrencyFillModel -> ParseJob. Before the fix
// Marshal emitted `run: |4`, which failed here and left the job blocked.
roundTripped, err := Parse(payload)
require.NoError(t, err, "serialized single workflow must round-trip; got payload:\n%s", payload)
require.Len(t, roundTripped, 1)
// the round-trip must preserve the run block byte-for-byte
_, gotJob := roundTripped[0].Job()
require.Len(t, gotJob.Steps, 1)
require.Equal(t, wantRun, gotJob.Steps[0].Run, "round-trip must preserve run content; got payload:\n%s", payload)
}
+4 -30
View File
@@ -62,7 +62,7 @@ func ParseWorkflowCallSpec(content []byte) (*WorkflowCallSpec, error) {
var doc struct {
On yaml.Node `yaml:"on"`
}
if err := yaml.Unmarshal(content, &doc); err != nil {
if err := decodeResolved(content, &doc); err != nil {
return nil, fmt.Errorf("parse workflow yaml: %w", err)
}
@@ -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)
}
}
+5 -3
View File
@@ -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:
+5 -4
View File
@@ -30,8 +30,9 @@ func FullSteps(task *actions_model.ActionTask) []*actions_model.ActionTaskStep {
// For example,
// 1. preStep(Success) -> step1(Success) -> step2(Running) -> step3(Waiting) -> postStep(Waiting): lastHasRunStep is step1.
// 2. preStep(Success) -> step1(Success) -> step2(Success) -> step3(Success) -> postStep(Success): lastHasRunStep is step3.
// 3. preStep(Success) -> step1(Success) -> step2(Failure) -> step3 -> postStep(Waiting): lastHasRunStep is step2.
// So its Stopped is the Started of postStep when there are no more steps to run.
// 3. preStep(Success) -> step1(Success) -> step2(Failure) -> step3(Waiting) -> postStep(Waiting): lastHasRunStep is step2.
// 4. preStep(Success) -> step1(Success) -> step2(Skipped) -> postStep(Success): lastHasRunStep is step1, because a skipped step never ran.
// So its Stopped is the Started of postStep once no step is left to run.
var lastHasRunStep *actions_model.ActionTaskStep
var logIndex int64
@@ -71,8 +72,8 @@ func FullSteps(task *actions_model.ActionTask) []*actions_model.ActionTaskStep {
Name: postStepName,
Status: actions_model.StatusWaiting,
}
// If the lastHasRunStep is the last step, or it has failed, postStep has started.
if lastHasRunStep.Status.IsFailure() || lastHasRunStep == task.Steps[len(task.Steps)-1] {
// If no step is left to run, or the lastHasRunStep has failed, postStep has started.
if lastHasRunStep.Status.IsFailure() || task.Steps[len(task.Steps)-1].Status.IsDone() {
postStep.LogIndex = logIndex
postStep.LogLength = task.LogLength - postStep.LogIndex
postStep.Started = lastHasRunStep.Stopped
+21
View File
@@ -156,6 +156,27 @@ func TestFullSteps(t *testing.T) {
{Name: postStepName, Status: actions_model.StatusSuccess, LogIndex: 90, LogLength: 10, Started: 10090, Stopped: 10100},
},
},
{
name: "last step is skipped",
task: &actions_model.ActionTask{
Steps: []*actions_model.ActionTaskStep{
{Status: actions_model.StatusSuccess, LogIndex: 10, LogLength: 20, Started: 10010, Stopped: 10020},
{Status: actions_model.StatusSuccess, LogIndex: 30, LogLength: 60, Started: 10020, Stopped: 10090},
{Status: actions_model.StatusSkipped, LogIndex: 0, LogLength: 0, Started: 0, Stopped: 0},
},
Status: actions_model.StatusSuccess,
Started: 10000,
Stopped: 10100,
LogLength: 100,
},
want: []*actions_model.ActionTaskStep{
{Name: preStepName, Status: actions_model.StatusSuccess, LogIndex: 0, LogLength: 10, Started: 10000, Stopped: 10010},
{Status: actions_model.StatusSuccess, LogIndex: 10, LogLength: 20, Started: 10010, Stopped: 10020},
{Status: actions_model.StatusSuccess, LogIndex: 30, LogLength: 60, Started: 10020, Stopped: 10090},
{Status: actions_model.StatusSkipped, LogIndex: 0, LogLength: 0, Started: 0, Stopped: 0},
{Name: postStepName, Status: actions_model.StatusSuccess, LogIndex: 90, LogLength: 10, Started: 10090, Stopped: 10100},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
+15 -11
View File
@@ -4,7 +4,6 @@
package actions
import (
"bytes"
"fmt"
"path"
"slices"
@@ -28,6 +27,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
@@ -118,7 +119,7 @@ func GetContentFromEntry(entry *git.TreeEntry) ([]byte, error) {
}
func GetEventsFromContent(content []byte) ([]*jobparser.Event, error) {
workflow, err := model.ReadWorkflow(bytes.NewReader(content))
workflow, err := jobparser.ReadWorkflow(content)
if err != nil {
return nil, err
}
@@ -203,17 +204,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 +255,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)
}
+4 -7
View File
@@ -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
},
}
}
+91 -59
View File
@@ -39,9 +39,7 @@ type CommitMessage struct {
trailerValues CommitMessageTrailerValues
allParticipants []*CommitIdentity
committerCoAuthorIdx int
committerCoAuthor *CommitIdentity
allAuthors []*CommitIdentity
}
func (c *CommitMessage) MessageUTF8() string {
@@ -77,8 +75,12 @@ func (c *CommitMessage) MessageTrailer() CommitMessageTrailerValues {
}
var commitMessageTrailerSplit = sync.OnceValue(func() *regexp.Regexp {
// the sep is either something like "\n---\n" or "\n\n" in the body, or at the start of the body like "---\n"
return regexp.MustCompile(`(?s)^(?P<content>.*?)(?P<sep>^|^\n|^-{3,}\n+|\n-{3,}\n+|\n\n)(?P<trailer>(?:[A-Za-z0-9][-A-Za-z0-9]*:[^\n]*\n?)*\n*)$`)
// ref: https://git-scm.com/docs/git-interpret-trailers
// TODO: the regexp is not able to perfectly parse the all kinds of trailers
// It was just copied from legacy code, it is not exactly the same as how Git parses the trailer and not quite right in some cases.
// For the key characters: it follows RFC 822 field name syntax (or RFC 2822/RFC 5322): printable ASCII characters between 33 and 126 except the colon (:),
// but maybe we don't want to make it that complicated, so here we only support some common "symbol-like" characters.
return regexp.MustCompile(`(?s)^(?P<content>.*?)(?P<sep>^|^\n|^-{3,}\n+|\n+-{3,}\n+|\n{2,})(?P<trailer>(?:[A-Za-z0-9][-\w]*:[^\n]*(\n\s+[^\n]*)*\n?)*\n*)$`)
})
// CommitMessageSplitTrailer tries to split the message by the trailer separator
@@ -93,6 +95,41 @@ func CommitMessageSplitTrailer(s string) (content, sep, trailer string) {
return v[re.SubexpIndex("content")], v[re.SubexpIndex("sep")], v[re.SubexpIndex("trailer")]
}
// CommitMessageMerge merges two commit messages with their trailers
func CommitMessageMerge(m1, m2 string) string {
c1, s1, t1 := CommitMessageSplitTrailer(m1)
c2, s2, t2 := CommitMessageSplitTrailer(m2)
c1, t1 = strings.TrimSpace(c1), strings.TrimSpace(t1)
c2, t2 = strings.TrimSpace(c2), strings.TrimSpace(t2)
out := strings.Builder{}
if c1 != "" && c2 != "" {
out.WriteString(c1)
out.WriteString("\n\n")
out.WriteString(c2)
} else if c1 != "" {
out.WriteString(c1)
} else if c2 != "" {
out.WriteString(c2)
}
if t1 != "" || t2 != "" {
sep := util.Iif(t1 == "", s2, s1)
sep = util.IfZero(sep, "\n\n")
if c1 != "" || c2 != "" {
out.WriteString(sep)
}
if t1 != "" {
out.WriteString(t1)
}
if t1 != "" && t2 != "" {
out.WriteString("\n")
}
if t2 != "" {
out.WriteString(t2)
}
}
return out.String()
}
func CommitMessageParseTrailer(s string) CommitMessageTrailerValues {
ret := CommitMessageTrailerValues{}
for line := range strings.SplitSeq(util.NormalizeStringEOL(s), "\n") {
@@ -107,63 +144,58 @@ 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
}
exclude := map[string]int{}
addParticipant := func(name, email string, role int) (existingRole int) {
if name == "" && email == "" {
return 0
}
emailLower := strings.ToLower(email)
if existingRole = exclude[emailLower]; emailLower != "" && existingRole != 0 {
return existingRole
}
c.allParticipants = append(c.allParticipants, &CommitIdentity{Name: name, Email: email, role: role})
exclude[emailLower] = role
return 0
}
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"] {
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}
}
}
return c.allParticipants
}
// 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.
// 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)
// 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
func (c *Commit) AllAuthorIdentities() []*CommitIdentity {
if c.allAuthors != nil {
return c.allAuthors
}
if all[1].role != commitIdentityRoleCommitter {
return all[1:] // no committer, so all after author are co-authors
trailerCoAuthors := c.MessageTrailer()["co-authored-by"]
c.allAuthors = make([]*CommitIdentity, 0, 1+len(trailerCoAuthors))
exclude := map[string]int{}
addAuthor := func(name, email string, role int) {
if name == "" && email == "" {
return
}
key := strings.ToLower(email)
if key == "" {
key = strings.ToLower(name)
}
if existingRole := exclude[key]; key != "" && existingRole != 0 {
return
}
c.allAuthors = append(c.allAuthors, &CommitIdentity{Name: name, Email: email, role: role})
exclude[key] = role
}
if c.committerCoAuthorIdx == -1 {
return all[2:] // the committer is not in the co-author list, so just return the co-author list
addAuthor(c.Author.Name, c.Author.Email, commitIdentityRoleAuthor)
for _, coAuthorValue := range trailerCoAuthors {
coAuthorName, coAuthorEmail := parseCommitIdentityValue(coAuthorValue)
addAuthor(coAuthorName, coAuthorEmail, commitIdentityRoleCoAuthor)
}
// 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 c.allAuthors
}
// Git identities are not RFC 5322 addresses: net/mail rejects names like "dependabot[bot]", so fall back to the angle-addr.
func parseCommitIdentityValue(value string) (name, email string) {
if addr, err := mail.ParseAddress(value); err == nil {
return addr.Name, addr.Address
}
begin, end := strings.LastIndex(value, "<"), strings.LastIndex(value, ">")
if begin == -1 || end < begin {
return value, ""
}
return strings.TrimSpace(value[:begin]), strings.TrimSpace(value[begin+1 : end])
}
func (c *Commit) CoAuthorIdentities() (coAuthors []*CommitIdentity) {
all := c.AllAuthorIdentities()
if len(all) == 0 {
return nil
}
if all[0].role == commitIdentityRoleAuthor {
return all[1:]
}
return all
}
+62 -18
View File
@@ -26,10 +26,12 @@ func TestCommitMessageTrailer(t *testing.T) {
{"a", "a", "", ""},
{"a\n\nk", "a\n\nk", "", ""},
{"a\n\nk:v", "a", "\n\n", "k:v"},
{"a\n\nk:v\n next-line", "a", "\n\n", "k:v\n next-line"},
{"a\n\nk:v\n next-line\nother: v", "a", "\n\n", "k:v\n next-line\nother: v"},
{"a\n\nk:v\n\n", "a", "\n\n", "k:v\n\n"},
{"a\n--\nk:v", "a\n--\nk:v", "", ""},
{"a\n---\nk:v", "a", "\n---\n", "k:v"},
{"a\n\n---\n\nk:v", "a\n", "\n---\n\n", "k:v"},
{"a\n---\nk:v", "a", "\n---\n", "k:v"}, // TODO: should we support such case? No empty line between "---" and the trailer
{"a\n\n---\n\nk:v", "a", "\n\n---\n\n", "k:v"},
{"k: v", "", "", "k: v"},
{"\nk:v", "", "\n", "k:v"},
@@ -50,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) {
@@ -114,12 +119,23 @@ 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("b", "", roleCoAuthor), idt("c", "", roleCoAuthor)},
},
{
"CoAuthorNameNotAnEmailAddress", // names net/mail rejects, e.g. bots and names with a comma
&Commit{
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>\nCo-authored-by: Smith, John <j@m.com>"},
},
[]*CommitIdentity{
idt("dependabot[bot]", "49699333+dependabot[bot]@users.noreply.github.com", roleCoAuthor),
idt("Smith, John", "j@m.com", roleCoAuthor),
},
[]*CommitIdentity{idt("x", "x@m.com", roleCoAuthor), idt("c-other", "c@m.com", roleCoAuthor), idt("y", "y@m.com", roleCoAuthor)},
},
}
for _, c := range cases {
@@ -127,3 +143,31 @@ func TestCommitMessageParticipants(t *testing.T) {
}
})
}
func TestCommitMessageMerge(t *testing.T) {
cases := []struct {
m1, m2 string
out string
}{
{"", "", ""},
{"msg1", "", "msg1"},
{"", "msg2", "msg2"},
{"msg1", "msg2", "msg1\n\nmsg2"},
{"k1: a", "", "k1: a"},
{"", "k2: b", "k2: b"},
{"k1: a", "k2: b", "k1: a\nk2: b"},
{"msg1", "k2: b", "msg1\n\nk2: b"},
{"k1: a", "msg2", "msg2\n\nk1: a"},
{"msg1\n\nk1: a", "msg2", "msg1\n\nmsg2\n\nk1: a"},
{"msg1\n----\nk1: a", "msg2", "msg1\n\nmsg2\n----\nk1: a"},
{"msg1\n\n----\n\nk1: a", "msg2", "msg1\n\nmsg2\n\n----\n\nk1: a"},
{"msg1", "msg2\n----\nk2: b", "msg1\n\nmsg2\n----\nk2: b"},
{"msg1", "msg2\n\nk2: b", "msg1\n\nmsg2\n\nk2: b"},
{"msg1\n\nk1: a", "msg2\n\nk2: b", "msg1\n\nmsg2\n\nk1: a\nk2: b"},
}
for i, c := range cases {
out := CommitMessageMerge(c.m1, c.m2)
assert.Equal(t, c.out, out, "idx=%d, m1=%q m2=%q", i, c.m1, c.m2)
}
}
+15 -1
View File
@@ -91,7 +91,7 @@ func syncGitConfig(ctx context.Context) (err error) {
}
}
// By default partial clones are disabled, enable them from git v2.22
// By default, partial clones are disabled, enable them from git v2.22
if !setting.Git.DisablePartialClone && DefaultFeatures().CheckVersionAtLeast("2.22") {
if err = configSet(ctx, "uploadpack.allowfilter", "true"); err != nil {
return err
@@ -114,9 +114,23 @@ func syncGitConfig(ctx context.Context) (err error) {
}
}
GlobalConfig = &GlobalConfigStruct{}
// HINT: GIT-DIFF-TREE-UI-CONFIG: Git's bug: git-diff-tree loads config with /* no "diff" UI options */ (since 20 years ago).
// https://github.com/git/git/blame/5d2e7709234afea1b6ddb25cd4f60d3d5fb3c200/builtin/diff-tree.c#L127
// Although document and manual say that "git-diff-tree" supports "diff.orderfile" option, but it is not actually supported.
// So we need to apply the diff.orderfile explicitly in our code.
GlobalConfig.DiffOrderFile, _ = configGet(ctx, "diff.orderfile")
return nil
}
func configGet(ctx context.Context, key string) (string, error) {
stdout, _, err := gitcmd.NewCommand("config", "--global", "--get").AddDynamicArguments(key).RunStdString(ctx)
if err != nil && !gitcmd.IsErrorExitCode(err, 1) {
return "", fmt.Errorf("failed to get git config %s, err: %w", key, err)
}
return strings.TrimRight(stdout, "\r\n"), nil
}
func configSet(ctx context.Context, key, value string) error {
stdout, _, err := gitcmd.NewCommand("config", "--global", "--get").
AddDynamicArguments(key).
+8 -1
View File
@@ -36,7 +36,14 @@ type Features struct {
SupportGitMergeTree bool // >= 2.40 // we also need "--merge-base"
}
var defaultFeatures *Features
type GlobalConfigStruct struct {
DiffOrderFile string
}
var (
defaultFeatures *Features
GlobalConfig *GlobalConfigStruct
)
func (f *Features) CheckVersionAtLeast(atLeast string) bool {
return f.gitVersion.Compare(version.Must(version.NewVersion(atLeast))) >= 0
+15
View File
@@ -0,0 +1,15 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import "gitea.dev/modules/git/gitcmd"
func HandleGitCmdHTTPRedirection(cmd *gitcmd.Command, targets ...string) {
// Protect from SSRF vector (e.g. migrating from an attacker URL).
// cmd.AddConfig("http.followRedirects", "false")
// However, we can't do so at the moment:
// this fails due to 301: git -c http.followRedirects=false clone -v https://gitlab.com/{owner}/{repo}
// this succeeds: git -c http.followRedirects=false clone -v https://gitlab.com/{owner}/{repo}.git
// FIXME: GIT-CLONE-HTTP-REDIRECT-SSRF: need a complete solution in the future
}
+1 -3
View File
@@ -121,9 +121,7 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error {
}
cmd := gitcmd.NewCommand().AddArguments("clone")
// Never follow HTTP redirects: no clone caller needs them, and a remote redirecting to an
// otherwise-blocked address would be an SSRF vector (e.g. migrating from an attacker URL).
cmd.AddArguments("-c", "http.followRedirects=false")
HandleGitCmdHTTPRedirection(cmd, from, to)
if opts.SkipTLSVerify {
cmd.AddArguments("-c", "http.sslVerify=false")
}
+1
View File
@@ -26,6 +26,7 @@ func TestRepoIsEmpty(t *testing.T) {
// TestCloneRefusesRedirects ensures Clone never follows HTTP redirects, so a remote
// cannot redirect to an otherwise-blocked address (SSRF, e.g. during migration).
func TestCloneRefusesRedirects(t *testing.T) {
t.Skip("FIXME: GIT-CLONE-HTTP-REDIRECT-SSRF: need a complete solution in the future")
var targetHit atomic.Bool
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
targetHit.Store(true)
+8 -4
View File
@@ -118,15 +118,19 @@ func createDelegateHooks(hookDir string) (err error) {
oldHookPath := filepath.Join(hookDir, hookName)
newHookPath := filepath.Join(hookDir, hookName+".d", "gitea")
if err := os.MkdirAll(filepath.Join(hookDir, hookName+".d"), os.ModePerm); err != nil {
return fmt.Errorf("create hooks dir '%s': %w", filepath.Join(hookDir, hookName+".d"), err)
hookDDir := filepath.Join(hookDir, hookName+".d")
if err := os.MkdirAll(hookDDir, 0o755); err != nil {
return fmt.Errorf("create hooks dir '%s': %w", hookDDir, err)
}
if err := os.Chmod(hookDDir, 0o755); err != nil {
return fmt.Errorf("chmod hooks dir '%s': %w", hookDDir, err)
}
// WARNING: This will override all old server-side hooks
if err = util.Remove(oldHookPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("unable to pre-remove old hook file '%s' prior to rewriting: %w ", oldHookPath, err)
}
if err = os.WriteFile(oldHookPath, []byte(hookTpls[i]), 0o777); err != nil {
if err = os.WriteFile(oldHookPath, []byte(hookTpls[i]), 0o755); err != nil {
return fmt.Errorf("write old hook file '%s': %w", oldHookPath, err)
}
@@ -137,7 +141,7 @@ func createDelegateHooks(hookDir string) (err error) {
if err = util.Remove(newHookPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("unable to pre-remove new hook file '%s' prior to rewriting: %w", newHookPath, err)
}
if err = os.WriteFile(newHookPath, []byte(giteaHookTpls[i]), 0o777); err != nil {
if err = os.WriteFile(newHookPath, []byte(giteaHookTpls[i]), 0o755); err != nil {
return fmt.Errorf("write new hook file '%s': %w", newHookPath, err)
}
+34
View File
@@ -0,0 +1,34 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func TestCreateDelegateHooksPermissions(t *testing.T) {
hookDir := t.TempDir()
existingHookDir := filepath.Join(hookDir, "post-receive.d")
require.NoError(t, os.MkdirAll(existingHookDir, 0o777))
require.NoError(t, os.Chmod(existingHookDir, 0o777))
require.NoError(t, createDelegateHooks(hookDir))
hookNames, _, _ := getHookTemplates()
for _, hookName := range hookNames {
for _, path := range []string{
filepath.Join(hookDir, hookName),
filepath.Join(hookDir, hookName+".d"),
filepath.Join(hookDir, hookName+".d", "gitea"),
} {
info, err := os.Stat(path)
require.NoError(t, err)
require.Equal(t, os.FileMode(0o755), info.Mode().Perm(), path)
}
}
}
+3 -5
View File
@@ -32,7 +32,6 @@ import (
analyzer_keyword "github.com/blevesearch/bleve/v2/analysis/analyzer/keyword"
"github.com/blevesearch/bleve/v2/analysis/token/lowercase"
"github.com/blevesearch/bleve/v2/analysis/token/unicodenorm"
"github.com/blevesearch/bleve/v2/analysis/tokenizer/letter"
"github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode"
"github.com/blevesearch/bleve/v2/mapping"
"github.com/blevesearch/bleve/v2/search/query"
@@ -69,9 +68,8 @@ func (d *RepoIndexerData) Type() string {
const (
repoIndexerAnalyzer = "repoIndexerAnalyzer"
filenameIndexerAnalyzer = "filenameIndexerAnalyzer"
filenameIndexerTokenizer = "filenameIndexerTokenizer"
repoIndexerDocType = "repoIndexerDocType"
repoIndexerLatestVersion = 9
repoIndexerLatestVersion = 10
)
// generateBleveIndexMapping generates a bleve index mapping for the repo indexer
@@ -107,8 +105,8 @@ func generateBleveIndexMapping() (mapping.IndexMapping, error) {
} else if err := mapping.AddCustomAnalyzer(repoIndexerAnalyzer, map[string]any{
"type": analyzer_custom.Name,
"char_filters": []string{},
"tokenizer": letter.Name,
"token_filters": []string{unicodeNormalizeName, lowercase.Name},
"tokenizer": codeTokenizerName,
"token_filters": []string{unicodeNormalizeName, codeTokenFilterName, lowercase.Name},
}); err != nil {
return nil, err
}
+59
View File
@@ -0,0 +1,59 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package bleve
import (
"testing"
"time"
"gitea.dev/models/db"
"gitea.dev/modules/indexer/code/internal"
inner_bleve "gitea.dev/modules/indexer/internal/bleve"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBleveIndexerTokenFilter(t *testing.T) {
dir := t.TempDir()
indexer := NewIndexer(dir)
defer indexer.Close()
_, err := indexer.Init(t.Context())
require.NoError(t, err)
batch := inner_bleve.NewFlushingBatch(indexer.inner.Indexer, maxBatchSize)
batch.Index("2", &RepoIndexerData{RepoID: 2, Content: "mDNS.port2=12345", UpdatedAt: time.Now()})
batch.Flush()
testCases := []struct {
keyword string
expectedIDs []int64
}{
{keyword: "12345", expectedIDs: []int64{2}},
{keyword: "DNS", expectedIDs: []int64{}},
{keyword: "mdns", expectedIDs: []int64{2}},
{keyword: "port", expectedIDs: []int64{2}},
{keyword: "port2", expectedIDs: []int64{2}},
}
for _, testCase := range testCases {
t.Run(testCase.keyword, func(t *testing.T) {
_, results, _, err := indexer.Search(t.Context(), &internal.SearchOptions{
Paginator: &db.ListOptions{Page: 1, PageSize: 1},
Keyword: testCase.keyword,
})
require.NoError(t, err)
assert.ElementsMatch(t, testCase.expectedIDs, searchResultIDs(results))
})
}
}
func searchResultIDs(result []*internal.SearchResult) []int64 {
ids := make([]int64, 0, len(result))
for _, hit := range result {
ids = append(ids, hit.RepoID)
}
return ids
}
+65
View File
@@ -0,0 +1,65 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package bleve
import (
"regexp"
"unicode"
"gitea.dev/modules/util"
"github.com/blevesearch/bleve/v2/analysis"
"github.com/blevesearch/bleve/v2/analysis/tokenizer/character"
"github.com/blevesearch/bleve/v2/registry"
)
const codeTokenizerName = "codeTokenizer"
func codeTokenizerConstructor(_ map[string]any, _ *registry.Cache) (analysis.Tokenizer, error) {
// Old code used "letter" tokenizer which doesn't support CJK.
// Here it still doesn't support CJK, since there is no usable CJK tokenizer at the moment.
return character.NewCharacterTokenizer(func(r rune) bool {
return unicode.IsLetter(r) || unicode.IsNumber(r)
}), nil
}
const codeTokenFilterName = "codeTokenFilter"
type codeTokenFilter struct {
re *regexp.Regexp
}
func (c codeTokenFilter) Filter(stream analysis.TokenStream) (ret analysis.TokenStream) {
// split one token to "letter" parts and "number" parts (to keep the old behavior).
// e.g.: input token="port123", then the output tokens are "port123", "port", "123"
for _, token := range stream {
ret = append(ret, token)
m := c.re.FindAllIndex(token.Term, -1)
if len(m) > 1 {
for _, it := range m {
p1, p2 := it[0], it[1]
t := &analysis.Token{
Start: token.Start + p1,
End: token.Start + p2,
Term: token.Term[p1:p2],
Position: token.Position,
Type: analysis.AlphaNumeric,
}
ret = append(ret, t)
}
}
}
return ret
}
func codeTokenFilterConstructor(_ map[string]any, _ *registry.Cache) (analysis.TokenFilter, error) {
return &codeTokenFilter{
re: regexp.MustCompile("[a-zA-Z]+|[0-9]+"),
}, nil
}
func init() {
util.MustNoError(registry.RegisterTokenizer(codeTokenizerName, codeTokenizerConstructor))
util.MustNoError(registry.RegisterTokenFilter(codeTokenFilterName, codeTokenFilterConstructor))
}
@@ -7,13 +7,13 @@ import (
"slices"
"strings"
"gitea.dev/modules/util"
"github.com/blevesearch/bleve/v2/analysis"
"github.com/blevesearch/bleve/v2/registry"
)
const (
Name = "gitea/path"
)
const Name = "gitea/path"
type TokenFilter struct{}
@@ -98,8 +98,5 @@ func generatePathTokens(input analysis.TokenStream, reversed bool) analysis.Toke
func init() {
// FIXME: move it to the bleve's init function, but do not call it in global init
err := registry.RegisterTokenFilter(Name, TokenFilterConstructor)
if err != nil {
panic(err)
}
util.MustNoError(registry.RegisterTokenFilter(Name, TokenFilterConstructor))
}
+2 -3
View File
@@ -15,7 +15,6 @@ import (
"github.com/blevesearch/bleve/v2"
"github.com/blevesearch/bleve/v2/analysis/analyzer/custom"
"github.com/blevesearch/bleve/v2/analysis/token/camelcase"
"github.com/blevesearch/bleve/v2/analysis/token/lowercase"
"github.com/blevesearch/bleve/v2/analysis/token/unicodenorm"
"github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode"
@@ -26,7 +25,7 @@ import (
const (
issueIndexerAnalyzer = "issueIndexer"
issueIndexerDocType = "issueIndexerDocType"
issueIndexerLatestVersion = 7
issueIndexerLatestVersion = 8
)
const unicodeNormalizeName = "unicodeNormalize"
@@ -103,7 +102,7 @@ func generateIssueIndexMapping() (mapping.IndexMapping, error) {
"type": custom.Name,
"char_filters": []string{},
"tokenizer": unicode.Name,
"token_filters": []string{unicodeNormalizeName, camelcase.Name, lowercase.Name},
"token_filters": []string{unicodeNormalizeName, camelCaseKeepWholeName, lowercase.Name},
}); err != nil {
return nil, err
}
@@ -77,6 +77,47 @@ func TestBleveIndexerNoAssignee(t *testing.T) {
}
}
func TestBleveIndexerTokenFilter(t *testing.T) {
dir := t.TempDir()
indexer := NewIndexer(dir)
defer indexer.Close()
_, err := indexer.Init(t.Context())
require.NoError(t, err)
require.NoError(t, indexer.Index(t.Context(),
&internal.IndexerData{ID: 1, Title: "fix(packages): SomeThing needs a rewrite (#12345)"},
&internal.IndexerData{ID: 2, Title: "add support for mDNS discovery abc1234"},
))
testCases := []struct {
name string
keyword string
expectedIDs []int64
}{
{name: "exact original case", keyword: "SomeThing", expectedIDs: []int64{1}},
{name: "case matching original transitions", keyword: "someThing", expectedIDs: []int64{1}},
{name: "all lower case", keyword: "something", expectedIDs: []int64{1}},
{name: "all upper case", keyword: "SOMETHING", expectedIDs: []int64{1}},
{name: "number match", keyword: "12345", expectedIDs: []int64{1}},
{name: "number as part", keyword: "1234", expectedIDs: []int64{2}},
{name: "sub-word search still works", keyword: "DNS", expectedIDs: []int64{2}},
{name: "sub-word search, lower case", keyword: "mdns", expectedIDs: []int64{2}},
{name: "keyword is camel case", keyword: "addSupport", expectedIDs: []int64{2}},
{name: "keyword not match", keyword: "addsupport", expectedIDs: []int64{}},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
result, err := indexer.Search(t.Context(), &internal.SearchOptions{
Keyword: testCase.keyword,
})
require.NoError(t, err)
assert.ElementsMatch(t, testCase.expectedIDs, searchResultIDs(result))
})
}
}
func searchResultIDs(result *internal.SearchResult) []int64 {
ids := make([]int64, 0, len(result.Hits))
for _, hit := range result.Hits {
@@ -0,0 +1,64 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package bleve
import (
"gitea.dev/modules/util"
"github.com/blevesearch/bleve/v2/analysis"
"github.com/blevesearch/bleve/v2/analysis/token/camelcase"
"github.com/blevesearch/bleve/v2/registry"
)
const camelCaseKeepWholeName = "camelCaseKeepWhole"
// camelCaseKeepWholeFilter behaves like bleve's built-in "camelCase" token filter,
// it also uses the whole word for a token. For example: when indexing "someThing",
// CamelCaseFilter only emits "some" and "thing", this filter also emits "something".
// It is questionable why the "issue indexer" used the CamelCaseFilter, it just can't search "someThing".
// To avoid breaking existing user experiences, this "whole token filter" is introduced to make the full word can be searched.
type camelCaseKeepWholeFilter struct {
inner *camelcase.CamelCaseFilter
}
func (f *camelCaseKeepWholeFilter) Filter(input analysis.TokenStream) analysis.TokenStream {
// First, do exactly what the stock camelCase filter does: split by "camelCase" tokens
split := f.inner.Filter(input)
// Index the resulting position of the *first* sub-token produced for
// each original token (matched by start offset), so the duplicated
// whole-word token we add below lines up at the same position as the
// sub-word it stands in for, instead of drifting out of sync for
// fields with more than one original token.
posByStart := make(map[int]int, len(split))
for _, tok := range split {
if _, ok := posByStart[tok.Start]; !ok {
posByStart[tok.Start] = tok.Position
}
}
rv := make(analysis.TokenStream, 0, len(split)+len(input))
rv = append(rv, split...)
// Then append one extra, un-split copy of every original token, so the
// whole word survives as a standalone, independently searchable term.
for _, token := range input {
dup := *token
dup.Term = append([]byte(nil), token.Term...)
if pos, ok := posByStart[token.Start]; ok {
dup.Position = pos
}
rv = append(rv, &dup)
}
return rv
}
func camelCaseKeepWholeFilterConstructor(_ map[string]any, _ *registry.Cache) (analysis.TokenFilter, error) {
return &camelCaseKeepWholeFilter{inner: camelcase.NewCamelCaseFilter()}, nil
}
func init() {
util.MustNoError(registry.RegisterTokenFilter(camelCaseKeepWholeName, camelCaseKeepWholeFilterConstructor))
}
+2 -2
View File
@@ -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)
}
+9
View File
@@ -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
+33 -22
View File
@@ -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
View File
@@ -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)
}
+1 -8
View File
@@ -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:
+3 -6
View File
@@ -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>")
}
+5 -5
View File
@@ -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>
-1
View File
@@ -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())
}
+9 -1
View File
@@ -70,7 +70,15 @@ func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error
w := &orgWriter{rctx: ctx, HTMLWriter: htmlWriter}
htmlWriter.ExtendingWriter = w
res, err := org.New().Silent().Parse(input, "").Write(w)
cfg := org.New()
cfg.ReadFile = func(path string) ([]byte, error) {
// actually the orgmode render doesn't support rendering the content from the content again,
// so just leave the plain text to end users
content := fmt.Sprintf("#+INCLUDE: [[%s]]", path)
return []byte(content), nil
}
doc := cfg.Silent().Parse(input, "")
res, err := doc.Write(w)
if err != nil {
return fmt.Errorf("orgmode.Render failed: %w", err)
}
+31 -43
View File
@@ -21,86 +21,74 @@ func TestMain(m *testing.M) {
os.Exit(m.Run())
}
func TestRender_StandardLinks(t *testing.T) {
test := func(input, expected string) {
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
func testRender(t *testing.T, input, expected string) {
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
test("[[https://google.com/]]",
func TestRender_StandardLinks(t *testing.T) {
testRender(t, "[[https://google.com/]]",
`<p><a href="https://google.com/">https://google.com/</a></p>`)
test("[[ImageLink.svg][The Image Desc]]",
testRender(t, "[[ImageLink.svg][The Image Desc]]",
`<p><a href="ImageLink.svg">The Image Desc</a></p>`)
}
func TestRender_InternalLinks(t *testing.T) {
test := func(input, expected string) {
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
test("[[file:test.org][Test]]",
testRender(t, "[[file:test.org][Test]]",
`<p><a href="test.org">Test</a></p>`)
test("[[./test.org][Test]]",
testRender(t, "[[./test.org][Test]]",
`<p><a href="./test.org">Test</a></p>`)
test("[[test.org][Test]]",
testRender(t, "[[test.org][Test]]",
`<p><a href="test.org">Test</a></p>`)
test("[[path/to/test.org][Test]]",
testRender(t, "[[path/to/test.org][Test]]",
`<p><a href="path/to/test.org">Test</a></p>`)
}
func TestRender_Media(t *testing.T) {
test := func(input, expected string) {
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
test("[[file:../../.images/src/02/train.jpg]]",
testRender(t, "[[file:../../.images/src/02/train.jpg]]",
`<p><img src="../../.images/src/02/train.jpg" alt="../../.images/src/02/train.jpg"></p>`)
test("[[file:train.jpg]]",
testRender(t, "[[file:train.jpg]]",
`<p><img src="train.jpg" alt="train.jpg"></p>`)
// With description.
test("[[https://example.com][https://example.com/example.svg]]",
testRender(t, "[[https://example.com][https://example.com/example.svg]]",
`<p><a href="https://example.com"><img src="https://example.com/example.svg" alt="https://example.com/example.svg"></a></p>`)
test("[[https://example.com][pre https://example.com/example.svg post]]",
testRender(t, "[[https://example.com][pre https://example.com/example.svg post]]",
`<p><a href="https://example.com">pre <img src="https://example.com/example.svg" alt="https://example.com/example.svg"> post</a></p>`)
test("[[https://example.com][https://example.com/example.mp4]]",
testRender(t, "[[https://example.com][https://example.com/example.mp4]]",
`<p><a href="https://example.com"><video src="https://example.com/example.mp4">https://example.com/example.mp4</video></a></p>`)
test("[[https://example.com][pre https://example.com/example.mp4 post]]",
testRender(t, "[[https://example.com][pre https://example.com/example.mp4 post]]",
`<p><a href="https://example.com">pre <video src="https://example.com/example.mp4">https://example.com/example.mp4</video> post</a></p>`)
// Without description.
test("[[https://example.com/example.svg]]",
testRender(t, "[[https://example.com/example.svg]]",
`<p><img src="https://example.com/example.svg" alt="https://example.com/example.svg"></p>`)
test("[[https://example.com/example.mp4]]",
testRender(t, "[[https://example.com/example.mp4]]",
`<p><video src="https://example.com/example.mp4">https://example.com/example.mp4</video></p>`)
// test [[LINK][DESCRIPTION]] syntax with "file:" prefix
test(`[[https://example.com/][file:https://example.com/foo%20bar.svg]]`,
testRender(t, `[[https://example.com/][file:https://example.com/foo%20bar.svg]]`,
`<p><a href="https://example.com/"><img src="https://example.com/foo%20bar.svg" alt="https://example.com/foo%20bar.svg"></a></p>`)
test(`[[file:https://example.com/foo%20bar.svg][Goto Image]]`,
testRender(t, `[[file:https://example.com/foo%20bar.svg][Goto Image]]`,
`<p><a href="https://example.com/foo%20bar.svg">Goto Image</a></p>`)
test(`[[file:https://example.com/link][https://example.com/image.jpg]]`,
testRender(t, `[[file:https://example.com/link][https://example.com/image.jpg]]`,
`<p><a href="https://example.com/link"><img src="https://example.com/image.jpg" alt="https://example.com/image.jpg"></a></p>`)
test(`[[file:https://example.com/link][file:https://example.com/image.jpg]]`,
testRender(t, `[[file:https://example.com/link][file:https://example.com/image.jpg]]`,
`<p><a href="https://example.com/link"><img src="https://example.com/image.jpg" alt="https://example.com/image.jpg"></a></p>`)
}
func TestRender_Source(t *testing.T) {
test := func(input, expected string) {
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
test(`#+begin_src c
testRender(t, `#+begin_src c
int a;
#+end_src
`, `<div class="src src-c">
<pre><code class="chroma language-c"><span class="kt">int</span> <span class="n">a</span><span class="p">;</span></code></pre>
</div>`)
}
func TestRender_IncludeLink(t *testing.T) {
testRender(t, `#+INCLUDE: "./other.org" src text`, `<div class="src src-text">
<pre><code class="chroma language-plaintext">#+INCLUDE: [[other.org]]</code></pre>
</div>`)
}
+12 -3
View File
@@ -18,9 +18,10 @@ import (
)
var (
ErrMissingPKGINFOFile = util.NewInvalidArgumentErrorf("PKGINFO file is missing")
ErrInvalidName = util.NewInvalidArgumentErrorf("package name is invalid")
ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid")
ErrMissingPKGINFOFile = util.NewInvalidArgumentErrorf("PKGINFO file is missing")
ErrInvalidName = util.NewInvalidArgumentErrorf("package name is invalid")
ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid")
ErrPackageInfoTooLarge = util.NewInvalidArgumentErrorf("PKGINFO contains too many entries")
)
const (
@@ -36,6 +37,8 @@ const (
RepositoryVersion = "_repository"
NoArch = "noarch"
maxPackageInfoEntries = 1024
)
// https://wiki.alpinelinux.org/wiki/Apk_spec
@@ -185,10 +188,16 @@ func ParsePackageInfo(r io.Reader) (*Package, error) {
p.FileMetadata.InstallIf = value
case "provides":
if value != "" {
if len(p.FileMetadata.Provides)+len(p.FileMetadata.Dependencies) >= maxPackageInfoEntries {
return nil, ErrPackageInfoTooLarge
}
p.FileMetadata.Provides = append(p.FileMetadata.Provides, value)
}
case "depend":
if value != "" {
if len(p.FileMetadata.Provides)+len(p.FileMetadata.Dependencies) >= maxPackageInfoEntries {
return nil, ErrPackageInfoTooLarge
}
p.FileMetadata.Dependencies = append(p.FileMetadata.Dependencies, value)
}
case "provider_priority":
+10
View File
@@ -8,6 +8,7 @@ import (
"bytes"
"compress/gzip"
"io"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -97,6 +98,15 @@ func TestParsePackage(t *testing.T) {
assert.Equal(t, "Q1SRYURM5+uQDqfHSwTnNIOIuuDVQ=", p.FileMetadata.Checksum)
})
t.Run("TooManyDependencyEntries", func(t *testing.T) {
data := append(createPKGINFOContent(packageName, packageVersion), []byte("\ndepend = item")...)
data = append(data, []byte(strings.Repeat("\ndepend = item", maxPackageInfoEntries))...)
p, err := ParsePackageInfo(bytes.NewReader(data))
assert.Nil(t, p)
assert.ErrorIs(t, err, ErrPackageInfoTooLarge)
})
}
func TestParsePackageInfo(t *testing.T) {
+3
View File
@@ -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
}
+1
View File
@@ -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)
+30 -2
View File
@@ -21,6 +21,7 @@ import (
var (
ErrMissingManifestFile = util.NewInvalidArgumentErrorf("Package.swift file is missing")
ErrManifestFileTooLarge = util.NewInvalidArgumentErrorf("Package.swift file is too large")
ErrManifestFilesTooLarge = util.NewInvalidArgumentErrorf("Package.swift files are too large")
ErrInvalidManifestVersion = util.NewInvalidArgumentErrorf("manifest version is invalid")
manifestPattern = regexp.MustCompile(`\APackage(?:@swift-(\d+(?:\.\d+)?(?:\.\d+)?))?\.swift\z`)
@@ -29,6 +30,8 @@ var (
const (
maxManifestFileSize = 128 * 1024
maxManifestFiles = 64
maxManifestSize = maxManifestFiles * maxManifestFileSize
PropertyScope = "swift.scope"
PropertyName = "swift.name"
@@ -123,11 +126,36 @@ 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)
}
}
if len(manifestFiles) > maxManifestFiles {
return nil, ErrManifestFilesTooLarge
}
var manifestSize uint64
for _, file := range manifestFiles {
manifestSize += file.UncompressedSize64
}
if manifestSize > maxManifestSize {
return nil, ErrManifestFilesTooLarge
}
for _, file := range manifestFiles {
manifestMatch := manifestPattern.FindStringSubmatch(path.Base(file.Name))
if file.UncompressedSize64 > maxManifestFileSize {
return nil, ErrManifestFileTooLarge
+98
View File
@@ -4,7 +4,9 @@
package swift
import (
"archive/zip"
"bytes"
"fmt"
"strings"
"testing"
@@ -24,6 +26,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": ""})
@@ -41,6 +55,19 @@ func TestParsePackage(t *testing.T) {
assert.ErrorIs(t, err, ErrManifestFileTooLarge)
})
t.Run("TooManyManifestFiles", func(t *testing.T) {
entries := make([][2]string, 0, maxManifestFiles+1)
entries = append(entries, [2]string{"Package.swift", "// swift-tools-version:5.7"})
for i := range maxManifestFiles {
entries = append(entries, [2]string{fmt.Sprintf("Package@swift-5.%d.swift", i), "// swift-tools-version:5.7"})
}
data := writeOrderedZipArchive(entries)
p, err := ParsePackage(bytes.NewReader(data.Bytes()), int64(data.Len()), nil)
assert.Nil(t, p)
assert.ErrorIs(t, err, ErrManifestFilesTooLarge)
})
t.Run("WithoutMetadata", func(t *testing.T) {
content1 := "// swift-tools-version:5.7\n//\n// Package.swift"
content2 := "// swift-tools-version:5.6\n//\n// Package@swift-5.6.swift"
@@ -65,6 +92,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",
-12
View File
@@ -109,18 +109,6 @@ func HookProcReceive(ctx context.Context, ownerName, repoName string, opts HookO
return requestJSONResp(req, &HookProcReceiveResult{})
}
// SetDefaultBranch will set the default branch to the provided branch for the provided repository
func SetDefaultBranch(ctx context.Context, ownerName, repoName, branch string) ResponseExtra {
reqURL := setting.LocalURL + fmt.Sprintf("api/internal/hook/set-default-branch/%s/%s/%s",
url.PathEscape(ownerName),
url.PathEscape(repoName),
url.PathEscape(branch),
)
req := newInternalRequestAPI(ctx, reqURL, "POST")
_, extra := requestJSONResp(req, &ResponseText{})
return extra
}
// SSHLog sends ssh error log response
func SSHLog(ctx context.Context, isErr bool, msg string) error {
reqURL := setting.LocalURL + "api/internal/ssh/log"
-19
View File
@@ -88,25 +88,6 @@ type LoggerOptions struct {
Config map[string]any
}
// AddLogger adds a logger
func AddLogger(ctx context.Context, logger, writer, mode string, config map[string]any) ResponseExtra {
reqURL := setting.LocalURL + "api/internal/manager/add-logger"
req := newInternalRequestAPI(ctx, reqURL, "POST", LoggerOptions{
Logger: logger,
Writer: writer,
Mode: mode,
Config: config,
})
return requestJSONClientMsg(req, "Added")
}
// RemoveLogger removes a logger
func RemoveLogger(ctx context.Context, logger, writer string) ResponseExtra {
reqURL := setting.LocalURL + fmt.Sprintf("api/internal/manager/remove-logger/%s/%s", url.PathEscape(logger), url.PathEscape(writer))
req := newInternalRequestAPI(ctx, reqURL, "POST")
return requestJSONClientMsg(req, "Removed")
}
// Processes return the current processes from this gitea instance
func Processes(ctx context.Context, out io.Writer, flat, noSystem, stacktraces, json bool, cancel string) ResponseExtra {
reqURL := setting.LocalURL + fmt.Sprintf("api/internal/manager/processes?flat=%t&no-system=%t&stacktraces=%t&json=%t&cancel-pid=%s", flat, noSystem, stacktraces, json, url.QueryEscape(cancel))
+6
View File
@@ -8,4 +8,10 @@ const (
KeyUname = "uname"
KeyUserHasTwoFactorAuth = "userHasTwoFactorAuth"
// KeySignInMethod records how the current session was authenticated so logout
// can decide whether RP-initiated OIDC logout is appropriate.
KeySignInMethod = "signInMethod"
SignInMethodOAuth2 = "oauth2"
)
+2 -1
View File
@@ -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
+1 -1
View File
@@ -108,7 +108,7 @@ var OAuth2 = struct {
JWTSigningAlgorithm: "RS256",
JWTSigningPrivateKeyFile: "jwt/private.pem",
MaxTokenLength: math.MaxInt16,
DefaultApplications: []string{"git-credential-oauth", "git-credential-manager", "tea"},
DefaultApplications: []string{"git-credential-oauth", "git-credential-manager", "tea", "gitea-app"},
}
func loadOAuth2From(rootCfg ConfigProvider) {
+1 -1
View File
@@ -62,7 +62,7 @@ func TestGetGeneralSigningSecretSave(t *testing.T) {
func TestOauth2DefaultApplications(t *testing.T) {
cfg, _ := NewConfigProviderFromData(``)
loadOAuth2From(cfg)
assert.Equal(t, []string{"git-credential-oauth", "git-credential-manager", "tea"}, OAuth2.DefaultApplications)
assert.Equal(t, []string{"git-credential-oauth", "git-credential-manager", "tea", "gitea-app"}, OAuth2.DefaultApplications)
cfg, _ = NewConfigProviderFromData(`[oauth2]
DEFAULT_APPLICATIONS = tea
+6 -8
View File
@@ -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))
}
+10 -18
View File
@@ -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) {
+6
View File
@@ -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 {

Some files were not shown because too many files have changed in this diff Show More