Compare commits

...

81 Commits

Author SHA1 Message Date
bircni
1dac1bb2f8 docs: Add Changelog for 1.27.2 (#38910) 2026-08-13 17:34:13 +00:00
silverwind
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
bircni
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
bircni
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
wxiaoguang
0acbcc58a7 fix: update collaborator access mode and httpsign (#38894, #38862) (#38895)
backport #38894, partially #38862
2026-08-13 09:59:07 +00:00
Giteabot
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
Zettat123
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
Giteabot
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
wxiaoguang
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
Giteabot
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
Giteabot
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
silverwind
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
silverwind
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
wxiaoguang
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
Giteabot
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
Giteabot
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
silverwind
6eab271921 fix(deps): update dependency mermaid to v11.16.1 [security] (#38816)
Backport #38813
2026-08-07 09:53:50 +02:00
Zettat123
eab225f095 fix(actions): allow cancelling runs without running jobs (#35842) (#38812) 2026-08-07 06:54:08 +02:00
silverwind
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
wxiaoguang
4e64b3a65d fix: render highlight language (#38793) (#38795)
backport #38793
2026-08-06 10:34:28 +00:00
Giteabot
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
Giteabot
e5c6669751 fix: markup link (#38764) (#38765)
Backport #38764

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-08-03 15:04:52 +00:00
Giteabot
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
Giteabot
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
wxiaoguang
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
wxiaoguang
a8e80ebc23 fix(lfs): failed upload deletes a concurrent upload's meta object (#38693) (#38722)
backport #38693
2026-07-31 13:41:26 +00:00
Giteabot
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
762b8b8264
("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
f1131d5ab6,
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
Giteabot
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
wxiaoguang
d2603a8b4a fix: avoid markup render panic (#38698) (#38703)
backport #38698, fix #38697
2026-07-30 13:41:12 +08:00
Brecht Van Lommel
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
Giteabot
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
bircni
a62dfffbe7 docs: Clean up build section in CHANGELOG.md (#38671) 2026-07-27 20:43:03 +02:00
bircni
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
Giteabot
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
Giteabot
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
Giteabot
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
Giteabot
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
Giteabot
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
Giteabot
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
wxiaoguang
d7bc52beea refactor: git patch apply (#38637) (#38638)
Backport #38637
2026-07-26 17:32:02 +00:00
Giteabot
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
Giteabot
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
Giteabot
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
Giteabot
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
Giteabot
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
wxiaoguang
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
Giteabot
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
Giteabot
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
Giteabot
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
Giteabot
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
Giteabot
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
Giteabot
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
Giteabot
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
Giteabot
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
Giteabot
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
Giteabot
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
Giteabot
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
Giteabot
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
Giteabot
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
Giteabot
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
Giteabot
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
Giteabot
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
wxiaoguang
9731ad7c3c fix: revert git clone http redirection forbidden (#38530) (#38545)
backport #38530
2026-07-20 17:21:17 +02:00
Giteabot
148d528814 fix: clean up orphaned user-keyed tables in deleteUser (#38511) (#38514) 2026-07-20 08:41:59 +00:00
Giteabot
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
Giteabot
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 Akman
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
Giteabot
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
Giteabot
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
Giteabot
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()`](ad967330a8/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
Giteabot
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
Giteabot
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
Giteabot
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
Giteabot
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
Giteabot
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
Giteabot
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
wxiaoguang
bf594690db fix: mail template for push event (#38467) (#38468)
Backport #38467
2026-07-15 18:57:03 +02:00
Giteabot
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
Giteabot
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
Giteabot
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
Giteabot
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
247 changed files with 5035 additions and 1703 deletions

View File

@@ -72,6 +72,18 @@ jobs:
BRANCH: ${{ steps.clean_name.outputs.branch }} BRANCH: ${{ steps.clean_name.outputs.branch }}
run: | run: |
aws s3 sync dist/release "s3://$AWS_S3_BUCKET/gitea/$BRANCH" --no-progress aws s3 sync dist/release "s3://$AWS_S3_BUCKET/gitea/$BRANCH" --no-progress
# configure-aws-credentials exports AWS_REGION job-wide and it wins over AWS_DEFAULT_REGION, so pin it here
- name: upload binaries to cloudflare r2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
AWS_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://$CLOUDFLARE_R2_BUCKET/gitea/$BRANCH" --endpoint-url "https://$CLOUDFLARE_R2_ACCOUNT_ID.r2.cloudflarestorage.com" --no-progress
nightly-container: nightly-container:
runs-on: namespace-profile-gitea-release-docker runs-on: namespace-profile-gitea-release-docker

View File

@@ -73,6 +73,18 @@ jobs:
BRANCH: ${{ steps.clean_name.outputs.branch }} BRANCH: ${{ steps.clean_name.outputs.branch }}
run: | run: |
aws s3 sync dist/release "s3://$AWS_S3_BUCKET/gitea/$BRANCH" --no-progress aws s3 sync dist/release "s3://$AWS_S3_BUCKET/gitea/$BRANCH" --no-progress
# configure-aws-credentials exports AWS_REGION job-wide and it wins over AWS_DEFAULT_REGION, so pin it here
- name: upload binaries to cloudflare r2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
AWS_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://$CLOUDFLARE_R2_BUCKET/gitea/$BRANCH" --endpoint-url "https://$CLOUDFLARE_R2_ACCOUNT_ID.r2.cloudflarestorage.com" --no-progress
- name: Install GH CLI - name: Install GH CLI
uses: dev-hanz-ops/install-gh-cli-action@af38ce09b1ec248aeb08eea2b16bbecea9e059f8 # v0.2.1 uses: dev-hanz-ops/install-gh-cli-action@af38ce09b1ec248aeb08eea2b16bbecea9e059f8 # v0.2.1
with: with:

View File

@@ -76,6 +76,18 @@ jobs:
BRANCH: ${{ steps.clean_name.outputs.branch }} BRANCH: ${{ steps.clean_name.outputs.branch }}
run: | run: |
aws s3 sync dist/release "s3://$AWS_S3_BUCKET/gitea/$BRANCH" --no-progress aws s3 sync dist/release "s3://$AWS_S3_BUCKET/gitea/$BRANCH" --no-progress
# configure-aws-credentials exports AWS_REGION job-wide and it wins over AWS_DEFAULT_REGION, so pin it here
- name: upload binaries to cloudflare r2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
AWS_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://$CLOUDFLARE_R2_BUCKET/gitea/$BRANCH" --endpoint-url "https://$CLOUDFLARE_R2_ACCOUNT_ID.r2.cloudflarestorage.com" --no-progress
- name: Install GH CLI - name: Install GH CLI
uses: dev-hanz-ops/install-gh-cli-action@af38ce09b1ec248aeb08eea2b16bbecea9e059f8 # v0.2.1 uses: dev-hanz-ops/install-gh-cli-action@af38ce09b1ec248aeb08eea2b16bbecea9e059f8 # v0.2.1
with: with:

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 without substantial changes to our git log; to see the highlights of what has
been added to each release, please refer to the [blog](https://blog.gitea.com). been added to each release, please refer to the [blog](https://blog.gitea.com).
## [1.27.2](https://github.com/go-gitea/gitea/releases/tag/v1.27.2) - 2026-08-14
* SECURITY
* Fix: update collaborator access mode and httpsign (#38894, #38862) (#38895)
* Refactor: external render (#38885) (#38898)
* Fix(actions): resolve pull_request_target reusable workflows at the base commit (#38886) (#38897)
* Refactor: markup render (#38864) (#38869)
* Fix(deps): update dependency mermaid to v11.16.1 (#38816)
* Fix(auth): set WebAuthn user verification per request (#38805) (#38810)
* Fix: render highlight language (#38793) (#38795)
* ENHANCEMENTS
* enhance: add missing npm package metadata properties (#38826) (#38831)
* BUGFIXES
* fix(actions): keep github.event.inputs as strings for workflow_dispatch (#38899) (#38908)
* fix(actions): let a rerun of selected jobs read the previous attempt's artifacts (#38857) (#38901)
* fix(lfs): accept successful transfer responses (#38866) (#38875)
* fix(packages): ignore nested Package.swift (#38788) (#38836)
* fix: drop newline-bearing member names in arch ParsePackage (#38102) (#38830)
* fix(storage): fix Azure Blob dump failing with file does not exist (#38814) (#38828)
* fix(migration): migration deletion returned json redirection (#38796) (#38825)
* fix(ui): change underlines to default browser style (#38819) (#38823)
* fix(actions): allow cancelling runs without running jobs (#35842) (#38812)
* fix(actions): evaluate each `${{ }}` part on its own (#38754) (#38797)
* fix(actions): write an action task report in one transaction (#38792) (#38794)
* fix: markup link (#38764) (#38765)
* fix: set a minio part size when the content size is unknown (#38753) (#38755)
* fix: bad path escape in subpath archive download (#38749) (#38750)
* fix: remove the pull merge box from UI when the refreshed page doesn't contain it (#38742) (#38744)
* fix(markdown): fix double strikethough on code (#38707) (#38729)
* fix(lfs): failed upload deletes a concurrent upload's meta object (#38693) (#38722)
* fix: correct full url when using sub-path (#38712) (#38716)
* fix: avoid markup render panic (#38698) (#38703)
* fix(ui): too many participants shown in commit avatar stacks (#38689) (#38700)
* fix: support HEAD requests on Alpine registry APKINDEX.tar.gz (#38686) (#38688)
* fix(migrations): use all configured GitHub tokens (#38841) (#38846)
## [1.27.1](https://github.com/go-gitea/gitea/releases/tag/v1.27.1) - 2026-07-27
* 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 ## [1.27.0](https://github.com/go-gitea/gitea/releases/tag/v1.27.0) - 2026-07-13
* BREAKING * BREAKING

View File

@@ -6,6 +6,7 @@ package cmd
import ( import (
"crypto/x509" "crypto/x509"
"encoding/pem" "encoding/pem"
"errors"
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
@@ -93,10 +94,21 @@ func runACME(listenAddr string, m http.Handler) error {
myACME := certmagic.NewACMEIssuer(magic, certmagic.DefaultACME) myACME := certmagic.NewACMEIssuer(magic, certmagic.DefaultACME)
magic.Issuers = []certmagic.Issuer{myACME} magic.Issuers = []certmagic.Issuer{myACME}
// this obtains certificates or renews them if necessary // Obtain certificates or renew them if necessary. ManageSync fails closed on
err := magic.ManageSync(graceful.GetManager().HammerContext(), []string{setting.Domain}) // 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 { 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() tlsConfig := magic.TLSConfig()

2
go.mod
View File

@@ -87,6 +87,7 @@ require (
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
github.com/quasoft/websspi v1.1.2 github.com/quasoft/websspi v1.1.2
github.com/redis/go-redis/v9 v9.21.0 github.com/redis/go-redis/v9 v9.21.0
github.com/rhysd/actionlint v1.7.12
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
github.com/sassoftware/go-rpmutils v0.4.0 github.com/sassoftware/go-rpmutils v0.4.0
@@ -244,7 +245,6 @@ require (
github.com/prometheus/common v0.68.1 // indirect github.com/prometheus/common v0.68.1 // indirect
github.com/prometheus/procfs v0.20.1 // indirect github.com/prometheus/procfs v0.20.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rhysd/actionlint v1.7.12 // indirect
github.com/rs/xid v1.6.0 // indirect github.com/rs/xid v1.6.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect

View File

@@ -9,10 +9,10 @@ package actions
import ( import (
"context" "context"
"errors" "errors"
"slices"
"time" "time"
"gitea.dev/models/db" "gitea.dev/models/db"
"gitea.dev/modules/optional"
"gitea.dev/modules/timeutil" "gitea.dev/modules/timeutil"
"gitea.dev/modules/util" "gitea.dev/modules/util"
@@ -147,7 +147,7 @@ type FindArtifactsOptions struct {
db.ListOptions db.ListOptions
RepoID int64 RepoID int64
RunID int64 RunID int64
RunAttemptID optional.Option[int64] // use optional to allow filtering by zero (legacy artifacts have run_attempt_id=0) RunAttemptIDs []int64 // empty means every attempt; pass 0 to target legacy artifacts, which have run_attempt_id=0
ArtifactName string ArtifactName string
Status int Status int
FinalizedArtifactsV4 bool FinalizedArtifactsV4 bool
@@ -167,8 +167,8 @@ func (opts FindArtifactsOptions) ToConds() builder.Cond {
if opts.RunID > 0 { if opts.RunID > 0 {
cond = cond.And(builder.Eq{"run_id": opts.RunID}) cond = cond.And(builder.Eq{"run_id": opts.RunID})
} }
if opts.RunAttemptID.Has() { if len(opts.RunAttemptIDs) > 0 {
cond = cond.And(builder.Eq{"run_attempt_id": opts.RunAttemptID.Value()}) cond = cond.And(builder.In("run_attempt_id", opts.RunAttemptIDs))
} }
if opts.ArtifactName != "" { if opts.ArtifactName != "" {
cond = cond.And(builder.Eq{"artifact_name": opts.ArtifactName}) cond = cond.And(builder.Eq{"artifact_name": opts.ArtifactName})
@@ -185,6 +185,27 @@ func (opts FindArtifactsOptions) ToConds() builder.Cond {
return cond return cond
} }
// FindReadableArtifacts returns the artifacts of opts.RunAttemptIDs, only keeps the ones from a newer attempt.
func FindReadableArtifacts(ctx context.Context, opts FindArtifactsOptions) ([]*ActionArtifact, error) {
arts, err := db.Find[ActionArtifact](ctx, opts)
if err != nil || len(opts.RunAttemptIDs) <= 1 {
return arts, err
}
return keepLatestAttemptArtifacts(arts), nil
}
// keepLatestAttemptArtifacts keeps, per name, only the artifacts of the newest attempt that has it.
// A v3 artifact is one row per uploaded file, so the whole group of the winning attempt is kept.
func keepLatestAttemptArtifacts(arts []*ActionArtifact) []*ActionArtifact {
latest := make(map[string]int64)
for _, art := range arts {
latest[art.ArtifactName] = max(latest[art.ArtifactName], art.RunAttemptID)
}
return slices.DeleteFunc(arts, func(art *ActionArtifact) bool {
return art.RunAttemptID != latest[art.ArtifactName]
})
}
// ActionArtifactMeta is the meta-data of an artifact // ActionArtifactMeta is the meta-data of an artifact
type ActionArtifactMeta struct { type ActionArtifactMeta struct {
ArtifactName string ArtifactName string

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)
}

View File

@@ -11,6 +11,7 @@ import (
"gitea.dev/models/db" "gitea.dev/models/db"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/container"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/timeutil" "gitea.dev/modules/timeutil"
"gitea.dev/modules/util" "gitea.dev/modules/util"
@@ -62,14 +63,16 @@ func (attempt *ActionRunAttempt) LoadAttributes(ctx context.Context) (err error)
attempt.Run = run attempt.Run = run
} }
if attempt.TriggerUser == nil { return attempt.LoadTriggerUser(ctx)
attempt.TriggerUserID, attempt.TriggerUser, err = user_model.GetPossibleUserByID(ctx, attempt.TriggerUserID) }
if err != nil {
return err
}
}
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) { 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 return &attempt, nil
} }
// GetArtifactAttemptIDs returns the IDs of the attempts whose artifacts the job may read, newest first,
// always including the job's own attempt.
// An attempt that re-ran only some of the run's jobs keeps the artifacts of the attempt it re-ran from,
// because the jobs it passed through never upload them again; a rerun of the whole run starts over.
func GetArtifactAttemptIDs(ctx context.Context, job *ActionRunJob) ([]int64, error) {
if job.Attempt <= 1 || job.RunAttemptID == 0 {
return []int64{job.RunAttemptID}, nil
}
attempts, err := ListRunAttemptsByRunID(ctx, job.RunID)
if err != nil {
return nil, err
}
// a newer attempt is never readable, and attempt 1 has nothing older to continue into
candidateIDs := container.FilterSlice(attempts, func(a *ActionRunAttempt) (int64, bool) {
return a.ID, a.Attempt > 1 && a.Attempt <= job.Attempt
})
passThroughAttemptIDs, err := findPassThroughAttemptIDs(ctx, candidateIDs)
if err != nil {
return nil, err
}
ids := make([]int64, 0, len(attempts))
for _, attempt := range attempts {
if attempt.Attempt > job.Attempt {
continue
}
ids = append(ids, attempt.ID)
if !slices.Contains(passThroughAttemptIDs, attempt.ID) {
// stops at the first attempt that passed no job through
break
}
}
return ids, nil
}
// findPassThroughAttemptIDs narrows the given attempts to those that were a rerun of selected jobs:
// only such a rerun clones jobs carrying a source task.
// TODO: best-effort. Needs a better way to distinguish between "partial re-run" and "full re-run".
func findPassThroughAttemptIDs(ctx context.Context, attemptIDs []int64) ([]int64, error) {
passThroughAttemptIDs := make([]int64, 0, len(attemptIDs))
return passThroughAttemptIDs, db.GetEngine(ctx).
Table("action_run_job").
Cols("run_attempt_id").
In("run_attempt_id", attemptIDs).
Where("source_task_id <> 0").
Distinct("run_attempt_id").
Find(&passThroughAttemptIDs)
}
// FindConcurrentRunAttempts returns attempts in the given concurrency group and status set. // FindConcurrentRunAttempts returns attempts in the given concurrency group and status set.
// Results are unordered; callers must not depend on any particular row order. // Results are unordered; callers must not depend on any particular row order.
func FindConcurrentRunAttempts(ctx context.Context, repoID int64, concurrencyGroup string, statuses []Status) ([]*ActionRunAttempt, error) { func FindConcurrentRunAttempts(ctx context.Context, repoID int64, concurrencyGroup string, statuses []Status) ([]*ActionRunAttempt, error) {

View File

@@ -333,6 +333,14 @@ func GetDirectChildJobsByParent(ctx context.Context, parentJob *ActionRunJob) (A
return jobs, nil 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 // CollectAllDescendantJobs returns every job in `allJobs` that lives under parent's subtree (recursively), excluding `parent` itself
func CollectAllDescendantJobs(parent *ActionRunJob, allJobs []*ActionRunJob) []*ActionRunJob { func CollectAllDescendantJobs(parent *ActionRunJob, allJobs []*ActionRunJob) []*ActionRunJob {
parents := map[int64]bool{parent.ID: true} 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) return affected, RefreshReusableCallerStatus(ctx, parent)
} }
{ if err := refreshRunStatus(ctx, job.RepoID, job.RunID, job.RunAttemptID, StatusUnknown); err != nil {
// Other goroutines may aggregate the status of the attempt/run and update it too. return 0, err
// So we need to load the current jobs before updating the aggregate state.
if job.RunAttemptID > 0 {
attempt, err := GetRunAttemptByRepoAndID(ctx, job.RepoID, job.RunAttemptID)
if err != nil {
return 0, err
}
jobs, err := GetRunJobsByRunAndAttemptID(ctx, job.RunID, job.RunAttemptID)
if err != nil {
return 0, err
}
attempt.Status = AggregateJobStatus(jobs)
if attempt.Started.IsZero() && attempt.Status.IsRunning() {
attempt.Started = timeutil.TimeStampNow()
}
if attempt.Stopped.IsZero() && attempt.Status.IsDone() {
attempt.Stopped = timeutil.TimeStampNow()
}
if err := UpdateRunAttempt(ctx, attempt, "status", "started", "stopped"); err != nil {
return 0, fmt.Errorf("update run attempt %d: %w", attempt.ID, err)
}
} else {
// TODO: Remove this fallback in the future.
// Legacy fallback: jobs created before migration v331 have RunAttemptID=0 and are NOT backfilled.
// This path keeps those runs' status consistent when their jobs finish, including:
// - jobs created before migration v331 and complete on the new version starts
// - zombie/abandoned cleanup cron tasks that call UpdateRunJob on legacy jobs
run, err := GetRunByRepoAndID(ctx, job.RepoID, job.RunID)
if err != nil {
return 0, err
}
jobs, err := GetLatestAttemptJobsByRepoAndRunID(ctx, job.RepoID, job.RunID)
if err != nil {
return 0, err
}
run.Status = AggregateJobStatus(jobs)
if run.Started.IsZero() && run.Status.IsRunning() {
run.Started = timeutil.TimeStampNow()
}
if run.Stopped.IsZero() && run.Status.IsDone() {
run.Stopped = timeutil.TimeStampNow()
}
if err := UpdateRun(ctx, run, "status", "started", "stopped"); err != nil {
return 0, fmt.Errorf("update run %d: %w", run.ID, err)
}
}
} }
return affected, nil return affected, nil
} }
// refreshRunStatus recomputes the status of an attempt from the jobs currently stored and persists it.
// The latest attempt propagates its status to its run, an older one only updates itself.
// noJobsStatus settles an attempt without any job, which AggregateJobStatus cannot conclude on its own.
func refreshRunStatus(ctx context.Context, repoID, runID, runAttemptID int64, noJobsStatus Status) error {
// Other goroutines may aggregate the status of the attempt/run and update it too.
// So we need to load the current jobs before updating the aggregate state.
if runAttemptID > 0 {
attempt, err := GetRunAttemptByRepoAndID(ctx, repoID, runAttemptID)
if err != nil {
return err
}
jobs, err := GetRunJobsByRunAndAttemptID(ctx, runID, runAttemptID)
if err != nil {
return err
}
attempt.Status = AggregateJobStatus(jobs)
if len(jobs) == 0 {
attempt.Status = noJobsStatus
}
if attempt.Started.IsZero() && attempt.Status.IsRunning() {
attempt.Started = timeutil.TimeStampNow()
}
if attempt.Stopped.IsZero() && attempt.Status.IsDone() {
attempt.Stopped = timeutil.TimeStampNow()
}
if err := UpdateRunAttempt(ctx, attempt, "status", "started", "stopped"); err != nil {
return fmt.Errorf("update run attempt %d: %w", attempt.ID, err)
}
return nil
}
// TODO: Remove this fallback in the future.
// Legacy fallback: jobs created before migration v331 have RunAttemptID=0 and are NOT backfilled.
// This path keeps those runs' status consistent when their jobs finish, including:
// - jobs created before migration v331 and complete on the new version starts
// - zombie/abandoned cleanup cron tasks that call UpdateRunJob on legacy jobs
// - cancelling a legacy run whose jobs are all already done
run, err := GetRunByRepoAndID(ctx, repoID, runID)
if err != nil {
return err
}
jobs, err := GetLatestAttemptJobsByRepoAndRunID(ctx, repoID, runID)
if err != nil {
return err
}
run.Status = AggregateJobStatus(jobs)
if len(jobs) == 0 {
run.Status = noJobsStatus
}
if run.Started.IsZero() && run.Status.IsRunning() {
run.Started = timeutil.TimeStampNow()
}
if run.Stopped.IsZero() && run.Status.IsDone() {
run.Stopped = timeutil.TimeStampNow()
}
if err := UpdateRun(ctx, run, "status", "started", "stopped"); err != nil {
return fmt.Errorf("update run %d: %w", run.ID, err)
}
return nil
}
// RefreshReusableCallerStatus recomputes a reusable workflow caller's Status, Started and Stopped from its current direct children and persists the change. // RefreshReusableCallerStatus recomputes a reusable workflow caller's Status, Started and Stopped from its current direct children and persists the change.
// No-op if caller is not a reusable caller. // No-op if caller is not a reusable caller.
// //
@@ -652,6 +676,8 @@ func CancelPreviousJobsByJobConcurrency(ctx context.Context, job *ActionRunJob)
return CancelJobs(ctx, jobsToCancel) return CancelJobs(ctx, jobsToCancel)
} }
// CancelJobs cancels every cancellable job it is given. It leaves the status of a run it
// cancelled nothing in untouched, SettleRunAfterCancel is what gives such a run a final one.
func CancelJobs(ctx context.Context, jobs []*ActionRunJob) ([]*ActionRunJob, error) { func CancelJobs(ctx context.Context, jobs []*ActionRunJob) ([]*ActionRunJob, error) {
cancelledJobs := make([]*ActionRunJob, 0, len(jobs)) cancelledJobs := make([]*ActionRunJob, 0, len(jobs))
@@ -676,6 +702,16 @@ func CancelJobs(ctx context.Context, jobs []*ActionRunJob) ([]*ActionRunJob, err
return cancelledJobs, nil return cancelledJobs, nil
} }
// SettleRunAfterCancel gives a run a final status when cancelling it updated no job at all.
// A run's status is otherwise only ever written as a side effect of a job update, so a run whose
// jobs are all done already, or that has no job at all, would stay unfinished forever.
func SettleRunAfterCancel(ctx context.Context, run *ActionRun) error {
if run.Status.IsDone() {
return nil
}
return refreshRunStatus(ctx, run.RepoID, run.ID, run.LatestAttemptID, StatusCancelled)
}
// cancelOneJob cancels a single job and returns the post-cancel row // cancelOneJob cancels a single job and returns the post-cancel row
func cancelOneJob(ctx context.Context, job *ActionRunJob) (*ActionRunJob, error) { func cancelOneJob(ctx context.Context, job *ActionRunJob) (*ActionRunJob, error) {
if job.Status.IsDone() { if job.Status.IsDone() {

View File

@@ -159,3 +159,12 @@ func (opts FindRunJobOptions) ToOrders() string {
} }
var _ db.FindOptionsOrder = FindRunJobOptions{} 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),
})
}

View File

@@ -8,6 +8,7 @@ import (
"gitea.dev/models/db" "gitea.dev/models/db"
"gitea.dev/models/unittest" "gitea.dev/models/unittest"
"gitea.dev/modules/timeutil"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -197,3 +198,91 @@ func TestCancelJobs_NestedBlockedReusableCaller(t *testing.T) {
gotRun := unittest.AssertExistsAndLoadBean(t, &ActionRun{ID: run.ID}) gotRun := unittest.AssertExistsAndLoadBean(t, &ActionRun{ID: run.ID})
assert.Equal(t, StatusCancelled, gotRun.Status, "run must aggregate to Cancelled, not stay Blocked") assert.Equal(t, StatusCancelled, gotRun.Status, "run must aggregate to Cancelled, not stay Blocked")
} }
func TestSettleRunAfterCancel(t *testing.T) {
// A run that cancelling updates no job in, because its jobs all reached a final status already
// or because it has none at all. Its own row has to be settled explicitly, or the run can never
// finish and can never be deleted either.
newStuckRun := func(t *testing.T, withAttempt, withJob bool) (*ActionRun, []*ActionRunJob) {
t.Helper()
ctx := t.Context()
run := &ActionRun{
Title: "stuck-waiting",
RepoID: 4,
Index: 9801,
OwnerID: 1,
WorkflowID: "test.yaml",
TriggerUserID: 1,
Ref: "refs/heads/master",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
EventPayload: "{}",
Status: StatusWaiting,
}
require.NoError(t, db.Insert(ctx, run))
var runAttemptID int64
if withAttempt {
attempt := &ActionRunAttempt{RepoID: run.RepoID, RunID: run.ID, Attempt: 1, TriggerUserID: 1, Status: StatusWaiting}
require.NoError(t, db.Insert(ctx, attempt))
run.LatestAttemptID = attempt.ID
require.NoError(t, UpdateRun(ctx, run, "latest_attempt_id"))
runAttemptID = attempt.ID
}
if !withJob {
return run, nil
}
job := &ActionRunJob{
RunID: run.ID,
RunAttemptID: runAttemptID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
Name: "job1",
JobID: "job1",
Attempt: 1,
Status: StatusSuccess,
Stopped: timeutil.TimeStampNow(),
}
require.NoError(t, db.Insert(ctx, job))
return run, []*ActionRunJob{job}
}
cases := []struct {
name string
withAttempt bool
withJob bool
want Status
}{
{"done job", true, true, StatusSuccess},
// Runs created before migration v331 have no attempt, their status lives on the run row itself.
{"done job on a legacy run without attempt", false, true, StatusSuccess},
// Aggregation cannot reach a final status without any job, so cancelling has to end the run itself.
{"no job at all", true, false, StatusCancelled},
{"no job at all on a legacy run without attempt", false, false, StatusCancelled},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
run, jobs := newStuckRun(t, tc.withAttempt, tc.withJob)
// mirrors what the CancelRun service does
cancelled, err := CancelJobs(t.Context(), jobs)
require.NoError(t, err)
assert.Empty(t, cancelled, "nothing is cancellable, so the run row has to be settled explicitly")
require.NoError(t, SettleRunAfterCancel(t.Context(), run))
if tc.withAttempt {
gotAttempt := unittest.AssertExistsAndLoadBean(t, &ActionRunAttempt{ID: run.LatestAttemptID})
assert.Equal(t, tc.want, gotAttempt.Status)
}
gotRun := unittest.AssertExistsAndLoadBean(t, &ActionRun{ID: run.ID})
assert.Equal(t, tc.want, gotRun.Status)
assert.NotZero(t, gotRun.Stopped)
})
}
}

View File

@@ -270,15 +270,31 @@ func (opts FindRunnerOptions) ToConds() builder.Cond {
return 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 { func (opts FindRunnerOptions) ToOrders() string {
// A unique tiebreaker (id) is appended so that runners sharing the same // A unique tiebreaker (id) is appended so that runners sharing the same
// last_online or name keep a deterministic order across paginated queries, // status, last_online or name keep a deterministic order across paginated
// otherwise the same runner may appear on more than one page. // queries, otherwise the same runner may appear on more than one page.
statusRank := runnerStatusOrderExpr()
switch opts.Sort { switch opts.Sort {
case "online": 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": case "offline":
return "last_online ASC, id ASC" return statusRank + " DESC, is_disabled ASC, last_online ASC, id ASC"
case "alphabetically": case "alphabetically":
return "name ASC, id ASC" return "name ASC, id ASC"
case "reversealphabetically": case "reversealphabetically":
@@ -288,7 +304,7 @@ func (opts FindRunnerOptions) ToOrders() string {
case "oldest": case "oldest":
return "id ASC" 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 // GetRunnerByUUID returns a runner via uuid

View File

@@ -4,16 +4,12 @@
package actions package actions
import ( import (
"fmt"
"testing" "testing"
"time" "time"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
"gitea.dev/modules/timeutil" "gitea.dev/modules/timeutil"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestShouldPersistLastOnline(t *testing.T) { 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)
}
}

View File

@@ -60,6 +60,12 @@ type ActionTask struct {
Updated timeutil.TimeStamp `xorm:"updated index"` 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] var successfulTokenTaskCache *lru.Cache[string, any]
func init() { func init() {
@@ -85,11 +91,15 @@ func (task *ActionTask) IsStopped() bool {
return task.Stopped > 0 return task.Stopped > 0
} }
func (task *ActionTask) GetRunLink() string { func (task *ActionTask) GetRunJobLink() string {
if task.Job == nil || task.Job.Run == nil { // 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 ""
} }
return task.Job.Run.Link() return fmt.Sprintf("%s/jobs/%d", task.Job.Run.Link(), task.Job.ID)
} }
func (task *ActionTask) GetCommitLink() string { func (task *ActionTask) GetCommitLink() string {
@@ -468,7 +478,7 @@ func UpdateTaskByState(ctx context.Context, runnerID int64, state *runnerv1.Task
return nil, err return nil, err
} }
task := &ActionTask{} task := &ActionTask{}
err = globallock.LockAndDo(ctx, fmt.Sprintf("UpdateTaskByState-run-%d", runID), func(ctx context.Context) error { applyState := func(ctx context.Context) error {
if has, err := db.GetEngine(ctx).ID(taskID).Get(task); err != nil { if has, err := db.GetEngine(ctx).ID(taskID).Get(task); err != nil {
return err return err
} else if !has { } else if !has {
@@ -533,6 +543,10 @@ func UpdateTaskByState(ctx context.Context, runnerID int64, state *runnerv1.Task
} }
} }
return nil return nil
}
err = globallock.LockAndDo(ctx, fmt.Sprintf("UpdateTaskByState-run-%d", runID), func(ctx context.Context) error {
// A half-written report leaves the task done with a running job, which no retry repairs.
return db.WithTx(ctx, applyState)
}) })
return task, err return task, err
} }
@@ -563,6 +577,10 @@ func StopTask(ctx context.Context, taskID int64, status Status) error {
status = StatusCancelled status = StatusCancelled
} else if !runner.HasCancellingSupport { } else if !runner.HasCancellingSupport {
status = StatusCancelled 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 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 task.Status = status

View File

@@ -4,11 +4,15 @@
package actions package actions
import ( import (
"context"
"errors"
"strings" "strings"
"sync/atomic"
"testing" "testing"
runnerv1 "gitea.dev/actions-proto-go/runner/v1" runnerv1 "gitea.dev/actions-proto-go/runner/v1"
"gitea.dev/models/db" "gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest" "gitea.dev/models/unittest"
"gitea.dev/modules/actions/jobparser" "gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/timeutil" "gitea.dev/modules/timeutil"
@@ -16,8 +20,24 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
"xorm.io/xorm/contexts"
) )
func TestActionTask_GetRunJobLink(t *testing.T) {
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) { func TestMakeTaskStepDisplayName(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -83,68 +103,11 @@ func TestMakeTaskStepDisplayName(t *testing.T) {
} }
func TestTaskCancellingFinalizesToCancelled(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) { testResult := func(t *testing.T, result runnerv1.Result) {
t.Helper() t.Helper()
require.NoError(t, unittest.PrepareTestDatabase()) 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)) require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
taskAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID}) taskAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
@@ -174,137 +137,92 @@ func TestTaskCancellingFinalizesToCancelled(t *testing.T) {
}) })
} }
func TestStopTaskCancellingFallsBackForLegacyRunner(t *testing.T) { // TestStopTaskCancellingFallsBackToCancelled covers the cases where the cancelling handshake can
require.NoError(t, unittest.PrepareTestDatabase()) // 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{ taskAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
Title: "cancelling-test-run", assert.Equal(t, StatusCancelled, taskAfterStop.Status)
RepoID: 1, assert.NotZero(t, taskAfterStop.Stopped)
OwnerID: 2,
WorkflowID: "test.yaml", jobAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
Index: 999, assert.Equal(t, StatusCancelled, jobAfterStop.Status)
TriggerUserID: 2, assert.NotZero(t, jobAfterStop.Stopped)
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{ // A runner too old to know the cancelling state can only be stopped by a final status.
RunID: run.ID, t.Run("legacy runner", func(t *testing.T) {
RepoID: run.RepoID, require.NoError(t, unittest.PrepareTestDatabase())
OwnerID: run.OwnerID, task, job := newRunningTaskForCancelling(t, "legacy-cancelling-job", false)
CommitSHA: run.CommitSHA,
Name: "legacy-cancelling-job",
Attempt: 1,
JobID: "legacy-cancelling-job",
Status: StatusRunning,
}
require.NoError(t, db.Insert(t.Context(), job))
runner := &ActionRunner{ require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
UUID: "runner-legacy-no-cancelling", assertCancelled(t, task, job)
Name: "runner-legacy-no-cancelling", })
HasCancellingSupport: false,
}
require.NoError(t, db.Insert(t.Context(), runner))
task := &ActionTask{ // The runner is gone, e.g. an ephemeral runner was cleaned up, so nobody can acknowledge.
JobID: job.ID, t.Run("missing runner", func(t *testing.T) {
Attempt: 1, require.NoError(t, unittest.PrepareTestDatabase())
RunnerID: runner.ID, task, job := newRunningTaskForCancelling(t, "missing-runner-cancelling-job", true)
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 := db.DeleteByID[ActionRunner](t.Context(), task.RunnerID)
_, err := UpdateRunJob(t.Context(), job, nil, "task_id") require.NoError(t, err)
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}) // The runner went silent, e.g. it gave up while Gitea was restarting, so it never picks up the request.
assert.Equal(t, StatusCancelled, taskAfterStop.Status) t.Run("silent runner", func(t *testing.T) {
assert.NotZero(t, taskAfterStop.Stopped) require.NoError(t, unittest.PrepareTestDatabase())
task, job := newRunningTaskForCancelling(t, "silent-runner-cancelling-job", true)
jobAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID}) // NoAutoTime because the point of the test is an "updated" older than xorm would write
assert.Equal(t, StatusCancelled, jobAfterStop.Status) task.Updated = timeutil.TimeStampNow().AddDuration(-2 * taskReportTimeout)
assert.NotZero(t, jobAfterStop.Stopped) _, 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()) require.NoError(t, unittest.PrepareTestDatabase())
task, _ := newRunningTaskForCancelling(t, "repeated-cancelling-job", true)
run := &ActionRun{ // NoAutoTime because the point of the test is an "updated" older than xorm would write
Title: "cancelling-test-run", lastReport := timeutil.TimeStampNow().AddDuration(-taskReportTimeout / 2)
RepoID: 1, task.Updated = lastReport
OwnerID: 2, _, err := db.GetEngine(t.Context()).ID(task.ID).Cols("updated").NoAutoTime().Update(task)
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)
require.NoError(t, err) 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)) require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
taskAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID}) taskAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
assert.Equal(t, StatusCancelled, taskAfterStop.Status) assert.Equal(t, StatusCancelling, taskAfterStop.Status)
assert.NotZero(t, taskAfterStop.Stopped) assert.Equal(t, lastReport, taskAfterStop.Updated)
jobAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID}) // cancelling it again does not reset the clock either
assert.Equal(t, StatusCancelled, jobAfterStop.Status) require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
assert.NotZero(t, jobAfterStop.Stopped) 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 // 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, StatusRunning, claimed.Status)
assert.Equal(t, task.ID, claimed.TaskID) assert.Equal(t, task.ID, claimed.TaskID)
} }
type failFirstStepWrite struct{ fired atomic.Bool }
func (h *failFirstStepWrite) BeforeProcess(c *contexts.ContextHook) (context.Context, error) {
if !h.fired.Load() && strings.HasPrefix(c.SQL, "UPDATE") && strings.Contains(c.SQL, "action_task_step") {
h.fired.Store(true)
return nil, errors.New("interrupted")
}
return c.Ctx, nil
}
func (*failFirstStepWrite) AfterProcess(*contexts.ContextHook) error { return nil }
// TestUpdateTaskByStateIsAtomic checks that an interrupted report writes nothing: a surviving task or
// job write would hit the "state is final" early return, which no retry or cleanup repairs.
func TestUpdateTaskByStateIsAtomic(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
task, job := newRunningTaskForCancelling(t, "atomic-report-job", true)
require.NoError(t, db.Insert(t.Context(), &ActionTaskStep{TaskID: task.ID, RepoID: task.RepoID, Status: StatusRunning}))
unittest.GetXORMEngine().AddHook(&failFirstStepWrite{})
finalState := &runnerv1.TaskState{Id: task.ID, Result: runnerv1.Result_RESULT_SUCCESS, StoppedAt: timestamppb.Now()}
_, err := UpdateTaskByState(t.Context(), task.RunnerID, finalState)
require.Error(t, err)
assert.Equal(t, StatusRunning, unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID}).Status)
assert.Equal(t, StatusRunning, unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID}).Status)
_, err = UpdateTaskByState(t.Context(), task.RunnerID, finalState)
require.NoError(t, err)
assert.Equal(t, StatusSuccess, unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID}).Status)
}
// newRunningTaskForCancelling inserts a running run/job/task assigned to a fresh runner,
// 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
}

View File

@@ -507,7 +507,7 @@ func updateApprovalWhitelist(ctx context.Context, repo *repo_model.Repository, c
return currentWhitelist, nil 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 { if err != nil {
return nil, err return nil, err
} }

View File

@@ -7,6 +7,7 @@ import (
"context" "context"
"gitea.dev/models/user" "gitea.dev/models/user"
"gitea.dev/modules/container"
"gitea.dev/modules/git" "gitea.dev/modules/git"
"gitea.dev/modules/log" "gitea.dev/modules/log"
) )
@@ -33,11 +34,18 @@ func BuildAvatarStackData(ctx context.Context, allParticipants []*git.CommitIden
ret := &AvatarStackData{ ret := &AvatarStackData{
Participants: make([]*CommitParticipant, 0, len(allParticipants)), Participants: make([]*CommitParticipant, 0, len(allParticipants)),
} }
uniqueUserIDs := make(container.Set[int64])
for _, p := range allParticipants { for _, p := range allParticipants {
var giteaUser *user.User var giteaUser *user.User
if emailUserMap != nil { if emailUserMap != nil {
giteaUser = emailUserMap.GetByEmail(p.Email) giteaUser = emailUserMap.GetByEmail(p.Email)
} }
if giteaUser != nil {
// identities without a Gitea account can only be compared by their git identity
if !uniqueUserIDs.Add(giteaUser.ID) {
continue
}
}
ret.Participants = append(ret.Participants, &CommitParticipant{GiteaUser: giteaUser, GitIdentity: p}) ret.Participants = append(ret.Participants, &CommitParticipant{GiteaUser: giteaUser, GitIdentity: p})
} }
return ret return ret

View File

@@ -39,8 +39,7 @@ func GetUserCommitsByGitCommits(ctx context.Context, gitCommits []*git.Commit, r
emailSet := make(container.Set[string]) emailSet := make(container.Set[string])
for _, c := range gitCommits { for _, c := range gitCommits {
emailSet.Add(c.Author.Email) emailSet.Add(c.Author.Email)
emailSet.Add(c.Committer.Email) for _, p := range c.AllAuthorIdentities() {
for _, p := range c.AllParticipantIdentities() {
emailSet.Add(p.Email) emailSet.Add(p.Email)
} }
} }
@@ -55,7 +54,7 @@ func GetUserCommitsByGitCommits(ctx context.Context, gitCommits []*git.Commit, r
uc := &UserCommit{ uc := &UserCommit{
AuthorUser: emailUserMap.GetByEmail(c.Author.Email), // FIXME: why GetUserCommitsByGitCommits uses "Author", but ParseCommitsWithSignature uses "Committer"? AuthorUser: emailUserMap.GetByEmail(c.Author.Email), // FIXME: why GetUserCommitsByGitCommits uses "Author", but ParseCommitsWithSignature uses "Committer"?
GitCommit: c, GitCommit: c,
AvatarStackData: BuildAvatarStackData(ctx, c.AllParticipantIdentities(), emailUserMap), AvatarStackData: BuildAvatarStackData(ctx, c.AllAuthorIdentities(), emailUserMap),
} }
uc.AvatarStackData.SearchByEmailLink = searchByEmailLink uc.AvatarStackData.SearchByEmailLink = searchByEmailLink
userCommits = append(userCommits, uc) userCommits = append(userCommits, uc)

View File

@@ -544,6 +544,9 @@ func (c *Comment) GetSanitizedContentHTML() template.HTML {
// LoadLabel if comment.Type is CommentTypeLabel, then load Label // LoadLabel if comment.Type is CommentTypeLabel, then load Label
func (c *Comment) LoadLabel(ctx context.Context) error { func (c *Comment) LoadLabel(ctx context.Context) error {
if c.LabelID == 0 {
return nil
}
var label Label var label Label
has, err := db.GetEngine(ctx).ID(c.LabelID).Get(&label) has, err := db.GetEngine(ctx).ID(c.LabelID).Get(&label)
if err != nil { if err != nil {
@@ -551,8 +554,8 @@ func (c *Comment) LoadLabel(ctx context.Context) error {
} else if has { } else if has {
c.Label = &label c.Label = &label
} else { } else {
// Ignore Label is deleted, but not clear this table // label was deleted but comment rows referencing it were not cleaned up
log.Warn("Commit %d cannot load label %d", c.ID, c.LabelID) log.Debug("Comment %d references deleted label %d", c.ID, c.LabelID)
} }
return nil return nil

View File

@@ -81,7 +81,7 @@ func (comments CommentList) loadLabels(ctx context.Context) error {
} }
for _, comment := range comments { for _, comment := range comments {
comment.Label = commentLabels[comment.ID] comment.Label = commentLabels[comment.LabelID]
} }
return nil return nil
} }

View File

@@ -599,8 +599,8 @@ func HasAnyUnitAccess(ctx context.Context, userID int64, repo *repo_model.Reposi
return perm.HasAnyUnitAccess(), nil 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) { 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 := GetUserIDsWithUnitAccess(ctx, repo, mode, unitType) userIDs, err := GetUserIDsWithAnyUnitAccess(ctx, repo, mode, unitType, moreUnitTypes...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -613,7 +613,7 @@ func GetUsersWithUnitAccess(ctx context.Context, repo *repo_model.Repository, mo
return users, nil 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]{} userIDs := container.Set[int64]{}
e := db.GetEngine(ctx) e := db.GetEngine(ctx)
accesses := make([]*Access, 0, 10) accesses := make([]*Access, 0, 10)
@@ -630,7 +630,7 @@ func GetUserIDsWithUnitAccess(ctx context.Context, repo *repo_model.Repository,
if !repo.Owner.IsOrganization() { if !repo.Owner.IsOrganization() {
userIDs.Add(repo.Owner.ID) userIDs.Add(repo.Owner.ID)
} else { } 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 { if err != nil {
return nil, err return nil, err
} }

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.AccessModeNone, perm.unitsMode[unit.TypeCode])
assert.Equal(t, perm_model.AccessModeRead, perm.unitsMode[unit.TypeIssues]) 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.NoError(t, err)
require.Len(t, users, 1) require.Len(t, users, 1)
assert.Equal(t, user.ID, users[0].ID) 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.NoError(t, err)
require.Empty(t, users) 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.TypeCode])
assert.Equal(t, perm_model.AccessModeWrite, perm.unitsMode[unit.TypeIssues]) 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.NoError(t, err)
require.Len(t, users, 1) require.Len(t, users, 1)
assert.Equal(t, user.ID, users[0].ID) assert.Equal(t, user.ID, users[0].ID)

View File

@@ -7,41 +7,45 @@ import (
"context" "context"
"io" "io"
repo_model "gitea.dev/models/repo"
"gitea.dev/modules/git" "gitea.dev/modules/git"
"gitea.dev/modules/gitrepo" "gitea.dev/modules/gitrepo"
"gitea.dev/modules/log" "gitea.dev/modules/log"
) )
type commitChecker struct { type commitChecker struct {
ctx context.Context ctx context.Context
commitCache map[string]bool commitCache map[string]bool
gitRepoFacade gitrepo.Repository repoOptional *repo_model.Repository
gitRepo *git.Repository gitRepo *git.Repository
gitRepoCloser io.Closer gitRepoCloser io.Closer
} }
func newCommitChecker(ctx context.Context, gitRepo gitrepo.Repository) *commitChecker { func newCommitChecker(ctx context.Context, repo *repo_model.Repository) *commitChecker {
return &commitChecker{ctx: ctx, commitCache: make(map[string]bool), gitRepoFacade: gitRepo} return &commitChecker{ctx: ctx, commitCache: make(map[string]bool), repoOptional: repo}
} }
func (c *commitChecker) Close() error { func (c *commitChecker) Close() error {
if c != nil && c.gitRepoCloser != nil { if c.gitRepoCloser != nil {
return c.gitRepoCloser.Close() return c.gitRepoCloser.Close()
} }
return nil return nil
} }
func (c *commitChecker) IsCommitIDExisting(commitID string) bool { func (c *commitChecker) IsCommitIDExisting(commitID string) bool {
if c.repoOptional == nil {
return false
}
exist, inCache := c.commitCache[commitID] exist, inCache := c.commitCache[commitID]
if inCache { if inCache {
return exist return exist
} }
if c.gitRepo == nil { if c.gitRepo == nil {
r, closer, err := gitrepo.RepositoryFromContextOrOpen(c.ctx, c.gitRepoFacade) r, closer, err := gitrepo.RepositoryFromContextOrOpen(c.ctx, c.repoOptional)
if err != nil { if err != nil {
log.Error("unable to open repository: %s Error: %v", gitrepo.RepoGitURL(c.gitRepoFacade), err) log.Error("unable to open repository: %s Error: %v", gitrepo.RepoGitURL(c.repoOptional), err)
return false return false
} }
c.gitRepo, c.gitRepoCloser = r, closer c.gitRepo, c.gitRepoCloser = r, closer

View File

@@ -51,10 +51,10 @@ func NewRenderContextRepoComment(ctx context.Context, repo *repo_model.Repositor
helper := &RepoComment{opts: util.OptionalArg(opts)} helper := &RepoComment{opts: util.OptionalArg(opts)}
rctx := markup.NewRenderContext(ctx) rctx := markup.NewRenderContext(ctx)
helper.ctx = rctx helper.ctx = rctx
helper.commitChecker = newCommitChecker(ctx, repo)
var metas map[string]string var metas map[string]string
if repo != nil { if repo != nil {
helper.repoLink = repo.Link() helper.repoLink = repo.Link()
helper.commitChecker = newCommitChecker(ctx, repo)
metas = repo.ComposeCommentMetas(ctx) metas = repo.ComposeCommentMetas(ctx)
} else { } else {
// repo can be nil when rendering a commit message in user's dashboard feedback whose repository has been deleted // repo can be nil when rendering a commit message in user's dashboard feedback whose repository has been deleted

View File

@@ -35,11 +35,11 @@ func (r *RepoFile) ResolveLink(link, preferLinkType string) (finalLink string) {
case markup.LinkTypeRoot: case markup.LinkTypeRoot:
finalLink = r.ctx.ResolveLinkRoot(link) finalLink = r.ctx.ResolveLinkRoot(link)
case markup.LinkTypeRaw: case markup.LinkTypeRaw:
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "raw", r.opts.CurrentRefSubURL), r.opts.CurrentTreePath, link) finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "raw", r.opts.CurrentRefSubURL), util.PathEscapeSegments(r.opts.CurrentTreePath), link)
case markup.LinkTypeMedia: case markup.LinkTypeMedia:
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "media", r.opts.CurrentRefSubURL), r.opts.CurrentTreePath, link) finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "media", r.opts.CurrentRefSubURL), util.PathEscapeSegments(r.opts.CurrentTreePath), link)
default: default:
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "src", r.opts.CurrentRefSubURL), r.opts.CurrentTreePath, link) finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "src", r.opts.CurrentRefSubURL), util.PathEscapeSegments(r.opts.CurrentTreePath), link)
} }
return finalLink return finalLink
} }
@@ -58,9 +58,9 @@ func NewRenderContextRepoFile(ctx context.Context, repo *repo_model.Repository,
helper := &RepoFile{opts: util.OptionalArg(opts)} helper := &RepoFile{opts: util.OptionalArg(opts)}
rctx := markup.NewRenderContext(ctx) rctx := markup.NewRenderContext(ctx)
helper.ctx = rctx helper.ctx = rctx
helper.commitChecker = newCommitChecker(ctx, repo)
if repo != nil { if repo != nil {
helper.repoLink = repo.Link() helper.repoLink = repo.Link()
helper.commitChecker = newCommitChecker(ctx, repo)
rctx = rctx.WithMetas(repo.ComposeRepoFileMetas(ctx)) rctx = rctx.WithMetas(repo.ComposeRepoFileMetas(ctx))
} else { } else {
// this is almost dead code, only to pass the incorrect tests // this is almost dead code, only to pass the incorrect tests

View File

@@ -68,7 +68,7 @@ func TestRepoFile(t *testing.T) {
t.Run("WithCurrentRefSubURLByTag", func(t *testing.T) { t.Run("WithCurrentRefSubURLByTag", func(t *testing.T) {
rctx := NewRenderContextRepoFile(t.Context(), repo1, RepoFileOptions{ rctx := NewRenderContextRepoFile(t.Context(), repo1, RepoFileOptions{
CurrentRefSubURL: "/commit/1234", CurrentRefSubURL: "/commit/1234",
CurrentTreePath: "my-dir", CurrentTreePath: "my dir",
}). }).
WithMarkupType(markdown.MarkupName) WithMarkupType(markdown.MarkupName)
rendered, err := testRenderString(rctx, ` rendered, err := testRenderString(rctx, `
@@ -76,8 +76,8 @@ func TestRepoFile(t *testing.T) {
<video src="LINK"> <video src="LINK">
`) `)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, `<a href="/user2/repo1/src/commit/1234/my-dir/LINK" target="_blank" rel="nofollow noopener"><img src="/user2/repo1/media/commit/1234/my-dir/LINK"/></a> assert.Equal(t, `<a href="/user2/repo1/src/commit/1234/my%20dir/LINK" target="_blank" rel="nofollow noopener"><img src="/user2/repo1/media/commit/1234/my%20dir/LINK"/></a>
<video src="/user2/repo1/media/commit/1234/my-dir/LINK"> <video src="/user2/repo1/media/commit/1234/my%20dir/LINK">
</video>`, rendered) </video>`, rendered)
}) })
} }
@@ -89,8 +89,8 @@ func TestRepoFileOrgMode(t *testing.T) {
t.Run("Links", func(t *testing.T) { t.Run("Links", func(t *testing.T) {
rctx := NewRenderContextRepoFile(t.Context(), repo1, RepoFileOptions{ rctx := NewRenderContextRepoFile(t.Context(), repo1, RepoFileOptions{
CurrentRefSubURL: "/commit/1234", CurrentRefSubURL: "/commit/1234",
CurrentTreePath: "my-dir", CurrentTreePath: "my dir",
}).WithRelativePath("my-dir/a.org") }).WithRelativePath("my dir/a.org")
rendered, err := testRenderString(rctx, ` rendered, err := testRenderString(rctx, `
[[https://google.com/]] [[https://google.com/]]
@@ -99,7 +99,7 @@ func TestRepoFileOrgMode(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, `<p> assert.Equal(t, `<p>
<a href="https://google.com/" rel="nofollow">https://google.com/</a> <a href="https://google.com/" rel="nofollow">https://google.com/</a>
<a href="/user2/repo1/src/commit/1234/my-dir/ImageLink.svg" rel="nofollow">The Image Desc</a></p> <a href="/user2/repo1/src/commit/1234/my%20dir/ImageLink.svg" rel="nofollow">The Image Desc</a></p>
`, rendered) `, rendered)
}) })

View File

@@ -36,9 +36,9 @@ func (r *RepoWiki) ResolveLink(link, preferLinkType string) (finalLink string) {
case markup.LinkTypeRoot: case markup.LinkTypeRoot:
finalLink = r.ctx.ResolveLinkRoot(link) finalLink = r.ctx.ResolveLinkRoot(link)
case markup.LinkTypeMedia, markup.LinkTypeRaw: case markup.LinkTypeMedia, markup.LinkTypeRaw:
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "wiki/raw", r.opts.currentRefSubURL), r.opts.currentTreePath, link) finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "wiki/raw", r.opts.currentRefSubURL), util.PathEscapeSegments(r.opts.currentTreePath), link)
default: default:
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "wiki", r.opts.currentRefSubURL), r.opts.currentTreePath, link) finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "wiki", r.opts.currentRefSubURL), util.PathEscapeSegments(r.opts.currentTreePath), link)
} }
return finalLink return finalLink
} }
@@ -57,9 +57,9 @@ type RepoWikiOptions struct {
func NewRenderContextRepoWiki(ctx context.Context, repo *repo_model.Repository, opts ...RepoWikiOptions) *markup.RenderContext { func NewRenderContextRepoWiki(ctx context.Context, repo *repo_model.Repository, opts ...RepoWikiOptions) *markup.RenderContext {
helper := &RepoWiki{opts: util.OptionalArg(opts)} helper := &RepoWiki{opts: util.OptionalArg(opts)}
rctx := markup.NewRenderContext(ctx).WithMarkupType(markdown.MarkupName) rctx := markup.NewRenderContext(ctx).WithMarkupType(markdown.MarkupName)
helper.commitChecker = newCommitChecker(ctx, repo)
if repo != nil { if repo != nil {
helper.repoLink = repo.Link() helper.repoLink = repo.Link()
helper.commitChecker = newCommitChecker(ctx, repo)
rctx = rctx.WithMetas(repo.ComposeWikiMetas(ctx)) rctx = rctx.WithMetas(repo.ComposeWikiMetas(ctx))
} else { } else {
// this is almost dead code, only to pass the incorrect tests // this is almost dead code, only to pass the incorrect tests

View File

@@ -50,14 +50,16 @@ func TestRepoWiki(t *testing.T) {
}) })
t.Run("PathInTag", func(t *testing.T) { t.Run("PathInTag", func(t *testing.T) {
rctx := NewRenderContextRepoWiki(t.Context(), repo1).WithMarkupType(markdown.MarkupName) rctx := NewRenderContextRepoWiki(t.Context(), repo1, RepoWikiOptions{
currentTreePath: "my dir",
}).WithMarkupType(markdown.MarkupName)
rendered, err := testRenderString(rctx, ` rendered, err := testRenderString(rctx, `
<img src="LINK"> <img src="LINK">
<video src="LINK"> <video src="LINK">
`) `)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, `<a href="/user2/repo1/wiki/LINK" target="_blank" rel="nofollow noopener"><img src="/user2/repo1/wiki/raw/LINK"/></a> assert.Equal(t, `<a href="/user2/repo1/wiki/my%20dir/LINK" target="_blank" rel="nofollow noopener"><img src="/user2/repo1/wiki/raw/my%20dir/LINK"/></a>
<video src="/user2/repo1/wiki/raw/LINK"> <video src="/user2/repo1/wiki/raw/my%20dir/LINK">
</video>`, rendered) </video>`, rendered)
}) })
} }

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}) return db.Exist[Collaboration](ctx, builder.Eq{"repo_id": repoID, "user_id": userID})
} }
// ChangeCollaborationAccessMode sets new access mode for the collaboration.
func ChangeCollaborationAccessMode(ctx context.Context, repo *Repository, uid int64, mode perm.AccessMode) error {
// Discard invalid input
if mode <= perm.AccessModeNone || mode > perm.AccessModeOwner {
return nil
}
return db.WithTx(ctx, func(ctx context.Context) error {
collaboration, has, err := db.Get[Collaboration](ctx, builder.Eq{"repo_id": repo.ID, "user_id": uid})
if err != nil {
return fmt.Errorf("get collaboration: %w", err)
} else if !has {
return nil
}
if collaboration.Mode == mode {
return nil
}
collaboration.Mode = mode
if _, err = db.GetEngine(ctx).
ID(collaboration.ID).
Cols("mode").
Update(collaboration); err != nil {
return fmt.Errorf("update collaboration: %w", err)
} else if _, err = db.Exec(ctx, "UPDATE access SET mode = ? WHERE user_id = ? AND repo_id = ?", mode, uid, repo.ID); err != nil {
return fmt.Errorf("update access table: %w", err)
}
return nil
})
}
// IsOwnerMemberCollaborator checks if a provided user is the owner, a collaborator or a member of a team in a repository // IsOwnerMemberCollaborator checks if a provided user is the owner, a collaborator or a member of a team in a repository
func IsOwnerMemberCollaborator(ctx context.Context, repo *Repository, userID int64) (bool, error) { func IsOwnerMemberCollaborator(ctx context.Context, repo *Repository, userID int64) (bool, error) {
if repo.OwnerID == userID { if repo.OwnerID == userID {

View File

@@ -7,8 +7,6 @@ import (
"testing" "testing"
"gitea.dev/models/db" "gitea.dev/models/db"
"gitea.dev/models/perm"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest" "gitea.dev/models/unittest"
@@ -69,28 +67,6 @@ func TestRepository_IsCollaborator(t *testing.T) {
test(4, 4, true) test(4, 4, true)
} }
func TestRepository_ChangeCollaborationAccessMode(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
assert.NoError(t, repo_model.ChangeCollaborationAccessMode(t.Context(), repo, 4, perm.AccessModeAdmin))
collaboration := unittest.AssertExistsAndLoadBean(t, &repo_model.Collaboration{RepoID: repo.ID, UserID: 4})
assert.Equal(t, perm.AccessModeAdmin, collaboration.Mode)
access := unittest.AssertExistsAndLoadBean(t, &access_model.Access{UserID: 4, RepoID: repo.ID})
assert.Equal(t, perm.AccessModeAdmin, access.Mode)
assert.NoError(t, repo_model.ChangeCollaborationAccessMode(t.Context(), repo, 4, perm.AccessModeAdmin))
assert.NoError(t, repo_model.ChangeCollaborationAccessMode(t.Context(), repo, unittest.NonexistentID, perm.AccessModeAdmin))
// Discard invalid input.
assert.NoError(t, repo_model.ChangeCollaborationAccessMode(t.Context(), repo, 4, perm.AccessMode(-1)))
unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repo.ID})
}
func TestRepository_IsOwnerMemberCollaborator(t *testing.T) { func TestRepository_IsOwnerMemberCollaborator(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())

View File

@@ -6,7 +6,10 @@ package jobparser
import ( import (
"errors" "errors"
"fmt" "fmt"
"math"
"reflect"
"regexp" "regexp"
"strconv"
"strings" "strings"
"gitea.com/gitea/runner/act/exprparser" "gitea.com/gitea/runner/act/exprparser"
@@ -23,12 +26,6 @@ func NewExpressionEvaluator(interpreter exprparser.Interpreter) *ExpressionEvalu
return &ExpressionEvaluator{interpreter: interpreter} return &ExpressionEvaluator{interpreter: interpreter}
} }
func (ee ExpressionEvaluator) evaluate(in string, defaultStatusCheck exprparser.DefaultStatusCheck) (any, error) {
evaluated, err := ee.interpreter.Evaluate(in, defaultStatusCheck)
return evaluated, err
}
func (ee ExpressionEvaluator) evaluateScalarYamlNode(node *yaml.Node) error { func (ee ExpressionEvaluator) evaluateScalarYamlNode(node *yaml.Node) error {
var in string var in string
if err := node.Decode(&in); err != nil { if err := node.Decode(&in); err != nil {
@@ -37,17 +34,17 @@ func (ee ExpressionEvaluator) evaluateScalarYamlNode(node *yaml.Node) error {
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") { if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
return nil return nil
} }
expr, _ := rewriteSubExpression(in, false) res, err := ee.evaluateScalar(in)
res, err := ee.evaluate(expr, exprparser.DefaultStatusCheckNone)
if err != nil { if err != nil {
return err return err
} }
return node.Encode(res) return node.Encode(res)
} }
// GitHub has this undocumented feature to merge maps, called insert directive
var insertDirective = regexp.MustCompile(`\${{\s*insert\s*}}`)
func (ee ExpressionEvaluator) evaluateMappingYamlNode(node *yaml.Node) error { func (ee ExpressionEvaluator) evaluateMappingYamlNode(node *yaml.Node) error {
// GitHub has this undocumented feature to merge maps, called insert directive
insertDirective := regexp.MustCompile(`\${{\s*insert\s*}}`)
for i := 0; i < len(node.Content)/2; { for i := 0; i < len(node.Content)/2; {
k := node.Content[i*2] k := node.Content[i*2]
v := node.Content[i*2+1] v := node.Content[i*2+1]
@@ -102,88 +99,170 @@ func (ee ExpressionEvaluator) EvaluateYamlNode(node *yaml.Node) error {
} }
} }
func (ee ExpressionEvaluator) Interpolate(in string) string { // interpolate evaluates every part on its own, so a malformed one cannot restructure its neighbours
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") { func (ee ExpressionEvaluator) interpolate(in string) (string, error) {
return in parts, err := splitSubExpressions(in)
}
expr, _ := rewriteSubExpression(in, true)
evaluated, err := ee.evaluate(expr, exprparser.DefaultStatusCheckNone)
if err != nil { if err != nil {
return "" return "", err
} }
if len(parts) == 1 && !parts[0].isExpr {
value, ok := evaluated.(string)
if !ok {
panic(fmt.Sprintf("Expression %s did not evaluate to a string", expr))
}
return value
}
func escapeFormatString(in string) string {
return strings.ReplaceAll(strings.ReplaceAll(in, "{", "{{"), "}", "}}")
}
func rewriteSubExpression(in string, forceFormat bool) (string, error) {
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
return in, nil return in, nil
} }
var out strings.Builder
out.Grow(len(in))
for _, part := range parts {
if !part.isExpr {
out.WriteString(part.text)
continue
}
evaluated, err := ee.interpreter.Evaluate(part.text, exprparser.DefaultStatusCheckNone)
if err != nil {
return "", err
}
out.WriteString(coerceToString(evaluated))
}
return out.String(), nil
}
strPattern := regexp.MustCompile("(?:''|[^'])*'") // evaluateScalar keeps the type of a lone expression, so `${{ fromJSON('[1,2]') }}` stays an array
pos := 0 func (ee ExpressionEvaluator) evaluateScalar(in string) (any, error) {
exprStart := -1 parts, err := splitSubExpressions(in)
strStart := -1 if err != nil {
var results []string return nil, err
var formatOut strings.Builder }
for pos < len(in) { if len(parts) == 1 && parts[0].isExpr {
if strStart > -1 { return ee.interpreter.Evaluate(parts[0].text, exprparser.DefaultStatusCheckNone)
matches := strPattern.FindStringIndex(in[pos:]) }
if matches == nil { return ee.interpolate(in)
return "", errors.New("unclosed string") }
}
strStart = -1 // evaluateCondition evaluates an `if:`, an expression even without `${{ }}`. Mixed content
pos += matches[1] // interpolates to a string, so the success() default applies to it separately.
} else if exprStart > -1 { func (ee ExpressionEvaluator) evaluateCondition(in string) (bool, error) {
exprEnd := strings.Index(in[pos:], "}}") parts, err := splitSubExpressions(in)
strStart = strings.Index(in[pos:], "'") if err != nil {
return false, err
}
if len(parts) == 1 {
evaluated, err := ee.interpreter.Evaluate(parts[0].text, exprparser.DefaultStatusCheckSuccess)
if err != nil {
return false, err
}
return exprparser.IsTruthy(evaluated), nil
}
if exprEnd > -1 && strStart > -1 { // mixed content is a string, so the success() default applies to it separately
if exprEnd < strStart { if !expressionCallsFunction(in, "success", "always", "failure", "cancelled") {
strStart = -1 status, err := ee.interpreter.Evaluate("success()", exprparser.DefaultStatusCheckNone)
} else { if err != nil {
exprEnd = -1 return false, err
} }
} if !exprparser.IsTruthy(status) {
return false, nil
if exprEnd > -1 {
fmt.Fprintf(&formatOut, "{%d}", len(results))
results = append(results, strings.TrimSpace(in[exprStart:pos+exprEnd]))
pos += exprEnd + 2
exprStart = -1
} else if strStart > -1 {
pos += strStart + 1
} else {
panic("unclosed expression.")
}
} else {
exprStart = strings.Index(in[pos:], "${{")
if exprStart != -1 {
formatOut.WriteString(escapeFormatString(in[pos : pos+exprStart]))
exprStart = pos + exprStart + 3
pos = exprStart
} else {
formatOut.WriteString(escapeFormatString(in[pos:]))
pos = len(in)
}
} }
} }
interpolated, err := ee.interpolate(in)
if err != nil {
return false, err
}
return exprparser.IsTruthy(interpolated), nil
}
if len(results) == 1 && formatOut.String() == "{0}" && !forceFormat { // coerceToString converts an evaluated expression value to a string the way GitHub does,
return in, nil // see https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#operators
// An already reflected value is accepted as-is, since Interface() would panic on an invalid one.
func coerceToString(v any) string {
value, ok := v.(reflect.Value)
if !ok {
value = reflect.ValueOf(v)
} }
out := fmt.Sprintf("format('%s', %s)", strings.ReplaceAll(formatOut.String(), "'", "''"), strings.Join(results, ", ")) switch value.Kind() {
return out, nil case reflect.Invalid:
return ""
case reflect.Bool:
return strconv.FormatBool(value.Bool())
case reflect.String:
return value.String()
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return strconv.FormatInt(value.Int(), 10)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return strconv.FormatUint(value.Uint(), 10)
case reflect.Float32, reflect.Float64:
if math.IsInf(value.Float(), 1) {
return "Infinity"
} else if math.IsInf(value.Float(), -1) {
return "-Infinity"
}
return fmt.Sprintf("%.15G", value.Float())
case reflect.Slice, reflect.Array:
return "Array"
// contexts such as `github` are pointers to structs, so they stringify as objects too
case reflect.Map, reflect.Struct:
return "Object"
case reflect.Interface, reflect.Pointer:
if value.IsNil() {
return ""
}
return coerceToString(value.Elem())
}
return fmt.Sprintf("%v", value)
}
type exprPart struct {
text string
isExpr bool
}
// splitSubExpressions splits in the way GitHub's template reader does, leaving a value without a
// complete expression literal.
func splitSubExpressions(in string) ([]exprPart, error) {
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
return []exprPart{{text: in}}, nil
}
parts := make([]exprPart, 0, 2*strings.Count(in, "${{")+1)
for {
start := strings.Index(in, "${{")
if start < 0 {
if in != "" {
parts = append(parts, exprPart{text: in})
}
return parts, nil
}
if start > 0 {
parts = append(parts, exprPart{text: in[:start]})
}
rest := in[start+len("${{"):]
end := indexExprEnd(rest)
if end < 0 {
return nil, errors.New("unclosed expression")
}
parts = append(parts, exprPart{text: strings.TrimSpace(rest[:end]), isExpr: true})
in = rest[end+len("}}"):]
}
}
// indexExprEnd returns the offset of the `}}` ending an expression, or -1. A quote toggles string
// state, so a `}}` inside a string does not end it.
func indexExprEnd(in string) int {
inString := false
for i := range len(in) {
switch {
case in[i] == '\'':
inString = !inString
case !inString && in[i] == '}' && i+1 < len(in) && in[i+1] == '}':
return i
}
}
return -1
} }

View File

@@ -57,9 +57,14 @@ func NewInterpeter(
} }
ee := &exprparser.EvaluationEnvironment{ ee := &exprparser.EvaluationEnvironment{
Github: gitCtx, Github: gitCtx,
Env: nil, // no need Env: nil, // no need
Job: 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 Steps: nil, // no need
Runner: nil, // no need Runner: nil, // no need
Secrets: nil, // no need Secrets: nil, // no need

View File

@@ -6,11 +6,13 @@ package jobparser
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"slices"
"sort" "sort"
"strings" "strings"
"gitea.com/gitea/runner/act/exprparser" "gitea.com/gitea/runner/act/exprparser"
"gitea.com/gitea/runner/act/model" "gitea.com/gitea/runner/act/model"
"github.com/rhysd/actionlint"
"go.yaml.in/yaml/v4" "go.yaml.in/yaml/v4"
) )
@@ -48,7 +50,9 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
} }
evaluator := NewExpressionEvaluator(exprparser.NewInterpeter(&exprparser.EvaluationEnvironment{Github: pc.gitContext, Vars: pc.vars, Inputs: pc.inputs}, exprparser.Config{})) evaluator := NewExpressionEvaluator(exprparser.NewInterpeter(&exprparser.EvaluationEnvironment{Github: pc.gitContext, Vars: pc.vars, Inputs: pc.inputs}, exprparser.Config{}))
workflow.RunName = evaluator.Interpolate(workflow.RunName) if workflow.RunName, err = evaluator.interpolate(workflow.RunName); err != nil {
return nil, fmt.Errorf("interpolate run-name: %w", err)
}
for i, id := range ids { for i, id := range ids {
job := jobs[i] job := jobs[i]
@@ -63,10 +67,14 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
} }
job.Strategy.RawMatrix = encodeMatrix(matrix) job.Strategy.RawMatrix = encodeMatrix(matrix)
evaluator := NewExpressionEvaluator(NewInterpeter(id, origin.GetJob(id), matrix, pc.gitContext, results, pc.vars, pc.inputs)) evaluator := NewExpressionEvaluator(NewInterpeter(id, origin.GetJob(id), matrix, pc.gitContext, results, pc.vars, pc.inputs))
job.Name = nameWithMatrix(job.Name, matrix, evaluator) if job.Name, err = nameWithMatrix(job.Name, matrix, evaluator); err != nil {
return nil, fmt.Errorf("interpolate name for job %q: %w", id, err)
}
runsOn := origin.GetJob(id).RunsOn() runsOn := origin.GetJob(id).RunsOn()
for i, v := range runsOn { for i, v := range runsOn {
runsOn[i] = evaluator.Interpolate(v) if runsOn[i], err = evaluator.interpolate(v); err != nil {
return nil, fmt.Errorf("interpolate runs-on for job %q: %w", id, err)
}
} }
job.RawRunsOn = encodeRunsOn(runsOn) job.RawRunsOn = encodeRunsOn(runsOn)
if err := evaluator.EvaluateYamlNode(&job.RawContinueOnError); err != nil { if err := evaluator.EvaluateYamlNode(&job.RawContinueOnError); err != nil {
@@ -150,16 +158,45 @@ func encodeRunsOn(runsOn []string) yaml.Node {
return node return node
} }
func nameWithMatrix(name string, m map[string]any, evaluator *ExpressionEvaluator) string { func nameWithMatrix(name string, m map[string]any, evaluator *ExpressionEvaluator) (string, error) {
if len(m) == 0 { if len(m) == 0 {
return name return name, nil
} }
if !strings.Contains(name, "${{") || !strings.Contains(name, "}}") { if !strings.Contains(name, "${{") || !strings.Contains(name, "}}") {
return name + " " + matrixName(m) return name + " " + matrixName(m), nil
} }
return evaluator.Interpolate(name) return evaluator.interpolate(name)
}
// expressionCallsFunction reports whether any ${{ }} expression in value calls one of the functions.
func expressionCallsFunction(value string, names ...string) bool {
parts, err := splitSubExpressions(value)
if err != nil {
return true // unparseable here, let the expansion report it against the real values
}
for _, part := range parts {
if !part.isExpr {
continue
}
// The lexer needs the closing `}}` that the scanner strips.
expr, err := actionlint.NewExprParser().Parse(actionlint.NewExprLexer(part.text + "}}"))
if err != nil {
return true // unparseable here, let the expansion report it against the real values
}
found := false
actionlint.VisitExprNode(expr, func(node, _ actionlint.ExprNode, entering bool) {
call, ok := node.(*actionlint.FuncCallNode)
if entering && ok && slices.Contains(names, strings.ToLower(call.Callee)) {
found = true
}
})
if found {
return true
}
}
return false
} }
func matrixName(m map[string]any) string { func matrixName(m map[string]any) string {

View File

@@ -7,6 +7,7 @@ import (
"strings" "strings"
"testing" "testing"
"gitea.com/gitea/runner/act/model"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.yaml.in/yaml/v4" "go.yaml.in/yaml/v4"
@@ -107,3 +108,42 @@ func TestParse(t *testing.T) {
}) })
} }
} }
func TestParseInterpolatesRunName(t *testing.T) {
workflow := func(runName string) []byte {
return []byte("name: t\nrun-name: \"" + runName + "\"\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps: [{run: echo}]\n")
}
for _, tt := range []struct{ name, runName, want string }{
{"bool", "${{ true }}", "true"},
{"int", "${{ 1 }}", "1"},
{"float", "${{ 1.0 }}", "1"},
{"null", "${{ null }}", ""},
{"object", `${{ fromJSON('{\"a\":1}') }}`, "Object"},
{"array", "${{ fromJSON('[1,2]') }}", "Array"},
{"context", "${{ github }}", "Object"},
{"surrounding literals", "run ${{ 1 }} now", "run 1 now"},
{"two expressions", "${{ 1 }}-${{ true }}", "1-true"},
{"closing brace inside a string", "${{ 'a}}b' }}", "a}}b"},
{"incomplete expression stays literal", "${{ 1", "${{ 1"},
} {
t.Run(tt.name, func(t *testing.T) {
result, err := Parse(workflow(tt.runName), WithGitContext(&model.GithubContext{EventName: "push"}))
require.NoError(t, err)
require.Len(t, result, 1)
assert.Equal(t, tt.want, result[0].RunName)
})
}
// a malformed part must not restructure the surrounding expression
for _, runName := range []string{"${{ 1) && (2 }}", "run ${{ 1) && (2 }} now", "${{ 'a' }} ${{ b", "${{ 'a }}"} {
_, err := Parse(workflow(runName), WithGitContext(&model.GithubContext{EventName: "push"}))
assert.ErrorContains(t, err, "interpolate run-name")
}
// callers such as commit status parse without a git context, leaving `github` a nil pointer
result, err := Parse(workflow("${{ github }}"))
require.NoError(t, err)
require.Len(t, result, 1)
assert.Empty(t, result[0].RunName)
}

View File

@@ -8,7 +8,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"gitea.com/gitea/runner/act/exprparser" "gitea.dev/modules/util"
"gitea.com/gitea/runner/act/model" "gitea.com/gitea/runner/act/model"
"go.yaml.in/yaml/v4" "go.yaml.in/yaml/v4"
) )
@@ -32,6 +33,11 @@ func (w *SingleWorkflow) Job() (string, *Job) {
return "", nil return "", nil
} }
// WorkflowDispatchConfig returns the `on: workflow_dispatch` declaration, nil if there is none.
func (w *SingleWorkflow) WorkflowDispatchConfig() *model.WorkflowDispatch {
return (&model.Workflow{RawOn: w.RawOn}).WorkflowDispatchConfig()
}
func (w *SingleWorkflow) jobs() ([]string, []*Job, error) { func (w *SingleWorkflow) jobs() ([]string, []*Job, error) {
ids, jobs, err := parseMappingNode[*Job](&w.RawJobs) ids, jobs, err := parseMappingNode[*Job](&w.RawJobs)
if err != nil { if err != nil {
@@ -75,7 +81,22 @@ func (w *SingleWorkflow) SetJob(id string, job *Job) error {
} }
func (w *SingleWorkflow) Marshal() ([]byte, 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 { type Job struct {
@@ -277,7 +298,7 @@ func EvaluateConcurrency(rc *model.RawConcurrency, jobID string, job *Job, gitCt
if evaluated.RawExpression != "" { if evaluated.RawExpression != "" {
return evaluated.RawExpression, false, nil return evaluated.RawExpression, false, nil
} }
return evaluated.Group, evaluated.CancelInProgress == "true", nil return evaluated.Group, util.ParseYamlBool(evaluated.CancelInProgress), nil
} }
func toGitContext(input map[string]any) *model.GithubContext { func toGitContext(input map[string]any) *model.GithubContext {
@@ -490,16 +511,20 @@ func EvaluateJobIfExpression(jobID string, job *Job, gitCtx map[string]any, resu
RawMatrix: job.Strategy.RawMatrix, RawMatrix: job.Strategy.RawMatrix,
}, },
} }
evaluator := NewExpressionEvaluator(NewInterpeter(jobID, actJob, nil, toGitContext(gitCtx), results, vars, inputs)) // Each per-matrix job carries its single matrix combination in RawMatrix so resolve it and pass it in;
expr, err := rewriteSubExpression(job.If.Value, false) // 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 { if err != nil {
return false, err return false, err
} }
result, err := evaluator.evaluate(expr, exprparser.DefaultStatusCheckSuccess) if len(matrixes) > 0 && len(matrixes[0]) > 0 {
if err != nil { matrix = matrixes[0]
return false, err
} }
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. // parseMappingNode parse a mapping node and preserve order.

View File

@@ -4,6 +4,7 @@
package jobparser package jobparser
import ( import (
"fmt"
"strings" "strings"
"testing" "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)
})
}
}

View File

@@ -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)
}

View File

@@ -260,7 +260,7 @@ func MatchCallerInputsAgainstSpec(spec *WorkflowCallSpec, evaluated map[string]a
func parseWorkflowCallInput(name string, typ InputType, v any) (any, error) { func parseWorkflowCallInput(name string, typ InputType, v any) (any, error) {
switch typ { switch typ {
case InputTypeString: case InputTypeString:
return toString(v), nil return coerceToString(v), nil
case InputTypeBoolean: case InputTypeBoolean:
// strict type matching: a boolean input only accepts a native bool, not a "true"/"false" string // strict type matching: a boolean input only accepts a native bool, not a "true"/"false" string
if b, ok := v.(bool); ok { if b, ok := v.(bool); ok {
@@ -361,11 +361,11 @@ func EvaluateWorkflowCallOutputs(spec *WorkflowCallSpec, gitCtx *model.GithubCon
Vars: vars, Vars: vars,
Inputs: inputs, Inputs: inputs,
} }
interpreter := exprparser.NewInterpeter(env, exprparser.Config{}) evaluator := NewExpressionEvaluator(exprparser.NewInterpeter(env, exprparser.Config{}))
out := make(map[string]string, len(spec.Outputs)) out := make(map[string]string, len(spec.Outputs))
for name, o := range spec.Outputs { for name, o := range spec.Outputs {
v, err := evaluateWorkflowCallOutputValue(interpreter, o.Value) v, err := evaluator.interpolate(o.Value)
if err != nil { if err != nil {
return nil, fmt.Errorf("workflow_call output %q: %w", name, err) return nil, fmt.Errorf("workflow_call output %q: %w", name, err)
} }
@@ -373,29 +373,3 @@ func EvaluateWorkflowCallOutputs(spec *WorkflowCallSpec, gitCtx *model.GithubCon
} }
return out, nil return out, nil
} }
func evaluateWorkflowCallOutputValue(interpreter exprparser.Interpreter, value string) (string, error) {
if !strings.Contains(value, "${{") || !strings.Contains(value, "}}") {
return value, nil
}
expr, err := rewriteSubExpression(value, true)
if err != nil {
return "", err
}
evaluated, err := interpreter.Evaluate(expr, exprparser.DefaultStatusCheckNone)
if err != nil {
return "", err
}
return toString(evaluated), nil
}
func toString(v any) string {
switch s := v.(type) {
case string:
return s
case nil:
return ""
default:
return fmt.Sprintf("%v", s)
}
}

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). // It returns the workflows whose `on:` matches, and those that matched the event but were excluded by a branch/paths filter (filtered).
func MatchScopedWorkflows( func MatchScopedWorkflows(
parsed []*ParsedScopedWorkflow, parsed []*ParsedScopedWorkflow,
sourceCommitSHA string,
consumerGitRepo *git.Repository, consumerGitRepo *git.Repository,
consumerCommit *git.Commit, consumerCommit *git.Commit,
triggedEvent webhook_module.HookEventType, triggedEvent webhook_module.HookEventType,
@@ -71,9 +72,10 @@ func MatchScopedWorkflows(
continue continue
} }
dwf := &DetectedWorkflow{ dwf := &DetectedWorkflow{
EntryName: p.EntryName, EntryName: p.EntryName,
TriggerEvent: evt, TriggerEvent: evt,
Content: p.Content, Content: p.Content,
SourceCommitSHA: sourceCommitSHA,
} }
switch detectWorkflowMatch(consumerGitRepo, consumerCommit, triggedEvent, payload, evt) { switch detectWorkflowMatch(consumerGitRepo, consumerCommit, triggedEvent, payload, evt) {
case detectMatched: case detectMatched:

View File

@@ -28,6 +28,8 @@ type DetectedWorkflow struct {
EntryName string EntryName string
TriggerEvent *jobparser.Event TriggerEvent *jobparser.Event
Content []byte Content []byte
// SourceCommitSHA is the commit Content was read from, and must always be filled in together with Content.
SourceCommitSHA string
} }
type detectResult int type detectResult int
@@ -203,17 +205,19 @@ func DetectWorkflows(
if evt.IsSchedule() { if evt.IsSchedule() {
if detectSchedule { if detectSchedule {
dwf := &DetectedWorkflow{ dwf := &DetectedWorkflow{
EntryName: entry.Name(), EntryName: entry.Name(),
TriggerEvent: evt, TriggerEvent: evt,
Content: content, Content: content,
SourceCommitSHA: commit.ID.String(),
} }
schedules = append(schedules, dwf) schedules = append(schedules, dwf)
} }
} else { } else {
dwf := &DetectedWorkflow{ dwf := &DetectedWorkflow{
EntryName: entry.Name(), EntryName: entry.Name(),
TriggerEvent: evt, TriggerEvent: evt,
Content: content, Content: content,
SourceCommitSHA: commit.ID.String(),
} }
switch detectWorkflowMatch(gitRepo, commit, triggedEvent, payload, evt) { switch detectWorkflowMatch(gitRepo, commit, triggedEvent, payload, evt) {
case detectMatched: case detectMatched:
@@ -252,9 +256,10 @@ func DetectScheduledWorkflows(gitRepo *git.Repository, commit *git.Commit) ([]*D
if evt.IsSchedule() { if evt.IsSchedule() {
log.Trace("detect scheduled workflow: %q", entry.Name()) log.Trace("detect scheduled workflow: %q", entry.Name())
dwf := &DetectedWorkflow{ dwf := &DetectedWorkflow{
EntryName: entry.Name(), EntryName: entry.Name(),
TriggerEvent: evt, TriggerEvent: evt,
Content: content, Content: content,
SourceCommitSHA: commit.ID.String(),
} }
wfs = append(wfs, dwf) wfs = append(wfs, dwf)
} }

View File

@@ -28,13 +28,10 @@ func Init() {
WebAuthn = &webauthn.WebAuthn{ WebAuthn = &webauthn.WebAuthn{
Config: &webauthn.Config{ Config: &webauthn.Config{
RPDisplayName: setting.AppName, RPDisplayName: setting.AppName,
RPID: setting.Domain, RPID: setting.Domain,
RPOrigins: []string{appURL}, RPOrigins: []string{appURL},
AuthenticatorSelection: protocol.AuthenticatorSelection{ AttestationPreference: protocol.PreferNoAttestation, // Gitea never verifies attestation
UserVerification: protocol.VerificationDiscouraged,
},
AttestationPreference: protocol.PreferDirectAttestation,
}, },
} }
} }

View File

@@ -39,9 +39,7 @@ type CommitMessage struct {
trailerValues CommitMessageTrailerValues trailerValues CommitMessageTrailerValues
allParticipants []*CommitIdentity allAuthors []*CommitIdentity
committerCoAuthorIdx int
committerCoAuthor *CommitIdentity
} }
func (c *CommitMessage) MessageUTF8() string { func (c *CommitMessage) MessageUTF8() string {
@@ -77,8 +75,12 @@ func (c *CommitMessage) MessageTrailer() CommitMessageTrailerValues {
} }
var commitMessageTrailerSplit = sync.OnceValue(func() *regexp.Regexp { 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" // ref: https://git-scm.com/docs/git-interpret-trailers
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*)$`) // 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 // 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")] 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 { func CommitMessageParseTrailer(s string) CommitMessageTrailerValues {
ret := CommitMessageTrailerValues{} ret := CommitMessageTrailerValues{}
for line := range strings.SplitSeq(util.NormalizeStringEOL(s), "\n") { for line := range strings.SplitSeq(util.NormalizeStringEOL(s), "\n") {
@@ -107,63 +144,50 @@ func CommitMessageParseTrailer(s string) CommitMessageTrailerValues {
return ret return ret
} }
// AllParticipantIdentities returns all the participants in the commit, the first one is the commit's author // AllAuthorIdentities returns all the author and co-authors in the commit. Committer is not included:
func (c *Commit) AllParticipantIdentities() []*CommitIdentity { // * Author & Co-author: they changed the code (attribution)
if c.allParticipants != nil { // * Committer: they submitted the commit but didn't change the code (e.g.: maintainer signed a commit)
return c.allParticipants func (c *Commit) AllAuthorIdentities() []*CommitIdentity {
if c.allAuthors != nil {
return c.allAuthors
} }
trailerCoAuthors := c.MessageTrailer()["co-authored-by"]
c.allAuthors = make([]*CommitIdentity, 0, 1+len(trailerCoAuthors))
exclude := map[string]int{} exclude := map[string]int{}
addParticipant := func(name, email string, role int) (existingRole int) { addAuthor := func(name, email string, role int) {
if name == "" && email == "" { if name == "" && email == "" {
return 0 return
} }
emailLower := strings.ToLower(email) key := strings.ToLower(email)
if existingRole = exclude[emailLower]; emailLower != "" && existingRole != 0 { if key == "" {
return existingRole key = strings.ToLower(name)
} }
c.allParticipants = append(c.allParticipants, &CommitIdentity{Name: name, Email: email, role: role}) if existingRole := exclude[key]; key != "" && existingRole != 0 {
exclude[emailLower] = role return
return 0 }
c.allAuthors = append(c.allAuthors, &CommitIdentity{Name: name, Email: email, role: role})
exclude[key] = role
} }
c.committerCoAuthorIdx = -1 addAuthor(c.Author.Name, c.Author.Email, commitIdentityRoleAuthor)
addParticipant(c.Author.Name, c.Author.Email, commitIdentityRoleAuthor) for _, coAuthorValue := range trailerCoAuthors {
addParticipant(c.Committer.Name, c.Committer.Email, commitIdentityRoleCommitter)
for _, coAuthorValue := range c.MessageTrailer()["co-authored-by"] {
addr, err := mail.ParseAddress(coAuthorValue) addr, err := mail.ParseAddress(coAuthorValue)
coAuthorName, coAuthorEmail := coAuthorValue, "" coAuthorName, coAuthorEmail := coAuthorValue, ""
if err == nil { if err == nil {
coAuthorName, coAuthorEmail = addr.Name, addr.Address coAuthorName, coAuthorEmail = addr.Name, addr.Address
} }
existingRole := addParticipant(coAuthorName, coAuthorEmail, commitIdentityRoleCoAuthor) addAuthor(coAuthorName, coAuthorEmail, commitIdentityRoleCoAuthor)
if existingRole == commitIdentityRoleCommitter && c.committerCoAuthorIdx == -1 {
c.committerCoAuthorIdx = len(c.allParticipants)
c.committerCoAuthor = &CommitIdentity{coAuthorName, coAuthorEmail, commitIdentityRoleCoAuthor}
}
} }
return c.allParticipants return c.allAuthors
} }
// CoAuthorIdentities returns co-author identities defined by "Co-authored-by:" in the git message trailer
// Only the commit's author is excluded. If committer is declared as co-author, it will be included in the result.
// * Author & Co-author: they changed the code (attribution)
// * Committer: they submitted the commit but didn't change the code (e.g.: maintainer signed a commit)
// So, a committer can also be a co-author if they changed the code.
func (c *Commit) CoAuthorIdentities() (coAuthors []*CommitIdentity) { func (c *Commit) CoAuthorIdentities() (coAuthors []*CommitIdentity) {
all := c.AllParticipantIdentities() all := c.AllAuthorIdentities()
if len(all) <= 1 { if len(all) == 0 {
return nil // no co-author list return nil
} }
if all[1].role != commitIdentityRoleCommitter { if all[0].role == commitIdentityRoleAuthor {
return all[1:] // no committer, so all after author are co-authors return all[1:]
} }
if c.committerCoAuthorIdx == -1 { return all
return all[2:] // the committer is not in the co-author list, so just return the co-author list
}
// the committer is in the co-author list but de-duplicated, so include them as co-author again
coAuthors = append(coAuthors, all[2:c.committerCoAuthorIdx]...)
coAuthors = append(coAuthors, c.committerCoAuthor)
coAuthors = append(coAuthors, all[c.committerCoAuthorIdx:]...)
return coAuthors
} }

View File

@@ -26,10 +26,12 @@ func TestCommitMessageTrailer(t *testing.T) {
{"a", "a", "", ""}, {"a", "a", "", ""},
{"a\n\nk", "a\n\nk", "", ""}, {"a\n\nk", "a\n\nk", "", ""},
{"a\n\nk:v", "a", "\n\n", "k:v"}, {"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\n\n", "a", "\n\n", "k:v\n\n"},
{"a\n--\nk:v", "a\n--\nk:v", "", ""}, {"a\n--\nk:v", "a\n--\nk:v", "", ""},
{"a\n---\nk:v", "a", "\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"}, {"a\n\n---\n\nk:v", "a", "\n\n---\n\n", "k:v"},
{"k: v", "", "", "k: v"}, {"k: v", "", "", "k: v"},
{"\nk:v", "", "\n", "k:v"}, {"\nk:v", "", "\n", "k:v"},
@@ -50,41 +52,44 @@ func TestCommitMessageTrailer(t *testing.T) {
func TestCommitMessageParticipants(t *testing.T) { func TestCommitMessageParticipants(t *testing.T) {
sig := func(n, e string) *Signature { return &Signature{Name: n, Email: e} } sig := func(n, e string) *Signature { return &Signature{Name: n, Email: e} }
idt := func(n, e string, r int) *CommitIdentity { return &CommitIdentity{n, e, r} } idt := func(n, e string, r int) *CommitIdentity { return &CommitIdentity{n, e, r} }
roleAuthor, roleCommitter, roleCoAuthor := commitIdentityRoleAuthor, commitIdentityRoleCommitter, commitIdentityRoleCoAuthor roleAuthor, _, roleCoAuthor := commitIdentityRoleAuthor, commitIdentityRoleCommitter, commitIdentityRoleCoAuthor
type testCase struct { type testCase struct {
name string name string
commit *Commit commit *Commit
identities []*CommitIdentity identities []*CommitIdentity
} }
t.Run("AllParticipants", func(t *testing.T) {
t.Run("AllAuthors", func(t *testing.T) {
cases := []testCase{ cases := []testCase{
{ {
"DifferentUsers", "CommitterExcluded",
&Commit{ &Commit{
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"), Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: x@m.com"}, CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: Full Name <x@m.com>"},
}, },
[]*CommitIdentity{idt("a", "a@m.com", roleAuthor), idt("c", "c@m.com", roleCommitter), idt("", "x@m.com", roleCoAuthor)}, []*CommitIdentity{idt("a", "a@m.com", roleAuthor), idt("Full Name", "x@m.com", roleCoAuthor)},
}, },
{ {
"SameUser", "AuthorIsCoAuthor",
&Commit{ &Commit{
Author: sig("a", "a@m.com"), Committer: sig("a", "A@M.com"), Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: a@m.com"}, CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: other-name <a@m.com>"},
}, },
[]*CommitIdentity{idt("a", "a@m.com", roleAuthor)}, []*CommitIdentity{idt("a", "a@m.com", roleAuthor)},
}, },
{ {
"NoCommitter", "EmptyAuthor", // synthesized commits (push feed) may have no author signature at all
&Commit{ &Commit{
Author: sig("a", "a@m.com"), Committer: sig("", ""), Author: sig("", ""), Committer: sig("", ""),
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: Full Name <X@M.com>"}, CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: c <c@m.com>"},
}, },
[]*CommitIdentity{idt("a", "a@m.com", roleAuthor), idt("Full Name", "X@M.com", roleCoAuthor)}, // but if the commit message contains co-authors, the co-authors are still parsed for "all authors"
// if it is a problem, the caller should fix the problem (provide correct "author")
[]*CommitIdentity{idt("c", "c@m.com", roleCoAuthor)},
}, },
} }
for _, c := range cases { for _, c := range cases {
assert.Equal(t, c.identities, c.commit.AllParticipantIdentities(), "case: %s", c.name) assert.Equal(t, c.identities, c.commit.AllAuthorIdentities(), "case: %s", c.name)
} }
}) })
t.Run("CoAuthors", func(t *testing.T) { t.Run("CoAuthors", func(t *testing.T) {
@@ -114,12 +119,12 @@ func TestCommitMessageParticipants(t *testing.T) {
[]*CommitIdentity{}, []*CommitIdentity{},
}, },
{ {
"CoAuthorCommitterNameWithIndex", // restore the committer co-author to the co-author list by the index with correct name "CoAuthorNameOnlyAndDuplicate",
&Commit{ &Commit{
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"), Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: x <x@m.com>\nCo-authored-by: c-other <c@m.com>\nCo-authored-by: y <y@m.com>"}, CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: b\nCo-authored-by: b\nCo-authored-by: c"},
}, },
[]*CommitIdentity{idt("x", "x@m.com", roleCoAuthor), idt("c-other", "c@m.com", roleCoAuthor), idt("y", "y@m.com", roleCoAuthor)}, []*CommitIdentity{idt("b", "", roleCoAuthor), idt("c", "", roleCoAuthor)},
}, },
} }
for _, c := range cases { for _, c := range cases {
@@ -127,3 +132,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)
}
}

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 !setting.Git.DisablePartialClone && DefaultFeatures().CheckVersionAtLeast("2.22") {
if err = configSet(ctx, "uploadpack.allowfilter", "true"); err != nil { if err = configSet(ctx, "uploadpack.allowfilter", "true"); err != nil {
return err 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 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 { func configSet(ctx context.Context, key, value string) error {
stdout, _, err := gitcmd.NewCommand("config", "--global", "--get"). stdout, _, err := gitcmd.NewCommand("config", "--global", "--get").
AddDynamicArguments(key). AddDynamicArguments(key).

View File

@@ -36,7 +36,14 @@ type Features struct {
SupportGitMergeTree bool // >= 2.40 // we also need "--merge-base" 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 { func (f *Features) CheckVersionAtLeast(atLeast string) bool {
return f.gitVersion.Compare(version.Must(version.NewVersion(atLeast))) >= 0 return f.gitVersion.Compare(version.Must(version.NewVersion(atLeast))) >= 0

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
}

View File

@@ -121,9 +121,7 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error {
} }
cmd := gitcmd.NewCommand().AddArguments("clone") cmd := gitcmd.NewCommand().AddArguments("clone")
// Never follow HTTP redirects: no clone caller needs them, and a remote redirecting to an HandleGitCmdHTTPRedirection(cmd, from, to)
// otherwise-blocked address would be an SSRF vector (e.g. migrating from an attacker URL).
cmd.AddArguments("-c", "http.followRedirects=false")
if opts.SkipTLSVerify { if opts.SkipTLSVerify {
cmd.AddArguments("-c", "http.sslVerify=false") cmd.AddArguments("-c", "http.sslVerify=false")
} }

View File

@@ -26,6 +26,7 @@ func TestRepoIsEmpty(t *testing.T) {
// TestCloneRefusesRedirects ensures Clone never follows HTTP redirects, so a remote // TestCloneRefusesRedirects ensures Clone never follows HTTP redirects, so a remote
// cannot redirect to an otherwise-blocked address (SSRF, e.g. during migration). // cannot redirect to an otherwise-blocked address (SSRF, e.g. during migration).
func TestCloneRefusesRedirects(t *testing.T) { func TestCloneRefusesRedirects(t *testing.T) {
t.Skip("FIXME: GIT-CLONE-HTTP-REDIRECT-SSRF: need a complete solution in the future")
var targetHit atomic.Bool var targetHit atomic.Bool
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
targetHit.Store(true) targetHit.Store(true)

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. // performRequest sends a request, optionally performs a callback on the request and returns the response.
// If the status code is 200, the response is returned, and it will contain a non-nil Body. // If the status code is in the 2xx range, the response is returned, and it will contain a non-nil Body.
// Otherwise, it will return an error, and the Body will be nil or closed. // Otherwise, it will return an error, and the Body will be nil or closed.
func performRequest(ctx context.Context, client *http.Client, req *http.Request) (*http.Response, error) { func performRequest(ctx context.Context, client *http.Client, req *http.Request) (*http.Response, error) {
log.Trace("performRequest: %s", req.URL) log.Trace("performRequest: %s", req.URL)
@@ -264,7 +264,7 @@ func performRequest(ctx context.Context, client *http.Client, req *http.Request)
return res, err return res, err
} }
if res.StatusCode != http.StatusOK { if res.StatusCode < 200 || res.StatusCode >= 300 {
defer res.Body.Close() defer res.Body.Close()
return res, handleErrorResponse(res) return res, handleErrorResponse(res)
} }

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) { t.Run("Verify", func(t *testing.T) {
cases := []struct { cases := []struct {
link *Link link *Link

View File

@@ -5,11 +5,11 @@ package external
import ( import (
"bytes" "bytes"
"errors"
"fmt" "fmt"
"io" "io"
"os" "os"
"os/exec" "os/exec"
"runtime"
"strings" "strings"
"gitea.dev/modules/markup" "gitea.dev/modules/markup"
@@ -91,52 +91,63 @@ func (p *Renderer) GetExternalRendererOptions() (ret markup.ExternalRendererOpti
return ret return ret
} }
func envMark(envName string) string { func (p *Renderer) prepareExternalCommand(vars map[string]string) (string, []string, error) {
if runtime.GOOS == "windows" { fields, err := shellquote.Split(strings.TrimSpace(p.Command))
return "%" + envName + "%" if err != nil {
return "", nil, err
} }
return "$" + envName if len(fields) == 0 {
return "", nil, errors.New("no command")
}
var replacements []string
for k, v := range vars {
replacements = append(replacements, "$"+k, v)
replacements = append(replacements, "%"+k+"%", v) // for legacy Windows-style support
}
r := strings.NewReplacer(replacements...)
for i := range fields {
fields[i] = r.Replace(fields[i])
}
return fields[0], fields[1:], nil
} }
// Render renders the data of the document to HTML via the external tool. // Render renders the data of the document to HTML via the external tool.
func (p *Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error { func (p *Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
baseLinkSrc := ctx.RenderHelper.ResolveLink("", markup.LinkTypeDefault) baseLinkSrc := ctx.RenderHelper.ResolveLink("", markup.LinkTypeDefault)
baseLinkRaw := ctx.RenderHelper.ResolveLink("", markup.LinkTypeRaw) baseLinkRaw := ctx.RenderHelper.ResolveLink("", markup.LinkTypeRaw)
command := strings.NewReplacer( cmdVars := map[string]string{
envMark("GITEA_PREFIX_SRC"), baseLinkSrc, "GITEA_PREFIX_SRC": baseLinkSrc,
envMark("GITEA_PREFIX_RAW"), baseLinkRaw, "GITEA_PREFIX_RAW": baseLinkRaw,
).Replace(p.Command) }
commands, err := shellquote.Split(command) cmdProg, cmdArgs, err := p.prepareExternalCommand(cmdVars)
if err != nil || len(commands) == 0 { if err != nil {
return fmt.Errorf("%s invalid command %q: %w", p.Name(), p.Command, err) return fmt.Errorf("invalid external render (%s) command %q: %w", p.Name(), p.Command, err)
} }
args := commands[1:]
if p.IsInputFile { if p.IsInputFile {
// write to temp file // write to temp file
f, cleanup, err := setting.AppDataTempDir("git-repo-content").CreateTempFileRandom("gitea_input") tmpFile, cleanup, err := setting.AppDataTempDir("git-repo-content").CreateTempFileRandom("gitea_input")
if err != nil { if err != nil {
return fmt.Errorf("%s create temp file when rendering %s failed: %w", p.Name(), p.Command, err) return fmt.Errorf("%s create temp file when rendering %s failed: %w", p.Name(), p.Command, err)
} }
defer cleanup() defer cleanup()
_, err = io.Copy(f, input) _, err = io.Copy(tmpFile, input)
if err != nil { if err != nil {
_ = f.Close() _ = tmpFile.Close()
return fmt.Errorf("%s write data to temp file when rendering %s failed: %w", p.Name(), p.Command, err) return fmt.Errorf("%s write data to temp file when rendering %s failed: %w", p.Name(), p.Command, err)
} }
err = f.Close() err = tmpFile.Close()
if err != nil { if err != nil {
return fmt.Errorf("%s close temp file when rendering %s failed: %w", p.Name(), p.Command, err) return fmt.Errorf("%s close temp file when rendering %s failed: %w", p.Name(), p.Command, err)
} }
args = append(args, f.Name()) cmdArgs = append(cmdArgs, tmpFile.Name())
} }
processCtx, _, finished := process.GetManager().AddContext(ctx, fmt.Sprintf("Render [%s] for %s", commands[0], baseLinkSrc)) processCtx, _, finished := process.GetManager().AddContext(ctx, fmt.Sprintf("Render [%s] for %s", cmdProg, baseLinkSrc))
defer finished() defer finished()
cmd := exec.CommandContext(processCtx, commands[0], args...) cmd := exec.CommandContext(processCtx, cmdProg, cmdArgs...)
cmd.Env = append( cmd.Env = append(
os.Environ(), os.Environ(),
"GITEA_PREFIX_SRC="+baseLinkSrc, "GITEA_PREFIX_SRC="+baseLinkSrc,
@@ -151,7 +162,7 @@ func (p *Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.
process.SetSysProcAttribute(cmd) process.SetSysProcAttribute(cmd)
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return fmt.Errorf("%s render run command %s %v failed: %w\nStderr: %s", p.Name(), commands[0], args, err, stderr.String()) return fmt.Errorf("%s render run command %s %v failed: %w\nStderr: %s", p.Name(), cmdProg, shellquote.Join(cmdArgs...), err, stderr.String())
} }
return nil return nil
} }

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)
}

View File

@@ -21,19 +21,12 @@ type frontendRenderer struct {
patterns []string patterns []string
} }
var ( var _ markup.ExternalRenderer = (*frontendRenderer)(nil)
_ markup.PostProcessRenderer = (*frontendRenderer)(nil)
_ markup.ExternalRenderer = (*frontendRenderer)(nil)
)
func (p *frontendRenderer) Name() string { func (p *frontendRenderer) Name() string {
return p.name return p.name
} }
func (p *frontendRenderer) NeedPostProcess() bool {
return false
}
func (p *frontendRenderer) FileNamePatterns() []string { func (p *frontendRenderer) FileNamePatterns() []string {
// TODO: the file extensions are ambiguous, even if the file name matches, it doesn't mean that the file is a 3D model // TODO: the file extensions are ambiguous, even if the file name matches, it doesn't mean that the file is a 3D model
// There are some approaches to make it more accurate, but they are all complicated: // There are some approaches to make it more accurate, but they are all complicated:

View File

@@ -29,9 +29,8 @@ func init() {
type renderer struct{} type renderer struct{}
var ( var (
_ markup.Renderer = (*renderer)(nil) _ markup.Renderer = (*renderer)(nil)
_ markup.PostProcessRenderer = (*renderer)(nil) _ markup.ExternalRenderer = (*renderer)(nil) // FIXME: this is not an external render, need to refactor the framework in the future
_ markup.ExternalRenderer = (*renderer)(nil) // FIXME: this is not an external render, need to refactor the framework in the future
) )
type mimeHandler struct { type mimeHandler struct {
@@ -96,8 +95,6 @@ func (renderer) Name() string {
return "jupyter-render" return "jupyter-render"
} }
func (renderer) NeedPostProcess() bool { return true }
func (renderer) GetExternalRendererOptions() markup.ExternalRendererOptions { func (renderer) GetExternalRendererOptions() markup.ExternalRendererOptions {
return markup.ExternalRendererOptions{ return markup.ExternalRendererOptions{
// HINT: no need to let markup render sanitize the output because there are many special CSS class names, inline attributes. // HINT: no need to let markup render sanitize the output because there are many special CSS class names, inline attributes.
@@ -215,7 +212,7 @@ func renderCellCode(output htmlutil.HTMLWriter, cell Cell, language string) erro
// Highlight code // Highlight code
lexer := highlight.DetectChromaLexerByFileName("", language) lexer := highlight.DetectChromaLexerByFileName("", language)
output.WriteFormat(`<div class="cell-right cell-input"><pre><code class="chroma language-%s">`, strings.ToLower(language)) output.WriteFormat(`<div class="cell-right cell-input"><pre><code class="chroma language-%s">`, strings.ToLower(lexer.Config().Name))
output.WriteHTML(highlight.RenderCodeByLexer(lexer, source)) output.WriteHTML(highlight.RenderCodeByLexer(lexer, source))
output.WriteHTML("</code></pre></div>") output.WriteHTML("</code></pre></div>")
} }

View File

@@ -261,7 +261,7 @@ func TestIntegrationAndSanitization(t *testing.T) {
maliciousNotebook := `{ maliciousNotebook := `{
"nbformat": 4, "nbformat": 4,
"nbformat_minor": 2, "nbformat_minor": 2,
"metadata": {}, "metadata": {"language_info":{"name":"any lang"}},
"cells": [ "cells": [
{ {
"cell_type": "code", "cell_type": "code",
@@ -274,7 +274,7 @@ func TestIntegrationAndSanitization(t *testing.T) {
"execution_count": 1, "execution_count": 1,
"data": { "data": {
"text/html": [ "text/html": [
"<div><script>alert('XSS Vector')</script><table class=\"dataframe\"><tr><td>Safe Content</td></tr></table></div>" "<div><script>foo</script><table class=other><tr><td>[[name=no-post-process|link=/link]]</td></tr></table></div>"
] ]
}, },
"metadata": {} "metadata": {}
@@ -295,8 +295,8 @@ func TestIntegrationAndSanitization(t *testing.T) {
<div class="cell-line"> <div class="cell-line">
<div class="cell-left cell-prompt">In [1]:</div> <div class="cell-left cell-prompt">In [1]:</div>
<div class="cell-right cell-input"> <div class="cell-right cell-input">
<pre><code class="chroma language-python"> <pre><code class="chroma language-fallback">
<span class="n">a</span><span class="o">=</span><span class="mi">1</span> a=1
</code></pre> </code></pre>
</div> </div>
</div> </div>
@@ -304,7 +304,7 @@ func TestIntegrationAndSanitization(t *testing.T) {
<div class="cell-left cell-prompt">Out [1]:</div> <div class="cell-left cell-prompt">Out [1]:</div>
<div class="cell-right cell-output"> <div class="cell-right cell-output">
<div class="cell-output-html"> <div class="cell-output-html">
<div><table><tbody><tr><td>Safe Content</td></tr></tbody></table></div> <div><table><tr><td>[[name=no-post-process|link=/link]]</td></tr></table></div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -14,7 +14,6 @@ import (
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
setting.IsInTesting = true setting.IsInTesting = true
markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true
setting.Markdown.FileNamePatterns = []string{"*.md"}
markup.RefreshFileNamePatterns() markup.RefreshFileNamePatterns()
os.Exit(m.Run()) os.Exit(m.Run())
} }

View File

@@ -70,7 +70,15 @@ func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error
w := &orgWriter{rctx: ctx, HTMLWriter: htmlWriter} w := &orgWriter{rctx: ctx, HTMLWriter: htmlWriter}
htmlWriter.ExtendingWriter = w 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 { if err != nil {
return fmt.Errorf("orgmode.Render failed: %w", err) return fmt.Errorf("orgmode.Render failed: %w", err)
} }

View File

@@ -21,86 +21,74 @@ func TestMain(m *testing.M) {
os.Exit(m.Run()) os.Exit(m.Run())
} }
func TestRender_StandardLinks(t *testing.T) { func testRender(t *testing.T, input, expected string) {
test := func(input, expected string) { buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input) assert.NoError(t, err)
assert.NoError(t, err) assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
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>`) `<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>`) `<p><a href="ImageLink.svg">The Image Desc</a></p>`)
} }
func TestRender_InternalLinks(t *testing.T) { func TestRender_InternalLinks(t *testing.T) {
test := func(input, expected string) { testRender(t, "[[file:test.org][Test]]",
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]]",
`<p><a href="test.org">Test</a></p>`) `<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>`) `<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>`) `<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>`) `<p><a href="path/to/test.org">Test</a></p>`)
} }
func TestRender_Media(t *testing.T) { func TestRender_Media(t *testing.T) {
test := func(input, expected string) { testRender(t, "[[file:../../.images/src/02/train.jpg]]",
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]]",
`<p><img src="../../.images/src/02/train.jpg" alt="../../.images/src/02/train.jpg"></p>`) `<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>`) `<p><img src="train.jpg" alt="train.jpg"></p>`)
// With description. // 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>`) `<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>`) `<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>`) `<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>`) `<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. // 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>`) `<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>`) `<p><video src="https://example.com/example.mp4">https://example.com/example.mp4</video></p>`)
// test [[LINK][DESCRIPTION]] syntax with "file:" prefix // 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>`) `<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>`) `<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>`) `<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>`) `<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) { func TestRender_Source(t *testing.T) {
test := func(input, expected string) { testRender(t, `#+begin_src c
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
test(`#+begin_src c
int a; int a;
#+end_src #+end_src
`, `<div class="src src-c"> `, `<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> <pre><code class="chroma language-c"><span class="kt">int</span> <span class="n">a</span><span class="p">;</span></code></pre>
</div>`) </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>`)
}

View File

@@ -153,6 +153,9 @@ func ParsePackage(r io.Reader) (*Package, error) {
return nil, err return nil, err
} }
} else if !strings.HasPrefix(filename, ".") { } else if !strings.HasPrefix(filename, ".") {
if strings.ContainsAny(hd.Name, "\n\r") {
continue // a newline would forge extra lines in the pacman index
}
if err := files.Add(hd.Name); err != nil { if err := files.Add(hd.Name); err != nil {
return nil, err return nil, err
} }

View File

@@ -104,6 +104,7 @@ func TestParsePackage(t *testing.T) {
data := createPackage(c, map[string][]byte{ data := createPackage(c, map[string][]byte{
".PKGINFO": createPKGINFOContent(packageName, packageVersion), ".PKGINFO": createPKGINFOContent(packageName, packageVersion),
"/test/dummy.txt": {}, "/test/dummy.txt": {},
"usr/lib/legit\n\n%FILES%\n/etc/cron.d/x": {}, // must not reach the file list
}) })
p, err := ParsePackage(data) p, err := ParsePackage(data)

View File

@@ -123,11 +123,26 @@ func ParsePackage(sr io.ReaderAt, size int64, mr io.Reader) (*Package, error) {
}, },
} }
// Nested packages (test fixtures, examples, benchmarks) ship their own manifests, which must not
// replace the package manifest. The package sits at the archive root or in a single top level
// directory, so keep only the shallowest manifest directory, breaking ties by name for stability.
var manifestFiles []*zip.File
manifestDir, manifestDepth := "", 0
for _, file := range zr.File { for _, file := range zr.File {
manifestMatch := manifestPattern.FindStringSubmatch(path.Base(file.Name)) if strings.HasSuffix(file.Name, "/") || !manifestPattern.MatchString(path.Base(file.Name)) {
if len(manifestMatch) == 0 {
continue continue
} }
dir, depth := path.Dir(file.Name), strings.Count(file.Name, "/")
switch {
case manifestFiles == nil || depth < manifestDepth || (depth == manifestDepth && dir < manifestDir):
manifestDir, manifestDepth, manifestFiles = dir, depth, []*zip.File{file}
case dir == manifestDir:
manifestFiles = append(manifestFiles, file)
}
}
for _, file := range manifestFiles {
manifestMatch := manifestPattern.FindStringSubmatch(path.Base(file.Name))
if file.UncompressedSize64 > maxManifestFileSize { if file.UncompressedSize64 > maxManifestFileSize {
return nil, ErrManifestFileTooLarge return nil, ErrManifestFileTooLarge

View File

@@ -4,6 +4,7 @@
package swift package swift
import ( import (
"archive/zip"
"bytes" "bytes"
"strings" "strings"
"testing" "testing"
@@ -24,6 +25,18 @@ const (
packageLicense = "MIT" packageLicense = "MIT"
) )
// writeOrderedZipArchive writes name/content pairs in the given order, which map based test.WriteZipArchive cannot do
func writeOrderedZipArchive(entries [][2]string) *bytes.Buffer {
buf := &bytes.Buffer{}
zw := zip.NewWriter(buf)
for _, entry := range entries {
w, _ := zw.Create(entry[0])
_, _ = w.Write([]byte(entry[1]))
}
_ = zw.Close()
return buf
}
func TestParsePackage(t *testing.T) { func TestParsePackage(t *testing.T) {
t.Run("MissingManifestFile", func(t *testing.T) { t.Run("MissingManifestFile", func(t *testing.T) {
data := test.WriteZipArchive(map[string]string{"dummy.txt": ""}) data := test.WriteZipArchive(map[string]string{"dummy.txt": ""})
@@ -65,6 +78,77 @@ func TestParsePackage(t *testing.T) {
assert.Equal(t, content2, m.Content) assert.Equal(t, content2, m.Content)
}) })
t.Run("IgnoresNestedManifests", func(t *testing.T) {
rootManifest := "// swift-tools-version:5.7\n//\n// Package.swift"
rootAltManifest := "// swift-tools-version:5.5\n//\n// Package@swift-5.5.swift"
rootPatchAltManifest := "// swift-tools-version:5.7.1\n//\n// Package@swift-5.7.1.swift"
nestedManifest := "// swift-tools-version:6.3\n//\n// nested fixture package"
data := writeOrderedZipArchive([][2]string{
{"Package.swift", rootManifest},
{"Package@swift-5.5.swift", rootAltManifest},
{"Package@swift-5.7.1.swift", rootPatchAltManifest},
{"Benchmarks/Package.swift", nestedManifest},
{"Utils/Fixtures/PlainPackage/Package.swift", nestedManifest},
})
p, err := ParsePackage(bytes.NewReader(data.Bytes()), int64(data.Len()), nil)
assert.NotNil(t, p)
assert.NoError(t, err)
assert.Len(t, p.Metadata.Manifests, 3)
assert.Equal(t, rootManifest, p.Metadata.Manifests[""].Content)
assert.Equal(t, "5.7", p.Metadata.Manifests[""].ToolsVersion)
assert.Equal(t, rootAltManifest, p.Metadata.Manifests["5.5"].Content)
assert.Equal(t, rootPatchAltManifest, p.Metadata.Manifests["5.7.1"].Content)
})
t.Run("IgnoresNestedManifestsInPrefixedArchive", func(t *testing.T) {
rootManifest := "// swift-tools-version:5.7\n//\n// Package.swift"
// `swift package archive-source` produces archives with a single top level directory
data := writeOrderedZipArchive([][2]string{
{"gitea-1.0.1/Package.swift", rootManifest},
{"gitea-1.0.1/Tests/Fixtures/Package.swift", "// swift-tools-version:6.3"},
})
p, err := ParsePackage(bytes.NewReader(data.Bytes()), int64(data.Len()), nil)
assert.NotNil(t, p)
assert.NoError(t, err)
assert.Len(t, p.Metadata.Manifests, 1)
assert.Equal(t, rootManifest, p.Metadata.Manifests[""].Content)
})
t.Run("AltManifestOnlyInRootDirectory", func(t *testing.T) {
// a deeper Package.swift belongs to a nested package and must not stand in for the missing root manifest
data := test.WriteZipArchive(map[string]string{
"Package@swift-5.5.swift": "// swift-tools-version:5.5",
"Sub/Package.swift": "// swift-tools-version:5.7",
})
p, err := ParsePackage(bytes.NewReader(data.Bytes()), int64(data.Len()), nil)
assert.Nil(t, p)
assert.ErrorIs(t, err, ErrMissingManifestFile)
})
t.Run("ManifestDirectoryTieBreak", func(t *testing.T) {
contentA := "// swift-tools-version:5.7\n// A"
contentB := "// swift-tools-version:5.7\n// B"
// at equal depth the name decides, never the archive order
data := writeOrderedZipArchive([][2]string{
{"a/Package.swift", contentA},
{"b/Package.swift", contentB},
})
p, err := ParsePackage(bytes.NewReader(data.Bytes()), int64(data.Len()), nil)
assert.NotNil(t, p)
assert.NoError(t, err)
assert.Len(t, p.Metadata.Manifests, 1)
assert.Equal(t, contentA, p.Metadata.Manifests[""].Content)
})
t.Run("WithMetadata", func(t *testing.T) { t.Run("WithMetadata", func(t *testing.T) {
data := test.WriteZipArchive(map[string]string{ data := test.WriteZipArchive(map[string]string{
"Package.swift": "// swift-tools-version:5.7\n//\n// Package.swift", "Package.swift": "// swift-tools-version:5.7\n//\n// Package.swift",

View File

@@ -8,4 +8,10 @@ const (
KeyUname = "uname" KeyUname = "uname"
KeyUserHasTwoFactorAuth = "userHasTwoFactorAuth" 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"
) )

View File

@@ -50,7 +50,8 @@ var Markdown = struct {
MathCodeBlockDetection []string MathCodeBlockDetection []string
MathCodeBlockOptions MarkdownMathCodeBlockOptions `ini:"-"` MathCodeBlockOptions MarkdownMathCodeBlockOptions `ini:"-"`
}{ }{
EnableMath: true, EnableMath: true,
FileNamePatterns: []string{"*.md"},
} }
// MarkupRenderer defines the external parser configured in ini // MarkupRenderer defines the external parser configured in ini

View File

@@ -304,12 +304,10 @@ func (a *AzureBlobStorage) ServeDirectURL(storePath, name, method string, reqPar
// IterateObjects iterates across the objects in the azureblobstorage // IterateObjects iterates across the objects in the azureblobstorage
func (a *AzureBlobStorage) IterateObjects(dirName string, fn func(path string, obj Object) error) error { func (a *AzureBlobStorage) IterateObjects(dirName string, fn func(path string, obj Object) error) error {
dirName = a.buildAzureBlobPath(dirName) basePrefix := buildObjectStorePathPrefix(a.cfg.BasePath, "")
if dirName != "" { dirPrefix := buildObjectStorePathPrefix(a.cfg.BasePath, dirName)
dirName += "/"
}
pager := a.client.NewListBlobsFlatPager(a.cfg.Container, &container.ListBlobsFlatOptions{ pager := a.client.NewListBlobsFlatPager(a.cfg.Container, &container.ListBlobsFlatOptions{
Prefix: &dirName, Prefix: &dirPrefix,
}) })
for pager.More() { for pager.More() {
resp, err := pager.NextPage(a.ctx) resp, err := pager.NextPage(a.ctx)
@@ -317,7 +315,8 @@ func (a *AzureBlobStorage) IterateObjects(dirName string, fn func(path string, o
return convertAzureBlobErr(err) return convertAzureBlobErr(err)
} }
for _, object := range resp.Segment.BlobItems { for _, object := range resp.Segment.BlobItems {
blobClient := a.getBlobClient(*object.Name) objPath := strings.TrimPrefix(*object.Name, basePrefix)
blobClient := a.getBlobClient(objPath)
object := &azureBlobObject{ object := &azureBlobObject{
Context: a.ctx, Context: a.ctx,
blobClient: blobClient, blobClient: blobClient,
@@ -327,7 +326,7 @@ func (a *AzureBlobStorage) IterateObjects(dirName string, fn func(path string, o
} }
if err := func(object *azureBlobObject, fn func(path string, obj Object) error) error { if err := func(object *azureBlobObject, fn func(path string, obj Object) error) error {
defer object.Close() defer object.Close()
return fn(strings.TrimPrefix(object.Name, a.cfg.BasePath), object) return fn(objPath, object)
}(object, fn); err != nil { }(object, fn); err != nil {
return convertAzureBlobErr(err) return convertAzureBlobErr(err)
} }
@@ -336,7 +335,6 @@ func (a *AzureBlobStorage) IterateObjects(dirName string, fn func(path string, o
return nil return nil
} }
// Delete delete a file
func (a *AzureBlobStorage) getBlobClient(path string) *blob.Client { func (a *AzureBlobStorage) getBlobClient(path string) *blob.Client {
return a.client.ServiceClient().NewContainerClient(a.cfg.Container).NewBlobClient(a.buildAzureBlobPath(path)) return a.client.ServiceClient().NewContainerClient(a.cfg.Container).NewBlobClient(a.buildAzureBlobPath(path))
} }

View File

@@ -27,24 +27,16 @@ func TestAzureBlobStorage(t *testing.T) {
Container: "test", Container: "test",
}, },
} }
table := []struct { t.Run("Iterator", func(t *testing.T) {
name string testStorageIterator(t, storageType, config)
test func(t *testing.T, typStr Type, cfg *setting.Storage) })
}{ t.Run("BlobStorageURLContentTypeAndDisposition", func(t *testing.T) {
{ testBlobStorageURLContentTypeAndDisposition(t, storageType, config)
name: "iterator", })
test: testStorageIterator, t.Run("IteratorWithBasePath", func(t *testing.T) {
}, config.AzureBlobConfig.BasePath = "test-base-path"
{ testStorageIterator(t, storageType, config)
name: "testBlobStorageURLContentTypeAndDisposition", })
test: testBlobStorageURLContentTypeAndDisposition,
},
}
for _, entry := range table {
t.Run(entry.name, func(t *testing.T) {
entry.test(t, storageType, config)
})
}
} }
func TestAzureBlobStoragePath(t *testing.T) { func TestAzureBlobStoragePath(t *testing.T) {

View File

@@ -26,6 +26,8 @@ import (
var _ ObjectStorage = &MinioStorage{} var _ ObjectStorage = &MinioStorage{}
const unknownSizePartSize = 1024 * 1024 * 16 // same as minio-go's minPartSize
type minioObject struct { type minioObject struct {
*minio.Object *minio.Object
} }
@@ -211,6 +213,10 @@ func (m *MinioStorage) Save(path string, r io.Reader, size int64) (int64, error)
// * https://www.backblaze.com/b2/docs/s3_compatible_api.html // * https://www.backblaze.com/b2/docs/s3_compatible_api.html
// do not support "x-amz-checksum-algorithm" header, so use legacy MD5 checksum // do not support "x-amz-checksum-algorithm" header, so use legacy MD5 checksum
SendContentMd5: m.cfg.ChecksumAlgorithm == "md5", SendContentMd5: m.cfg.ChecksumAlgorithm == "md5",
// with an unknown size (-1) minio-go assumes a 5TiB object and buffers a 528MiB part for it, even
// for a payload of a few KiB, so pin the part size there, a known size derives its own
PartSize: util.Iif[uint64](size < 0, unknownSizePartSize, 0),
}, },
) )
if err != nil { if err != nil {

View File

@@ -11,11 +11,13 @@ import (
"net/url" "net/url"
"os" "os"
"path" "path"
"strings"
"gitea.dev/modules/httplib" "gitea.dev/modules/httplib"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/public" "gitea.dev/modules/public"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
"gitea.dev/modules/util"
) )
// ErrURLNotSupported represents url is not supported // ErrURLNotSupported represents url is not supported
@@ -139,6 +141,23 @@ func SaveFrom(objStorage ObjectStorage, path string, callback func(w io.Writer)
return err return err
} }
func buildObjectStorePath(base, p string) string {
p = strings.TrimPrefix(util.PathJoinRelX(base, p), "/") // object store doesn't use slash for root path
if p == "." {
p = "" // object store doesn't use dot as relative path
}
return p
}
func buildObjectStorePathPrefix(base, p string) string {
// ending slash is required for avoiding matching like "foo/" and "foobar/" with prefix "foo"
p = buildObjectStorePath(base, p) + "/"
if p == "/" {
p = "" // object store doesn't use slash for root path
}
return p
}
var ( var (
// Attachments represents attachments storage // Attachments represents attachments storage
Attachments ObjectStorage = uninitializedStorage Attachments ObjectStorage = uninitializedStorage

View File

@@ -4,6 +4,7 @@
package storage package storage
import ( import (
"io"
"net/http" "net/http"
"strings" "strings"
"testing" "testing"
@@ -31,6 +32,11 @@ func testStorageIterator(t *testing.T, typStr Type, cfg *setting.Storage) {
_, err = l.Save(f[0], strings.NewReader(f[1]), -1) _, err = l.Save(f[0], strings.NewReader(f[1]), -1)
assert.NoError(t, err) assert.NoError(t, err)
} }
defer func() {
for _, f := range testFiles {
_ = l.Delete(f[0])
}
}()
expectedList := map[string][]string{ expectedList := map[string][]string{
"a": {"a/1.txt"}, "a": {"a/1.txt"},
@@ -43,7 +49,9 @@ func testStorageIterator(t *testing.T, typStr Type, cfg *setting.Storage) {
for dir, expected := range expectedList { for dir, expected := range expectedList {
count := 0 count := 0
err = l.IterateObjects(dir, func(path string, f Object) error { err = l.IterateObjects(dir, func(path string, f Object) error {
defer f.Close() content, err := io.ReadAll(f)
assert.NoError(t, err)
assert.NotEmpty(t, content)
assert.Contains(t, expected, path) assert.Contains(t, expected, path)
count++ count++
return nil return nil

View File

@@ -32,3 +32,8 @@ type RepoTopicOptions struct {
// list of topic names // list of topic names
Topics []string `json:"topics"` Topics []string `json:"topics"`
} }
// TopicListResponse returns a list of TopicResponse
type TopicListResponse struct {
Topics []*TopicResponse `json:"topics"`
}

View File

@@ -370,7 +370,7 @@ func (ut *RenderUtils) AvatarStackPushCommit(pushCommit *repository.PushCommit)
// there is no way to know the real committer, but the field can't be nil // there is no way to know the real committer, but the field can't be nil
Committer: &git.Signature{Name: pushCommit.AuthorName, Email: pushCommit.AuthorEmail}, Committer: &git.Signature{Name: pushCommit.AuthorName, Email: pushCommit.AuthorEmail},
} }
data := user_model.BuildAvatarStackData(ut.ctx, fakeGitCommit.AllParticipantIdentities(), nil) data := user_model.BuildAvatarStackData(ut.ctx, fakeGitCommit.AllAuthorIdentities(), nil)
return ut.AvatarStack(data) return ut.AvatarStack(data)
} }

View File

@@ -26,6 +26,11 @@ func IsEmptyString(s string) bool {
return len(strings.TrimSpace(s)) == 0 return len(strings.TrimSpace(s)) == 0
} }
// ParseYamlBool parses YAML 1.2 boolean values into bool
func ParseYamlBool(s string) bool {
return s == "true" || s == "True" || s == "TRUE"
}
// NormalizeEOL will convert Windows (CRLF) and Mac (CR) EOLs to UNIX (LF) // NormalizeEOL will convert Windows (CRLF) and Mac (CR) EOLs to UNIX (LF)
func NormalizeEOL(input []byte) []byte { func NormalizeEOL(input []byte) []byte {
var right, left, pos int var right, left, pos int

View File

@@ -124,6 +124,7 @@
"artifacts": "Artifacts", "artifacts": "Artifacts",
"expired": "Expired", "expired": "Expired",
"artifact_expires_at": "Expires at %s", "artifact_expires_at": "Expires at %s",
"artifact_expired_at": "Expired at %s",
"confirm_delete_artifact": "Are you sure you want to delete the artifact '%s'?", "confirm_delete_artifact": "Are you sure you want to delete the artifact '%s'?",
"archived": "Archived", "archived": "Archived",
"concept_system_global": "Global", "concept_system_global": "Global",
@@ -2251,7 +2252,6 @@
"repo.settings.webhook_deletion_success": "The webhook has been removed.", "repo.settings.webhook_deletion_success": "The webhook has been removed.",
"repo.settings.webhook.test_delivery": "Test Push Event", "repo.settings.webhook.test_delivery": "Test Push Event",
"repo.settings.webhook.test_delivery_desc": "Test this webhook with a fake push event.", "repo.settings.webhook.test_delivery_desc": "Test this webhook with a fake push event.",
"repo.settings.webhook.test_delivery_desc_disabled": "To test this webhook with a fake event, activate it.",
"repo.settings.webhook.request": "Request", "repo.settings.webhook.request": "Request",
"repo.settings.webhook.response": "Response", "repo.settings.webhook.response": "Response",
"repo.settings.webhook.headers": "Headers", "repo.settings.webhook.headers": "Headers",
@@ -3742,7 +3742,7 @@
"actions.runners.runner_title": "Runner", "actions.runners.runner_title": "Runner",
"actions.runners.task_list": "Recent tasks on this runner", "actions.runners.task_list": "Recent tasks on this runner",
"actions.runners.task_list.no_tasks": "There is no task yet.", "actions.runners.task_list.no_tasks": "There is no task yet.",
"actions.runners.task_list.run": "Run", "actions.runners.task_list.job": "Job",
"actions.runners.task_list.status": "Status", "actions.runners.task_list.status": "Status",
"actions.runners.task_list.repository": "Repository", "actions.runners.task_list.repository": "Repository",
"actions.runners.task_list.commit": "Commit", "actions.runners.task_list.commit": "Commit",
@@ -3784,6 +3784,9 @@
"actions.runs.pushed_by": "pushed by", "actions.runs.pushed_by": "pushed by",
"actions.runs.invalid_workflow_helper": "Workflow config file is invalid. Please check your config file: %s", "actions.runs.invalid_workflow_helper": "Workflow config file is invalid. Please check your config file: %s",
"actions.runs.no_matching_online_runner_helper": "No matching online runner with label: %s", "actions.runs.no_matching_online_runner_helper": "No matching online runner with label: %s",
"actions.runs.no_runner_online": "No runner is online to pick up this job.",
"actions.runs.waiting_for_available_runner": "Waiting for a matching runner to become available.",
"actions.runs.waiting_for_dependent_jobs": "Waiting for the following jobs to complete: %s",
"actions.runs.no_job_without_needs": "The workflow must contain at least one job without dependencies.", "actions.runs.no_job_without_needs": "The workflow must contain at least one job without dependencies.",
"actions.runs.no_job": "The workflow must contain at least one job", "actions.runs.no_job": "The workflow must contain at least one job",
"actions.runs.invalid_reusable_workflow_uses": "Invalid reusable workflow \"uses\": %s", "actions.runs.invalid_reusable_workflow_uses": "Invalid reusable workflow \"uses\": %s",
@@ -3804,6 +3807,7 @@
"actions.runs.cancel": "Cancel workflow run", "actions.runs.cancel": "Cancel workflow run",
"actions.runs.delete.description": "Are you sure you want to permanently delete this workflow run? This action cannot be undone.", "actions.runs.delete.description": "Are you sure you want to permanently delete this workflow run? This action cannot be undone.",
"actions.runs.not_done": "This workflow run is not done.", "actions.runs.not_done": "This workflow run is not done.",
"actions.runs.no_failed_jobs": "This workflow run has no failed jobs to re-run.",
"actions.runs.view_workflow_file": "View workflow file", "actions.runs.view_workflow_file": "View workflow file",
"actions.runs.summary": "Summary", "actions.runs.summary": "Summary",
"actions.runs.all_jobs": "All jobs", "actions.runs.all_jobs": "All jobs",

View File

@@ -52,7 +52,7 @@
"jquery": "4.0.0", "jquery": "4.0.0",
"js-yaml": "4.2.0", "js-yaml": "4.2.0",
"katex": "0.17.0", "katex": "0.17.0",
"mermaid": "11.15.0", "mermaid": "11.16.1",
"online-3d-viewer": "0.18.0", "online-3d-viewer": "0.18.0",
"pdfobject": "2.3.1", "pdfobject": "2.3.1",
"perfect-debounce": "2.1.0", "perfect-debounce": "2.1.0",

24
pnpm-lock.yaml generated
View File

@@ -73,7 +73,7 @@ importers:
version: 0.1.0-rc2 version: 0.1.0-rc2
'@mermaid-js/layout-elk': '@mermaid-js/layout-elk':
specifier: 0.2.1 specifier: 0.2.1
version: 0.2.1(mermaid@11.15.0) version: 0.2.1(mermaid@11.16.1)
'@primer/octicons': '@primer/octicons':
specifier: 19.28.1 specifier: 19.28.1
version: 19.28.1 version: 19.28.1
@@ -147,8 +147,8 @@ importers:
specifier: 0.17.0 specifier: 0.17.0
version: 0.17.0 version: 0.17.0
mermaid: mermaid:
specifier: 11.15.0 specifier: 11.16.1
version: 11.15.0 version: 11.16.1
online-3d-viewer: online-3d-viewer:
specifier: 0.18.0 specifier: 0.18.0
version: 0.18.0 version: 0.18.0
@@ -942,8 +942,8 @@ packages:
peerDependencies: peerDependencies:
mermaid: ^11.0.2 mermaid: ^11.0.2
'@mermaid-js/parser@1.1.1': '@mermaid-js/parser@1.2.0':
resolution: {integrity: sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==} resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==}
'@napi-rs/wasm-runtime@1.1.6': '@napi-rs/wasm-runtime@1.1.6':
resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
@@ -3301,8 +3301,8 @@ packages:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
mermaid@11.15.0: mermaid@11.16.1:
resolution: {integrity: sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==} resolution: {integrity: sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g==}
micromark-core-commonmark@2.0.3: micromark-core-commonmark@2.0.3:
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
@@ -5179,13 +5179,13 @@ snapshots:
- supports-color - supports-color
- utf-8-validate - utf-8-validate
'@mermaid-js/layout-elk@0.2.1(mermaid@11.15.0)': '@mermaid-js/layout-elk@0.2.1(mermaid@11.16.1)':
dependencies: dependencies:
d3: 7.9.0 d3: 7.9.0
elkjs: 0.9.3 elkjs: 0.9.3
mermaid: 11.15.0 mermaid: 11.16.1
'@mermaid-js/parser@1.1.1': '@mermaid-js/parser@1.2.0':
dependencies: dependencies:
'@chevrotain/types': 11.1.2 '@chevrotain/types': 11.1.2
@@ -7764,11 +7764,11 @@ snapshots:
merge2@1.4.1: {} merge2@1.4.1: {}
mermaid@11.15.0: mermaid@11.16.1:
dependencies: dependencies:
'@braintree/sanitize-url': 7.1.2 '@braintree/sanitize-url': 7.1.2
'@iconify/utils': 3.1.3 '@iconify/utils': 3.1.3
'@mermaid-js/parser': 1.1.1 '@mermaid-js/parser': 1.2.0
'@types/d3': 7.4.3 '@types/d3': 7.4.3
'@upsetjs/venn.js': 2.0.0 '@upsetjs/venn.js': 2.0.0
cytoscape: 3.33.4 cytoscape: 3.33.4

View File

@@ -66,6 +66,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"slices"
"strconv" "strconv"
"strings" "strings"
@@ -74,7 +75,6 @@ import (
"gitea.dev/modules/httplib" "gitea.dev/modules/httplib"
"gitea.dev/modules/json" "gitea.dev/modules/json"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
"gitea.dev/modules/storage" "gitea.dev/modules/storage"
"gitea.dev/modules/util" "gitea.dev/modules/util"
@@ -336,15 +336,19 @@ type (
) )
func (ar artifactRoutes) listArtifacts(ctx *ArtifactContext) { func (ar artifactRoutes) listArtifacts(ctx *ArtifactContext) {
_, runID, ok := validateRunID(ctx) task, runID, ok := validateRunID(ctx)
if !ok {
return
}
attemptIDs, ok := readableArtifactAttemptIDs(ctx, task)
if !ok { if !ok {
return return
} }
artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{ artifacts, err := actions.FindReadableArtifacts(ctx, actions.FindArtifactsOptions{
RunID: runID, RunID: runID,
RunAttemptID: optional.Some(ctx.ActionTask.Job.RunAttemptID), RunAttemptIDs: attemptIDs,
Status: int(actions.ArtifactStatusUploadConfirmed), Status: int(actions.ArtifactStatusUploadConfirmed),
}) })
if err != nil { if err != nil {
log.Error("Error getting artifacts: %v", err) log.Error("Error getting artifacts: %v", err)
@@ -397,7 +401,7 @@ type (
// getDownloadArtifactURL generates download url for each artifact // getDownloadArtifactURL generates download url for each artifact
func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) { func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) {
_, runID, ok := validateRunID(ctx) task, runID, ok := validateRunID(ctx)
if !ok { if !ok {
return return
} }
@@ -407,11 +411,16 @@ func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) {
return return
} }
artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{ attemptIDs, ok := readableArtifactAttemptIDs(ctx, task)
RunID: runID, if !ok {
RunAttemptID: optional.Some(ctx.ActionTask.Job.RunAttemptID), return
ArtifactName: itemPath, }
Status: int(actions.ArtifactStatusUploadConfirmed),
artifacts, err := actions.FindReadableArtifacts(ctx, actions.FindArtifactsOptions{
RunID: runID,
RunAttemptIDs: attemptIDs,
ArtifactName: itemPath,
Status: int(actions.ArtifactStatusUploadConfirmed),
}) })
if err != nil { if err != nil {
log.Error("Error getting artifacts: %v", err) log.Error("Error getting artifacts: %v", err)
@@ -461,7 +470,7 @@ func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) {
// downloadArtifact downloads artifact content // downloadArtifact downloads artifact content
func (ar artifactRoutes) downloadArtifact(ctx *ArtifactContext) { func (ar artifactRoutes) downloadArtifact(ctx *ArtifactContext) {
_, runID, ok := validateRunID(ctx) task, runID, ok := validateRunID(ctx)
if !ok { if !ok {
return return
} }
@@ -483,10 +492,17 @@ func (ar artifactRoutes) downloadArtifact(ctx *ArtifactContext) {
ctx.HTTPError(http.StatusBadRequest) ctx.HTTPError(http.StatusBadRequest)
return return
} }
if ctx.ActionTask.Job.RunAttemptID > 0 && artifact.RunAttemptID != ctx.ActionTask.Job.RunAttemptID { // resolving the readable attempts costs a query, and an artifact of the task's own attempt never needs it
log.Error("Error mismatch runAttemptID and artifactID, task: %v, artifact: %v", ctx.ActionTask.Job.RunAttemptID, artifactID) if artifact.RunAttemptID != task.Job.RunAttemptID {
ctx.HTTPError(http.StatusBadRequest) attemptIDs, ok := readableArtifactAttemptIDs(ctx, task)
return if !ok {
return
}
if !slices.Contains(attemptIDs, artifact.RunAttemptID) {
log.Error("Error artifact %d belongs to run attempt %d, which the task cannot read: %v", artifactID, artifact.RunAttemptID, attemptIDs)
ctx.HTTPError(http.StatusBadRequest)
return
}
} }
if artifact.Status != actions.ArtifactStatusUploadConfirmed { if artifact.Status != actions.ArtifactStatusUploadConfirmed {
log.Error("Error artifact not found: %s", artifact.Status.ToString()) log.Error("Error artifact not found: %s", artifact.Status.ToString())

View File

@@ -20,7 +20,6 @@ import (
"gitea.dev/models/actions" "gitea.dev/models/actions"
"gitea.dev/models/db" "gitea.dev/models/db"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
"gitea.dev/modules/storage" "gitea.dev/modules/storage"
) )
@@ -261,9 +260,9 @@ func listOrderedChunksForArtifact(st storage.ObjectStorage, runID, artifactID in
func mergeChunksForRun(ctx *ArtifactContext, st storage.ObjectStorage, runID, runAttemptID int64, artifactName string) error { func mergeChunksForRun(ctx *ArtifactContext, st storage.ObjectStorage, runID, runAttemptID int64, artifactName string) error {
// read all db artifacts by name // read all db artifacts by name
artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{ artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{
RunID: runID, RunID: runID,
RunAttemptID: optional.Some(runAttemptID), RunAttemptIDs: []int64{runAttemptID},
ArtifactName: artifactName, ArtifactName: artifactName,
}) })
if err != nil { if err != nil {
return err return err

View File

@@ -43,7 +43,7 @@ func validateRunID(ctx *ArtifactContext) (*actions.ActionTask, int64, bool) {
return task, runID, true return task, runID, true
} }
func validateRunIDV4(ctx *ArtifactContext, rawRunID string) (*actions.ActionTask, int64, bool) { //nolint:unparam // ActionTask is never used func validateRunIDV4(ctx *ArtifactContext, rawRunID string) (*actions.ActionTask, int64, bool) {
task := ctx.ActionTask task := ctx.ActionTask
runID, err := strconv.ParseInt(rawRunID, 10, 64) runID, err := strconv.ParseInt(rawRunID, 10, 64)
if err != nil || task.Job.RunID != runID { if err != nil || task.Job.RunID != runID {
@@ -54,6 +54,18 @@ func validateRunIDV4(ctx *ArtifactContext, rawRunID string) (*actions.ActionTask
return task, runID, true return task, runID, true
} }
// readableArtifactAttemptIDs resolves the attempts a task may read artifacts from:
// its own attempt, plus the attempts it inherits from when only a subset of the run's jobs was re-run.
func readableArtifactAttemptIDs(ctx *ArtifactContext, task *actions.ActionTask) ([]int64, bool) {
attemptIDs, err := actions.GetArtifactAttemptIDs(ctx, task.Job)
if err != nil {
log.Error("Error getting readable artifact attempts: %v", err)
ctx.HTTPError(http.StatusInternalServerError, "Error getting readable artifact attempts")
return nil, false
}
return attemptIDs, true
}
func validateArtifactHash(ctx *ArtifactContext, artifactName string) bool { func validateArtifactHash(ctx *ArtifactContext, artifactName string) bool {
paramHash := ctx.PathParam("artifact_hash") paramHash := ctx.PathParam("artifact_hash")
// use artifact name to create upload url // use artifact name to create upload url

View File

@@ -107,7 +107,6 @@ import (
actions_module "gitea.dev/modules/actions" actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/httplib" "gitea.dev/modules/httplib"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
"gitea.dev/modules/storage" "gitea.dev/modules/storage"
"gitea.dev/modules/util" "gitea.dev/modules/util"
@@ -262,9 +261,28 @@ func (r *artifactV4Routes) verifySignature(ctx *ArtifactContext, endp string) (*
return task, artifactName, true return task, artifactName, true
} }
func (r *artifactV4Routes) getArtifactByName(ctx *ArtifactContext, runID, runAttemptID int64, name string) (*actions_model.ActionArtifact, error) { // getOwnAttemptArtifactByName resolves an artifact of the given attempt whatever its status,
// since upload and finalize work on the pending row they just created.
func (r *artifactV4Routes) getOwnAttemptArtifactByName(ctx *ArtifactContext, runID, runAttemptID int64, name string) (*actions_model.ActionArtifact, error) {
return r.findArtifactByName(ctx, runID, []int64{runAttemptID}, name, nil)
}
// getDownloadableArtifactByName resolves the newest artifact with the given name within the attempts whose content can still be served,
// so a pending, deleted or expired row of a newer attempt does not shadow the confirmed copy inherited from an older one.
func (r *artifactV4Routes) getDownloadableArtifactByName(ctx *ArtifactContext, runID int64, runAttemptIDs []int64, name string) (*actions_model.ActionArtifact, error) {
return r.findArtifactByName(ctx, runID, runAttemptIDs, name, builder.Eq{"status": actions_model.ArtifactStatusUploadConfirmed})
}
func (r *artifactV4Routes) findArtifactByName(ctx *ArtifactContext, runID int64, runAttemptIDs []int64, name string, extraCond builder.Cond) (*actions_model.ActionArtifact, error) {
cond := builder.NewCond().
And(builder.Eq{"run_id": runID, "artifact_name": name}, builder.Like{"content_encoding", "%/%"}).
And(builder.In("run_attempt_id", runAttemptIDs))
if extraCond != nil {
cond = cond.And(extraCond)
}
var art actions_model.ActionArtifact var art actions_model.ActionArtifact
has, err := db.GetEngine(ctx).Where(builder.Eq{"run_id": runID, "run_attempt_id": runAttemptID, "artifact_name": name}, builder.Like{"content_encoding", "%/%"}).Get(&art) has, err := db.GetEngine(ctx).Where(cond).OrderBy("run_attempt_id DESC, id DESC").Get(&art)
if err != nil { if err != nil {
return nil, err return nil, err
} else if !has { } else if !has {
@@ -384,7 +402,7 @@ func (r *artifactV4Routes) uploadArtifact(ctx *ArtifactContext) {
switch comp { switch comp {
case "block", "appendBlock": case "block", "appendBlock":
// get artifact by name // get artifact by name
artifact, err := r.getArtifactByName(ctx, task.Job.RunID, task.Job.RunAttemptID, artifactName) artifact, err := r.getOwnAttemptArtifactByName(ctx, task.Job.RunID, task.Job.RunAttemptID, artifactName)
if err != nil { if err != nil {
log.Error("Error artifact not found: %v", err) log.Error("Error artifact not found: %v", err)
ctx.HTTPError(http.StatusNotFound, "Error artifact not found") ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
@@ -471,7 +489,7 @@ func (r *artifactV4Routes) finalizeArtifact(ctx *ArtifactContext) {
} }
// get artifact by name // get artifact by name
artifact, err := r.getArtifactByName(ctx, runID, ctx.ActionTask.Job.RunAttemptID, req.Name) artifact, err := r.getOwnAttemptArtifactByName(ctx, runID, ctx.ActionTask.Job.RunAttemptID, req.Name)
if err != nil { if err != nil {
log.Error("Error artifact not found: %v", err) log.Error("Error artifact not found: %v", err)
ctx.HTTPError(http.StatusNotFound, "Error artifact not found") ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
@@ -578,14 +596,18 @@ func (r *artifactV4Routes) listArtifacts(ctx *ArtifactContext) {
if ok := r.parseProtobufBody(ctx, &req); !ok { if ok := r.parseProtobufBody(ctx, &req); !ok {
return return
} }
_, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId) task, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
if !ok {
return
}
attemptIDs, ok := readableArtifactAttemptIDs(ctx, task)
if !ok { if !ok {
return return
} }
artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{ artifacts, err := actions_model.FindReadableArtifacts(ctx, actions_model.FindArtifactsOptions{
RunID: runID, RunID: runID,
RunAttemptID: optional.Some(ctx.ActionTask.Job.RunAttemptID), RunAttemptIDs: attemptIDs,
Status: int(actions_model.ArtifactStatusUploadConfirmed), Status: int(actions_model.ArtifactStatusUploadConfirmed),
FinalizedArtifactsV4: true, FinalizedArtifactsV4: true,
}) })
@@ -597,6 +619,8 @@ func (r *artifactV4Routes) listArtifacts(ctx *ArtifactContext) {
list := []*ListArtifactsResponse_MonolithArtifact{} list := []*ListArtifactsResponse_MonolithArtifact{}
// both filters pick from what this attempt may read, so they run after the shadowed artifacts are gone:
// a shadowed artifact is not downloadable either, GetSignedArtifactURL resolves by name
table := map[string]*ListArtifactsResponse_MonolithArtifact{} table := map[string]*ListArtifactsResponse_MonolithArtifact{}
for _, artifact := range artifacts { for _, artifact := range artifacts {
if _, ok := table[artifact.ArtifactName]; ok || req.IdFilter != nil && artifact.ID != req.IdFilter.Value || req.NameFilter != nil && artifact.ArtifactName != req.NameFilter.Value { if _, ok := table[artifact.ArtifactName]; ok || req.IdFilter != nil && artifact.ID != req.IdFilter.Value || req.NameFilter != nil && artifact.ArtifactName != req.NameFilter.Value {
@@ -631,7 +655,11 @@ func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) {
if ok := r.parseProtobufBody(ctx, &req); !ok { if ok := r.parseProtobufBody(ctx, &req); !ok {
return return
} }
_, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId) task, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
if !ok {
return
}
attemptIDs, ok := readableArtifactAttemptIDs(ctx, task)
if !ok { if !ok {
return return
} }
@@ -639,17 +667,12 @@ func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) {
artifactName := req.Name artifactName := req.Name
// get artifact by name // get artifact by name
artifact, err := r.getArtifactByName(ctx, runID, ctx.ActionTask.Job.RunAttemptID, artifactName) artifact, err := r.getDownloadableArtifactByName(ctx, runID, attemptIDs, artifactName)
if err != nil { if err != nil {
log.Error("Error artifact not found: %v", err) log.Error("Error artifact not found: %v", err)
ctx.HTTPError(http.StatusNotFound, "Error artifact not found") ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
return return
} }
if artifact.Status != actions_model.ArtifactStatusUploadConfirmed {
log.Error("Error artifact not found: %s", artifact.Status.ToString())
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
return
}
respData := GetSignedArtifactURLResponse{} respData := GetSignedArtifactURLResponse{}
@@ -671,16 +694,15 @@ func (r *artifactV4Routes) downloadArtifact(ctx *ArtifactContext) {
if !ok { if !ok {
return return
} }
attemptIDs, ok := readableArtifactAttemptIDs(ctx, task)
// get artifact by name if !ok {
artifact, err := r.getArtifactByName(ctx, task.Job.RunID, task.Job.RunAttemptID, artifactName)
if err != nil {
log.Error("Error artifact not found: %v", err)
ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
return return
} }
if artifact.Status != actions_model.ArtifactStatusUploadConfirmed {
log.Error("Error artifact not found: %s", artifact.Status.ToString()) // get artifact by name
artifact, err := r.getDownloadableArtifactByName(ctx, task.Job.RunID, attemptIDs, artifactName)
if err != nil {
log.Error("Error artifact not found: %v", err)
ctx.HTTPError(http.StatusNotFound, "Error artifact not found") ctx.HTTPError(http.StatusNotFound, "Error artifact not found")
return return
} }
@@ -704,7 +726,7 @@ func (r *artifactV4Routes) deleteArtifact(ctx *ArtifactContext) {
} }
// get artifact by name // get artifact by name
artifact, err := r.getArtifactByName(ctx, runID, ctx.ActionTask.Job.RunAttemptID, req.Name) artifact, err := r.getOwnAttemptArtifactByName(ctx, runID, ctx.ActionTask.Job.RunAttemptID, req.Name)
if err != nil { if err != nil {
log.Error("Error artifact not found: %v", err) log.Error("Error artifact not found: %v", err)
ctx.HTTPError(http.StatusNotFound, "Error artifact not found") ctx.HTTPError(http.StatusNotFound, "Error artifact not found")

View File

@@ -67,15 +67,34 @@ func GetRepositoryFile(ctx *context.Context) {
return return
} }
branch := ctx.PathParam("branch")
repository := ctx.PathParam("repository")
architecture := ctx.PathParam("architecture")
s, u, pf, err := packages_service.OpenFileForDownloadByPackageVersion( s, u, pf, err := packages_service.OpenFileForDownloadByPackageVersion(
ctx, ctx,
pv, pv,
&packages_service.PackageFileInfo{ &packages_service.PackageFileInfo{
Filename: alpine_service.IndexArchiveFilename, Filename: alpine_service.IndexArchiveFilename,
CompositeKey: fmt.Sprintf("%s|%s|%s", ctx.PathParam("branch"), ctx.PathParam("repository"), ctx.PathParam("architecture")), CompositeKey: fmt.Sprintf("%s|%s|%s", branch, repository, architecture),
}, },
ctx.Req.Method, ctx.Req.Method,
) )
// A repository that only contains "noarch" packages has no per-architecture
// index. Since noarch packages are installable on every architecture, fall
// back to the noarch index so clients requesting their own architecture
// (e.g. x86_64) can still discover them.
if errors.Is(err, util.ErrNotExist) && architecture != alpine_module.NoArch {
s, u, pf, err = packages_service.OpenFileForDownloadByPackageVersion(
ctx,
pv,
&packages_service.PackageFileInfo{
Filename: alpine_service.IndexArchiveFilename,
CompositeKey: fmt.Sprintf("%s|%s|%s", branch, repository, alpine_module.NoArch),
},
ctx.Req.Method,
)
}
if err != nil { if err != nil {
if errors.Is(err, util.ErrNotExist) { if errors.Is(err, util.ErrNotExist) {
apiError(ctx, http.StatusNotFound, err) apiError(ctx, http.StatusNotFound, err)

View File

@@ -135,7 +135,7 @@ func CommonRoutes() *web.Router {
r.Group("/{branch}/{repository}", func() { r.Group("/{branch}/{repository}", func() {
r.Put("", reqPackageAccess(perm.AccessModeWrite), alpine.UploadPackageFile) r.Put("", reqPackageAccess(perm.AccessModeWrite), alpine.UploadPackageFile)
r.Group("/{architecture}", func() { r.Group("/{architecture}", func() {
r.Get("/APKINDEX.tar.gz", alpine.GetRepositoryFile) r.Methods("HEAD,GET", "/APKINDEX.tar.gz", alpine.GetRepositoryFile)
r.Group("/{filename}", func() { r.Group("/{filename}", func() {
r.Get("", alpine.DownloadPackageFile) r.Get("", alpine.DownloadPackageFile)
r.Delete("", reqPackageAccess(perm.AccessModeWrite), alpine.DeletePackageFile) r.Delete("", reqPackageAccess(perm.AccessModeWrite), alpine.DeletePackageFile)

View File

@@ -9,6 +9,7 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"sort" "sort"
"time"
packages_model "gitea.dev/models/packages" packages_model "gitea.dev/models/packages"
npm_module "gitea.dev/modules/packages/npm" npm_module "gitea.dev/modules/packages/npm"
@@ -22,8 +23,14 @@ func createPackageMetadataResponse(registryURL string, pds []*packages_model.Pac
versions := make(map[string]*npm_module.PackageMetadataVersion) versions := make(map[string]*npm_module.PackageMetadataVersion)
distTags := make(map[string]string) distTags := make(map[string]string)
times := make(map[string]time.Time)
firstPublished, lastPublished := pds[0].Version.CreatedUnix, pds[0].Version.CreatedUnix
for _, pd := range pds { for _, pd := range pds {
versions[pd.SemVer.String()] = createPackageMetadataVersion(registryURL, pd) semVer := pd.SemVer.String()
versions[semVer] = createPackageMetadataVersion(registryURL, pd)
times[semVer] = pd.Version.CreatedUnix.AsTimeInLocation(time.UTC)
firstPublished = min(firstPublished, pd.Version.CreatedUnix)
lastPublished = max(lastPublished, pd.Version.CreatedUnix)
for _, pvp := range pd.VersionProperties { for _, pvp := range pd.VersionProperties {
if pvp.Name == npm_module.TagProperty { if pvp.Name == npm_module.TagProperty {
@@ -32,6 +39,10 @@ func createPackageMetadataResponse(registryURL string, pds []*packages_model.Pac
} }
} }
// npm derives both from the versions currently served, so a deletion moves them
times["created"] = firstPublished.AsTimeInLocation(time.UTC)
times["modified"] = lastPublished.AsTimeInLocation(time.UTC)
latest := pds[len(pds)-1] latest := pds[len(pds)-1]
metadata := latest.Metadata.(*npm_module.Metadata) metadata := latest.Metadata.(*npm_module.Metadata)
@@ -42,7 +53,10 @@ func createPackageMetadataResponse(registryURL string, pds []*packages_model.Pac
DistTags: distTags, DistTags: distTags,
Description: metadata.Description, Description: metadata.Description,
Readme: metadata.Readme, Readme: metadata.Readme,
Maintainers: []npm_module.User{{Name: latest.Owner.Name}},
Time: times,
Homepage: metadata.ProjectURL, Homepage: metadata.ProjectURL,
Keywords: metadata.Keywords,
Author: npm_module.User{Name: metadata.Author}, Author: npm_module.User{Name: metadata.Author},
License: metadata.License, License: metadata.License,
Versions: versions, Versions: versions,
@@ -61,8 +75,10 @@ func createPackageMetadataVersion(registryURL string, pd *packages_model.Package
Version: pd.Version.Version, Version: pd.Version.Version,
Description: metadata.Description, Description: metadata.Description,
Author: npm_module.User{Name: metadata.Author}, Author: npm_module.User{Name: metadata.Author},
Maintainers: []npm_module.User{{Name: pd.Owner.Name}},
Homepage: metadata.ProjectURL, Homepage: metadata.ProjectURL,
License: metadata.License, License: metadata.License,
Keywords: metadata.Keywords,
Dependencies: metadata.Dependencies, Dependencies: metadata.Dependencies,
BundleDependencies: metadata.BundleDependencies, BundleDependencies: metadata.BundleDependencies,
DevDependencies: metadata.DevelopmentDependencies, DevDependencies: metadata.DevelopmentDependencies,

View File

@@ -0,0 +1,46 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package npm
import (
"testing"
"time"
packages_model "gitea.dev/models/packages"
user_model "gitea.dev/models/user"
npm_module "gitea.dev/modules/packages/npm"
"gitea.dev/modules/timeutil"
"github.com/hashicorp/go-version"
"github.com/stretchr/testify/assert"
)
func TestCreatePackageMetadataResponse(t *testing.T) {
descriptor := func(v string, publishedUnix int64) *packages_model.PackageDescriptor {
return &packages_model.PackageDescriptor{
Package: &packages_model.Package{Name: "test"},
Owner: &user_model.User{Name: "alice"},
Version: &packages_model.PackageVersion{Version: v, CreatedUnix: timeutil.TimeStamp(publishedUnix)},
SemVer: version.Must(version.NewVersion(v)),
Metadata: &npm_module.Metadata{Keywords: []string{"gitea"}},
Files: []*packages_model.PackageFileDescriptor{{File: &packages_model.PackageFile{}, Blob: &packages_model.PackageBlob{}}},
}
}
result := createPackageMetadataResponse("https://gitea.dev/api/packages/alice/npm", []*packages_model.PackageDescriptor{
descriptor("1.1.0", 1000),
descriptor("1.0.0", 2000),
})
assert.Equal(t, map[string]time.Time{
"1.0.0": time.Unix(2000, 0).UTC(),
"1.1.0": time.Unix(1000, 0).UTC(),
"created": time.Unix(1000, 0).UTC(),
"modified": time.Unix(2000, 0).UTC(),
}, result.Time)
assert.Equal(t, []npm_module.User{{Name: "alice"}}, result.Maintainers)
assert.Equal(t, []string{"gitea"}, result.Keywords)
assert.Equal(t, []string{"gitea"}, result.Versions["1.0.0"].Keywords)
assert.Equal(t, []npm_module.User{{Name: "alice"}}, result.Versions["1.0.0"].Maintainers)
}

View File

@@ -4,8 +4,9 @@
package misc package misc
import ( import (
"gitea.dev/modules/markup" "io"
"gitea.dev/modules/markup/markdown"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs" api "gitea.dev/modules/structs"
"gitea.dev/modules/util" "gitea.dev/modules/util"
"gitea.dev/modules/web" "gitea.dev/modules/web"
@@ -84,9 +85,6 @@ func MarkdownRaw(ctx *context.APIContext) {
// "$ref": "#/responses/MarkdownRender" // "$ref": "#/responses/MarkdownRender"
// "422": // "422":
// "$ref": "#/responses/validationError" // "$ref": "#/responses/validationError"
defer ctx.Req.Body.Close() textBytes, _ := io.ReadAll(io.LimitReader(ctx.Req.Body, setting.UI.MaxDisplayFileSize))
if err := markdown.RenderRaw(markup.NewRenderContext(ctx), ctx.Req.Body, ctx.Resp); err != nil { common.RenderMarkup(ctx.Base, ctx.Repo, "markdown", util.UnsafeBytesToString(textBytes), "", "")
ctx.APIErrorInternal(err)
return
}
} }

View File

@@ -7,19 +7,15 @@ import (
go_context "context" go_context "context"
"io" "io"
"net/http" "net/http"
"os"
"path" "path"
"strings" "strings"
"testing" "testing"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
"gitea.dev/modules/markup" "gitea.dev/modules/markup"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
api "gitea.dev/modules/structs" api "gitea.dev/modules/structs"
"gitea.dev/modules/test" "gitea.dev/modules/test"
"gitea.dev/modules/web" "gitea.dev/modules/web"
context_service "gitea.dev/services/context"
"gitea.dev/services/contexttest" "gitea.dev/services/contexttest"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -27,13 +23,6 @@ import (
const AppURL = "http://localhost:3000/" const AppURL = "http://localhost:3000/"
func TestMain(m *testing.M) {
unittest.MainTest(m, &unittest.TestOptions{
FixtureFiles: []string{"repository.yml", "user.yml"},
})
os.Exit(m.Run())
}
func testRenderMarkup(t *testing.T, mode string, wiki bool, filePath, text, expectedBody string, expectedCode int) { func testRenderMarkup(t *testing.T, mode string, wiki bool, filePath, text, expectedBody string, expectedCode int) {
setting.AppURL = AppURL setting.AppURL = AppURL
defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)() defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
@@ -49,13 +38,11 @@ func testRenderMarkup(t *testing.T, mode string, wiki bool, filePath, text, expe
FilePath: filePath, FilePath: filePath,
} }
ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markup") ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markup")
ctx.Repo = &context_service.Repository{}
ctx.Repo.Repository = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
web.SetForm(ctx, &options) web.SetForm(ctx, &options)
Markup(ctx) Markup(ctx)
assert.Equal(t, expectedBody, resp.Body.String()) assert.Equal(t, expectedBody, resp.Body.String())
assert.Equal(t, expectedCode, resp.Code) assert.Equal(t, expectedCode, resp.Code)
resp.Body.Reset() assert.Contains(t, resp.Header().Get("Content-Security-Policy"), "script-src * 'nonce-")
} }
func testRenderMarkdown(t *testing.T, mode string, wiki bool, text, responseBody string, responseCode int) { func testRenderMarkdown(t *testing.T, mode string, wiki bool, text, responseBody string, responseCode int) {
@@ -76,11 +63,10 @@ func testRenderMarkdown(t *testing.T, mode string, wiki bool, text, responseBody
Markdown(ctx) Markdown(ctx)
assert.Equal(t, responseBody, resp.Body.String()) assert.Equal(t, responseBody, resp.Body.String())
assert.Equal(t, responseCode, resp.Code) assert.Equal(t, responseCode, resp.Code)
resp.Body.Reset() assert.Contains(t, resp.Header().Get("Content-Security-Policy"), "script-src * 'nonce-")
} }
func TestAPI_RenderGFM(t *testing.T) { func TestAPI_RenderGFM(t *testing.T) {
unittest.PrepareTestEnv(t)
markup.Init(&markup.RenderHelperFuncs{ markup.Init(&markup.RenderHelperFuncs{
IsUsernameMentionable: func(ctx go_context.Context, username string) bool { IsUsernameMentionable: func(ctx go_context.Context, username string) bool {
return username == "r-lyeh" return username == "r-lyeh"
@@ -177,49 +163,34 @@ Here are some links to the most important topics. You can find the full list of
testRenderMarkup(t, "unknown", false, "", "## Test", "unsupported render mode: unknown\n", http.StatusUnprocessableEntity) testRenderMarkup(t, "unknown", false, "", "## Test", "unsupported render mode: unknown\n", http.StatusUnprocessableEntity)
} }
var simpleCases = []string{
// Guard wiki sidebar: special syntax
`[[Guardfile-DSL / Configuring-Guard|Guardfile-DSL---Configuring-Guard]]`,
// rendered
`<p>[[Guardfile-DSL / Configuring-Guard|Guardfile-DSL---Configuring-Guard]]</p>
`,
// special syntax
`[[Name|Link]]`,
// rendered
`<p>[[Name|Link]]</p>
`,
// empty
``,
// rendered
``,
}
func TestAPI_RenderSimple(t *testing.T) { func TestAPI_RenderSimple(t *testing.T) {
setting.AppURL = AppURL setting.AppURL = AppURL
markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true
options := api.MarkdownOption{
Mode: "markdown",
Text: "",
Context: "/user2/repo1",
}
ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markdown")
for i := 0; i < len(simpleCases); i += 2 {
options.Text = simpleCases[i]
web.SetForm(ctx, &options)
Markdown(ctx)
assert.Equal(t, simpleCases[i+1], resp.Body.String())
resp.Body.Reset()
}
}
func TestAPI_RenderRaw(t *testing.T) { testCases := []struct {
setting.AppURL = AppURL in, out string
markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true mode string
ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markdown") }{
for i := 0; i < len(simpleCases); i += 2 { {in: "", out: ""},
ctx.Req.Body = io.NopCloser(strings.NewReader(simpleCases[i])) {in: "[[special-syntax]]", out: "<p>[[special-syntax]]</p>\n", mode: "markdown"},
MarkdownRaw(ctx) {in: "[[special|syntax]]", out: "<p>[[special|syntax]]</p>\n", mode: "markdown"},
assert.Equal(t, simpleCases[i+1], resp.Body.String()) {in: "01234567890123456789", out: "<p>01234567890123456789</p>\n", mode: "gfm"}, // commit-like content should not crash the render
resp.Body.Reset()
} }
t.Run("markdown", func(t *testing.T) {
for _, c := range testCases {
options := api.MarkdownOption{Mode: c.mode, Text: c.in, Context: "/user2/repo1"}
ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markdown")
web.SetForm(ctx, &options)
Markdown(ctx)
assert.Equal(t, c.out, resp.Body.String())
}
})
t.Run("markdown-raw", func(t *testing.T) {
for _, c := range testCases {
ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markdown")
ctx.Req.Body = io.NopCloser(strings.NewReader(c.in))
MarkdownRaw(ctx)
assert.Equal(t, c.out, resp.Body.String())
}
})
} }

View File

@@ -1503,7 +1503,14 @@ func RerunFailedWorkflowRun(ctx *context.APIContext) {
return return
} }
if _, err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, ctx.Doer, actions_service.GetFailedJobsForRerun(jobs)); err != nil { failedJobs := actions_service.GetFailedJobsForRerun(jobs)
// Empty failedJobs means no failed jobs to re-run
if len(failedJobs) == 0 {
ctx.APIError(http.StatusBadRequest, "this workflow run has no failed jobs to re-run")
return
}
if _, err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, ctx.Doer, failedJobs); err != nil {
handleWorkflowRerunError(ctx, err) handleWorkflowRerunError(ctx, err)
return return
} }

View File

@@ -177,7 +177,7 @@ func TestHook(ctx *context.APIContext) {
commit := convert.ToPayloadCommit(ctx, ctx.Repo.Repository, ctx.Repo.Commit) commit := convert.ToPayloadCommit(ctx, ctx.Repo.Repository, ctx.Repo.Commit)
commitID := ctx.Repo.Commit.ID.String() commitID := ctx.Repo.Commit.ID.String()
if err := webhook_service.PrepareWebhook(ctx, hook, webhook_module.HookEventPush, &api.PushPayload{ if err := webhook_service.PrepareTestWebhook(ctx, hook, webhook_module.HookEventPush, &api.PushPayload{
Ref: ref, Ref: ref,
Before: commitID, Before: commitID,
After: commitID, After: commitID,

View File

@@ -300,7 +300,7 @@ func TopicSearch(ctx *context.APIContext) {
} }
ctx.SetTotalCountHeader(total) ctx.SetTotalCountHeader(total)
ctx.JSON(http.StatusOK, map[string]any{ ctx.JSON(http.StatusOK, api.TopicListResponse{
"topics": topicResponses, Topics: topicResponses,
}) })
} }

View File

@@ -348,7 +348,7 @@ type swaggerFileDeleteResponse struct {
// swagger:response TopicListResponse // swagger:response TopicListResponse
type swaggerTopicListResponse struct { type swaggerTopicListResponse struct {
// in: body // in: body
Body []api.TopicResponse `json:"body"` Body api.TopicListResponse `json:"body"`
} }
// TopicNames // TopicNames

View File

@@ -46,7 +46,7 @@ type swaggerResponseUserHeatmapData struct {
// swagger:response UserSettings // swagger:response UserSettings
type swaggerResponseUserSettings struct { type swaggerResponseUserSettings struct {
// in:body // in:body
Body []api.UserSettings `json:"body"` Body api.UserSettings `json:"body"`
} }
// BadgeList // BadgeList

View File

@@ -40,6 +40,7 @@ func renderServerErrorPage(w http.ResponseWriter, req *http.Request, respCode in
if acceptsHTML { if acceptsHTML {
err := templates.PageRenderer().HTML(outBuf, respCode, tmpl, ctxData, tmplCtx) err := templates.PageRenderer().HTML(outBuf, respCode, tmpl, ctxData, tmplCtx)
if err != nil { if err != nil {
log.Error("Failed to render error page template %s: %v", tmpl, err)
_, _ = w.Write([]byte("Internal server error but failed to render error page template, please collect error logs and report to Gitea issue tracker")) _, _ = w.Write([]byte("Internal server error but failed to render error page template, please collect error logs and report to Gitea issue tracker"))
return return
} }

View File

@@ -31,6 +31,8 @@ func RenderMarkup(ctx *context.Base, ctxRepo *context.Repository, mode, text, ur
// for example, when previewing file "/gitea/owner/repo/src/branch/features/feat-123/doc/CHANGE.md", then filePath is "doc/CHANGE.md" // for example, when previewing file "/gitea/owner/repo/src/branch/features/feat-123/doc/CHANGE.md", then filePath is "doc/CHANGE.md"
// and the urlPathContext is "/gitea/owner/repo/src/branch/features/feat-123/doc" // and the urlPathContext is "/gitea/owner/repo/src/branch/features/feat-123/doc"
ctx.SetHeaderContentSecurityPolicyGeneral()
if mode == "" || mode == "markdown" { if mode == "" || mode == "markdown" {
// raw Markdown doesn't do any special handling // raw Markdown doesn't do any special handling
// TODO: raw markdown doesn't do any link processing, so "urlPathContext" doesn't take effect // TODO: raw markdown doesn't do any link processing, so "urlPathContext" doesn't take effect
@@ -62,6 +64,7 @@ func RenderMarkup(ctx *context.Base, ctxRepo *context.Repository, mode, text, ur
treePath = path.Dir(filePath) // it is "doc" if filePath is "doc/CHANGE.md" treePath = path.Dir(filePath) // it is "doc" if filePath is "doc/CHANGE.md"
refPath = strings.Join(fields[3:], "/") // it is "branch/features/feat-12/doc" refPath = strings.Join(fields[3:], "/") // it is "branch/features/feat-12/doc"
refPath = strings.TrimSuffix(refPath, "/"+treePath) // now we get the correct branch path: "branch/features/feat-12" refPath = strings.TrimSuffix(refPath, "/"+treePath) // now we get the correct branch path: "branch/features/feat-12"
refPath = util.PathEscapeSegments(refPath)
} else if fields = strings.SplitN(repoLinkPath, "/", 3); len(fields) == 2 { } else if fields = strings.SplitN(repoLinkPath, "/", 3); len(fields) == 2 {
repoOwnerName, repoName = fields[0], fields[1] repoOwnerName, repoName = fields[0], fields[1]
} }

View File

@@ -73,12 +73,9 @@ func TwoFactorPost(ctx *context.Context) {
return return
} }
if ctx.Session.Get("linkAccount") != nil { if err = completePendingLinks(ctx, u); err != nil {
err = linkAccountFromContext(ctx, u) ctx.ServerError("completePendingLinks", err)
if err != nil { return
ctx.ServerError("UserSignIn", err)
return
}
} }
_ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true) _ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true)
@@ -145,6 +142,11 @@ func TwoFactorScratchPost(ctx *context.Context) {
return return
} }
if err = completePendingLinks(ctx, u); err != nil {
ctx.ServerError("completePendingLinks", err)
return
}
handleSignInFull(ctx, u, remember) handleSignInFull(ctx, u, remember)
if ctx.Written() { if ctx.Written() {
return return

View File

@@ -8,6 +8,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"html/template" "html/template"
"maps"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
@@ -329,46 +330,35 @@ func SignInPost(ctx *context.Context) {
// If this user is enrolled in 2FA TOTP, we can't sign the user in just yet. // If this user is enrolled in 2FA TOTP, we can't sign the user in just yet.
// Instead, redirect them to the 2FA authentication page. // Instead, redirect them to the 2FA authentication page.
hasTOTPtwofa, err := auth.HasTwoFactorByUID(ctx, u.ID) hasTwoFactor, err := auth.HasTwoFactorOrWebAuthn(ctx, u.ID)
if err != nil { if err != nil {
ctx.ServerError("UserSignIn", err) ctx.ServerError("HasTwoFactorOrWebAuthn", err)
return return
} }
if !hasTwoFactor {
// Check if the user has webauthn registration
hasWebAuthnTwofa, err := auth.HasWebAuthnRegistrationsByUID(ctx, u.ID)
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}
if !hasTOTPtwofa && !hasWebAuthnTwofa {
// No two-factor auth configured we can sign in the user
handleSignIn(ctx, u, form.Remember) handleSignIn(ctx, u, form.Remember)
return return
} }
updates := map[string]any{ handleTwoFactorRequired(ctx, u, form.Remember, nil)
// User will need to use 2FA TOTP or WebAuthn, save data }
"twofaUid": u.ID,
"twofaRemember": form.Remember, func handleTwoFactorRequired(ctx *context.Context, u *user_model.User, remember bool, extra map[string]any) {
} updates := map[string]any{"twofaUid": u.ID, "twofaRemember": remember}
if hasTOTPtwofa { maps.Copy(updates, extra)
// User will need to use WebAuthn, save data
updates["totpEnrolled"] = u.ID
}
if err := regenerateSession(ctx, nil, updates); err != nil { if err := regenerateSession(ctx, nil, updates); err != nil {
ctx.ServerError("UserSignIn: Unable to update session", err) ctx.ServerError("RegenerateSession", err)
return return
} }
hasWebAuthn, err := auth.HasWebAuthnRegistrationsByUID(ctx, u.ID)
// If we have WebAuthn redirect there first if err != nil {
if hasWebAuthnTwofa { ctx.ServerError("HasWebAuthnRegistrationsByUID", err)
return
}
if hasWebAuthn {
ctx.Redirect(setting.AppSubURL + "/user/webauthn") ctx.Redirect(setting.AppSubURL + "/user/webauthn")
return return
} }
// Fallback to 2FA
ctx.Redirect(setting.AppSubURL + "/user/two_factor") ctx.Redirect(setting.AppSubURL + "/user/two_factor")
} }
@@ -408,6 +398,7 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember bool) {
"twofaRemember", "twofaRemember",
"linkAccount", "linkAccount",
"linkAccountData", "linkAccountData",
"openidPendingURI",
}, map[string]any{ }, map[string]any{
session.KeyUID: u.ID, session.KeyUID: u.ID,
session.KeyUname: u.Name, session.KeyUname: u.Name,
@@ -484,20 +475,26 @@ func SignOut(ctx *context.Context) {
} }
func buildSignOutRedirectURL(ctx *context.Context) string { func buildSignOutRedirectURL(ctx *context.Context) string {
if ctx.Doer != nil && ctx.Doer.LoginType == auth.OAuth2 { if ctx.Doer != nil && shouldRedirectToOIDCEndSession(ctx) {
if s := buildOIDCEndSessionURL(ctx, ctx.Doer); s != "" { if s := buildOIDCEndSessionURL(ctx, ctx.Doer); s != "" {
return s return s
} }
} }
// The assumption is: if reverse proxy auth is enabled, then the users should only sign-in via reverse proxy auth. // The assumption is: if reverse proxy auth is enabled, then the users should only sign-in via reverse proxy auth.
// TODO: in the future, if we need to distinguish different sign-in methods, we need to save the sign-in method in session and check here
if setting.Service.EnableReverseProxyAuth && setting.ReverseProxyLogoutRedirect != "" { if setting.Service.EnableReverseProxyAuth && setting.ReverseProxyLogoutRedirect != "" {
return setting.ReverseProxyLogoutRedirect return setting.ReverseProxyLogoutRedirect
} }
return setting.AppSubURL + "/" return setting.AppSubURL + "/"
} }
// shouldRedirectToOIDCEndSession reports whether this session should end at the
// OIDC provider. Prefer the session sign-in method so an OAuth2-linked account
// that signed in with a password does not hit end_session_endpoint.
func shouldRedirectToOIDCEndSession(ctx *context.Context) bool {
return ctx.Session.Get(session.KeySignInMethod) == session.SignInMethodOAuth2
}
func prepareSignUpPageData(ctx *context.Context) bool { func prepareSignUpPageData(ctx *context.Context) bool {
ctx.Data["Title"] = ctx.Tr("sign_up") ctx.Data["Title"] = ctx.Tr("sign_up")
ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/sign_up" ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/sign_up"

View File

@@ -11,6 +11,7 @@ import (
"testing" "testing"
auth_model "gitea.dev/models/auth" auth_model "gitea.dev/models/auth"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/session" "gitea.dev/modules/session"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
@@ -154,16 +155,47 @@ func TestWebAuthOAuth2(t *testing.T) {
authSource, err := auth_model.GetActiveOAuth2SourceByAuthName(t.Context(), "oidc-auth-source") authSource, err := auth_model.GetActiveOAuth2SourceByAuthName(t.Context(), "oidc-auth-source")
require.NoError(t, err) require.NoError(t, err)
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("dummy-sid")} oauthUser := &user_model.User{ID: 1, LoginType: auth_model.OAuth2, LoginSource: authSource.ID}
ctx, resp := contexttest.MockContext(t, "/user/logout", mockOpt)
ctx.Doer = &user_model.User{ID: 1, LoginType: auth_model.OAuth2, LoginSource: authSource.ID} t.Run("OAuth2SignInRedirectsToOIDC", func(t *testing.T) {
SignOut(ctx) mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("dummy-sid-oauth")}
assert.Equal(t, http.StatusSeeOther, resp.Code) ctx, resp := contexttest.MockContext(t, "/user/logout", mockOpt)
u, err := url.Parse(test.RedirectURL(resp)) ctx.Doer = oauthUser
require.NoError(t, err) require.NoError(t, ctx.Session.Set(session.KeySignInMethod, session.SignInMethodOAuth2))
expectedValues := url.Values{"oidc-key": []string{"oidc-val"}, "post_logout_redirect_uri": []string{setting.AppURL}, "client_id": []string{"mock-client-id"}} SignOut(ctx)
assert.Equal(t, expectedValues, u.Query()) assert.Equal(t, http.StatusSeeOther, resp.Code)
u.RawQuery = "" u, err := url.Parse(test.RedirectURL(resp))
assert.Equal(t, "https://example.com/oidc-logout", u.String()) require.NoError(t, err)
expectedValues := url.Values{"oidc-key": []string{"oidc-val"}, "post_logout_redirect_uri": []string{setting.AppURL}, "client_id": []string{"mock-client-id"}}
assert.Equal(t, expectedValues, u.Query())
u.RawQuery = ""
assert.Equal(t, "https://example.com/oidc-logout", u.String())
})
t.Run("PasswordSignInSkipsOIDC", func(t *testing.T) {
// OAuth2-linked account signed in via password form must not hit end_session_endpoint.
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("dummy-sid-password")}
ctx, resp := contexttest.MockContext(t, "/user/logout", mockOpt)
ctx.Doer = oauthUser
SignOut(ctx)
assert.Equal(t, http.StatusSeeOther, resp.Code)
assert.Equal(t, "/", test.RedirectURL(resp))
})
}) })
} }
func TestOpenIDRequireTwoFactor(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("dummy-sid-openid")}
user32 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 32}) // has a webauthn credential
ctx, resp := contexttest.MockContext(t, "/user/openid/connect", mockOpt)
openIDRequireTwoFactor(ctx, user32, false, "https://example.com/id")
assert.Equal(t, "/user/webauthn", test.RedirectURL(resp))
unittest.AssertNotExistsBean(t, &user_model.UserOpenID{UID: user32.ID}) // not attached before the key answered
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
ctx, _ = contexttest.MockContext(t, "/user/openid/connect", mockOpt)
openIDRequireTwoFactor(ctx, user2, false, "https://example.com/id")
assert.False(t, ctx.Written())
}

View File

@@ -11,6 +11,7 @@ import (
"gitea.dev/models/auth" "gitea.dev/models/auth"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/session"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
"gitea.dev/modules/templates" "gitea.dev/modules/templates"
"gitea.dev/modules/util" "gitea.dev/modules/util"
@@ -147,15 +148,13 @@ func oauth2LinkAccount(ctx *context.Context, u *user_model.User, linkAccountData
// If this user is enrolled in 2FA, we can't sign the user in just yet. // If this user is enrolled in 2FA, we can't sign the user in just yet.
// Instead, redirect them to the 2FA authentication page. // Instead, redirect them to the 2FA authentication page.
// We deliberately ignore the skip local 2fa setting here because we are linking to a previous user here // We deliberately ignore the skip local 2fa setting here because we are linking to a previous user here
_, err := auth.GetTwoFactorByUID(ctx, u.ID) hasTwoFactor, err := auth.HasTwoFactorOrWebAuthn(ctx, u.ID)
if err != nil { if err != nil {
if !auth.IsErrTwoFactorNotEnrolled(err) { ctx.ServerError("UserLinkAccount", err)
ctx.ServerError("UserLinkAccount", err) return
return }
} if !hasTwoFactor {
if err := externalaccount.LinkAccountToUser(ctx, linkAccountData.AuthSourceID, u, linkAccountData.GothUser); err != nil {
err = externalaccount.LinkAccountToUser(ctx, linkAccountData.AuthSourceID, u, linkAccountData.GothUser)
if err != nil {
ctx.ServerError("UserLinkAccount", err) ctx.ServerError("UserLinkAccount", err)
return return
} }
@@ -169,24 +168,10 @@ func oauth2LinkAccount(ctx *context.Context, u *user_model.User, linkAccountData
return return
} }
if err := regenerateSession(ctx, nil, map[string]any{ handleTwoFactorRequired(ctx, u, remember, map[string]any{
// User needs to use 2FA, save data and redirect to 2FA page. "linkAccount": true,
"twofaUid": u.ID, session.KeySignInMethod: session.SignInMethodOAuth2,
"twofaRemember": remember, })
"linkAccount": true,
}); err != nil {
ctx.ServerError("RegenerateSession", err)
return
}
// If WebAuthn is enrolled -> Redirect to WebAuthn instead
regs, err := auth.GetWebAuthnCredentialsByUID(ctx, u.ID)
if err == nil && len(regs) > 0 {
ctx.Redirect(setting.AppSubURL + "/user/webauthn")
return
}
ctx.Redirect(setting.AppSubURL + "/user/two_factor")
} }
// LinkAccountPostRegister handle the creation of a new account for an external account using signUp // LinkAccountPostRegister handle the creation of a new account for an external account using signUp
@@ -277,6 +262,15 @@ func LinkAccountPostRegister(ctx *context.Context) {
handleSignIn(ctx, u, false) handleSignIn(ctx, u, false)
} }
func completePendingLinks(ctx *context.Context, user *user_model.User) error {
if ctx.Session.Get("linkAccount") != nil {
if err := linkAccountFromContext(ctx, user); err != nil {
return err
}
}
return openIDConnectFromContext(ctx, user)
}
func linkAccountFromContext(ctx *context.Context, user *user_model.User) error { func linkAccountFromContext(ctx *context.Context, user *user_model.User) error {
linkAccountData := oauth2GetLinkAccountData(ctx) linkAccountData := oauth2GetLinkAccountData(ctx)
if linkAccountData == nil { if linkAccountData == nil {

View File

@@ -361,12 +361,11 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
needs2FA := false needs2FA := false
if !authSource.TwoFactorShouldSkip() { if !authSource.TwoFactorShouldSkip() {
_, err := auth.GetTwoFactorByUID(ctx, u.ID) var err error
if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) { if needs2FA, err = auth.HasTwoFactorOrWebAuthn(ctx, u.ID); err != nil {
ctx.ServerError("UserSignIn", err) ctx.ServerError("UserSignIn", err)
return return
} }
needs2FA = err == nil
} }
oauth2Source := authSource.Cfg.(*oauth2.Source) oauth2Source := authSource.Cfg.(*oauth2.Source)
@@ -432,6 +431,7 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
session.KeyUID: u.ID, session.KeyUID: u.ID,
session.KeyUname: u.Name, session.KeyUname: u.Name,
session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth, session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth,
session.KeySignInMethod: session.SignInMethodOAuth2,
}); err != nil { }); err != nil {
ctx.ServerError("updateSession", err) ctx.ServerError("updateSession", err)
return return
@@ -453,23 +453,7 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
} }
} }
if err := regenerateSession(ctx, nil, map[string]any{ handleTwoFactorRequired(ctx, u, false, map[string]any{session.KeySignInMethod: session.SignInMethodOAuth2})
// User needs to use 2FA, save data and redirect to 2FA page.
"twofaUid": u.ID,
"twofaRemember": false,
}); err != nil {
ctx.ServerError("updateSession", err)
return
}
// If WebAuthn is enrolled -> Redirect to WebAuthn instead
regs, err := auth.GetWebAuthnCredentialsByUID(ctx, u.ID)
if err == nil && len(regs) > 0 {
ctx.Redirect(setting.AppSubURL + "/user/webauthn")
return
}
ctx.Redirect(setting.AppSubURL + "/user/two_factor")
} }
// OAuth2UserLoginCallback attempts to handle the callback from the OAuth2 provider and if successful // OAuth2UserLoginCallback attempts to handle the callback from the OAuth2 provider and if successful

View File

@@ -207,9 +207,24 @@ func IntrospectOAuth(ctx *context.Context) {
ctx.JSON(http.StatusOK, response) ctx.JSON(http.StatusOK, response)
} }
func oauthDoerAuthorizePreCheck(ctx *context.Context, formState string) bool {
if ctx.DoerNeedTwoFactorAuth() {
handleAuthorizeError(ctx, AuthorizeError{
ErrorCode: ErrorCodeAccessDenied,
ErrorDescription: "two-factor authentication is required",
State: formState,
}, "")
return false
}
return true
}
// AuthorizeOAuth manages authorize requests // AuthorizeOAuth manages authorize requests
func AuthorizeOAuth(ctx *context.Context) { func AuthorizeOAuth(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.AuthorizationForm) form := web.GetForm(ctx).(*forms.AuthorizationForm)
if !oauthDoerAuthorizePreCheck(ctx, form.State) {
return
}
errs := binding.Errors{} errs := binding.Errors{}
errs = form.Validate(ctx.Req, errs) errs = form.Validate(ctx.Req, errs)
if len(errs) > 0 { if len(errs) > 0 {
@@ -385,6 +400,10 @@ func AuthorizeOAuth(ctx *context.Context) {
// GrantApplicationOAuth manages the post request submitted when a user grants access to an application // GrantApplicationOAuth manages the post request submitted when a user grants access to an application
func GrantApplicationOAuth(ctx *context.Context) { func GrantApplicationOAuth(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.GrantApplicationForm) form := web.GetForm(ctx).(*forms.GrantApplicationForm)
if !oauthDoerAuthorizePreCheck(ctx, form.State) {
return
}
if ctx.Session.Get("client_id") != form.ClientID || ctx.Session.Get("state") != form.State || if ctx.Session.Get("client_id") != form.ClientID || ctx.Session.Get("state") != form.State ||
ctx.Session.Get("redirect_uri") != form.RedirectURI { ctx.Session.Get("redirect_uri") != form.RedirectURI {
ctx.HTTPError(http.StatusBadRequest) ctx.HTTPError(http.StatusBadRequest)

View File

@@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
auth_model "gitea.dev/models/auth"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/auth/openid" "gitea.dev/modules/auth/openid"
"gitea.dev/modules/log" "gitea.dev/modules/log"
@@ -26,6 +27,36 @@ const (
tplSignUpOID templates.TplName = "user/auth/signup_openid_register" tplSignUpOID templates.TplName = "user/auth/signup_openid_register"
) )
// the OpenID is attached only after the second factor passed, so a stolen password cannot leave one behind
func openIDRequireTwoFactor(ctx *context.Context, u *user_model.User, remember bool, pendingURI string) {
hasTwoFactor, err := auth_model.HasTwoFactorOrWebAuthn(ctx, u.ID)
if err != nil {
ctx.ServerError("HasTwoFactorOrWebAuthn", err)
return
}
if !hasTwoFactor {
return
}
handleTwoFactorRequired(ctx, u, remember, map[string]any{"openidPendingURI": pendingURI})
}
func openIDConnectFromContext(ctx *context.Context, u *user_model.User) error {
uri, _ := ctx.Session.Get("openidPendingURI").(string)
if uri == "" {
return nil
}
if err := ctx.Session.Delete("openidPendingURI"); err != nil {
return err
}
if err := user_model.AddUserOpenID(ctx, &user_model.UserOpenID{UID: u.ID, URI: uri}); err != nil {
if !user_model.IsErrOpenIDAlreadyUsed(err) {
return err
}
ctx.Flash.Error(ctx.Tr("form.openid_been_used", uri))
}
return nil
}
// SignInOpenID render sign in page // SignInOpenID render sign in page
func SignInOpenID(ctx *context.Context) { func SignInOpenID(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("sign_in") ctx.Data["Title"] = ctx.Tr("sign_in")
@@ -154,6 +185,10 @@ func signInOpenIDVerify(ctx *context.Context) {
log.Trace("User exists, logging in") log.Trace("User exists, logging in")
remember, _ := ctx.Session.Get("openid_signin_remember").(bool) remember, _ := ctx.Session.Get("openid_signin_remember").(bool)
log.Trace("Session stored openid-remember: %t", remember) log.Trace("Session stored openid-remember: %t", remember)
openIDRequireTwoFactor(ctx, u, remember, "")
if ctx.Written() {
return
}
handleSignIn(ctx, u, remember) handleSignIn(ctx, u, remember)
return return
} }
@@ -270,7 +305,12 @@ func ConnectOpenIDPost(ctx *context.Context) {
return return
} }
// add OpenID for the user remember, _ := ctx.Session.Get("openid_signin_remember").(bool)
openIDRequireTwoFactor(ctx, u, remember, oid)
if ctx.Written() {
return
}
userOID := &user_model.UserOpenID{UID: u.ID, URI: oid} userOID := &user_model.UserOpenID{UID: u.ID, URI: oid}
if err := user_model.AddUserOpenID(ctx, userOID); err != nil { if err := user_model.AddUserOpenID(ctx, userOID); err != nil {
if user_model.IsErrOpenIDAlreadyUsed(err) { if user_model.IsErrOpenIDAlreadyUsed(err) {
@@ -282,9 +322,6 @@ func ConnectOpenIDPost(ctx *context.Context) {
} }
ctx.Flash.Success(ctx.Tr("settings.add_openid_success")) ctx.Flash.Success(ctx.Tr("settings.add_openid_success"))
remember, _ := ctx.Session.Get("openid_signin_remember").(bool)
log.Trace("Session stored openid-remember: %t", remember)
handleSignIn(ctx, u, remember) handleSignIn(ctx, u, remember)
} }

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