Compare commits

..
29 Commits
Author SHA1 Message Date
bircniandGitHub a62dfffbe7 docs: Clean up build section in CHANGELOG.md (#38671) 2026-07-27 20:43:03 +02:00
bircniandGitHub 035dd58664 docs: update changelog for 1.27.1 (#38668)
added the changelog for 1.27.1

---------

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

Fixes #38209

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

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


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

## What to build

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

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

### Root cause

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

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

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

### Fix

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

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

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

## Acceptance criteria

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

## Blocked by

- None -- can start immediately.

---
Generated by Codet

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

fix #38652


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

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

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

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

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

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

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

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

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

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

Reproduction:

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

## Cause

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

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

## Change

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

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

### Tradeoff

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

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

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

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

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

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

Several small fixes to the Actions runner management UI.

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

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

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

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

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

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

Various fixes to actions

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

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

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

Fixes #38494

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

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

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

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

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

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

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

Fixes #23840

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

Fixes #38603

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

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

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

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

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

```
run: |4


                while ...
```

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

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

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

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

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

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

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

## Changes

### 1. Handle reusable workflow expansion failures

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

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

### 2. No longer process concurrent run inline

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

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

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

Fix #38519

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

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

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

fix #38569, also fix the UI

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

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

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

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

Fixes #38571 

## Root Cause

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

## Solution

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

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

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

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

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

Signed-off-by: cyphercodes <cyphercodes@users.noreply.github.com>
Co-authored-by: Rayan Salhab <r.salhab@aiyexpertsolutions.com>
Co-authored-by: cyphercodes <cyphercodes@users.noreply.github.com>
Co-authored-by: Hermes Agent (GPT-5.5) <hermes-agent@nousresearch.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Hermes Agent <hermes@noreply.local>
Co-authored-by: silverwind <me@silverwind.io>
2026-07-22 12:07:42 +00:00
86 changed files with 1596 additions and 630 deletions
+12
View File
@@ -72,6 +72,18 @@ jobs:
BRANCH: ${{ steps.clean_name.outputs.branch }}
run: |
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:
runs-on: namespace-profile-gitea-release-docker
+12
View File
@@ -73,6 +73,18 @@ jobs:
BRANCH: ${{ steps.clean_name.outputs.branch }}
run: |
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
uses: dev-hanz-ops/install-gh-cli-action@af38ce09b1ec248aeb08eea2b16bbecea9e059f8 # v0.2.1
with:
+12
View File
@@ -76,6 +76,18 @@ jobs:
BRANCH: ${{ steps.clean_name.outputs.branch }}
run: |
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
uses: dev-hanz-ops/install-gh-cli-action@af38ce09b1ec248aeb08eea2b16bbecea9e059f8 # v0.2.1
with:
+58
View File
@@ -4,6 +4,64 @@ This changelog goes through the changes that have been made in each release
without substantial changes to our git log; to see the highlights of what has
been added to each release, please refer to the [blog](https://blog.gitea.com).
## [1.27.1](https://github.com/go-gitea/gitea/releases/tag/v1.27.1) - 2026-07-27
* SECURITY
* fix(oauth2): enforce mandatory 2FA policy on OAuth2 authorize/grant endpoints (#38591) (#38606)
* API
* fix(api): align Swagger schemas for UserSettings and TopicListResponse (#38590) (#38592)
* ENHANCEMENTS
* enhance: improve diff contrast in light and dark themes (#37477) (#38574)
* BUGFIXES
* fix: skip OIDC end-session after password login for OAuth2 users (#38439) (#38666)
* fix: make Actions log parser support multiple line message encoding (#38659) (#38664)
* fix(actions): use base branch ref for pull_request_target context (#38636) (#38657)
* fix(actions): skip already-approved runs in `ApproveRuns` (#38653) (#38654)
* fix: orgmode render include path (#38642) (#38645)
* fix(actions): cancel tasks immediately when the runner stopped reporting (#38616) (#38644)
* fix(issues): fix label bulk-load key and reduce log noise in LoadLabel (#38632) (#38643)
* fix(actions): improve runner list status sorting, labels and task job links (#38586) (#38633)
* fix(actions): correctness and hardening fixes (#38518) (#38631)
* fix(repo): prevent double-write redirect collisions on dependency errors, fix ui (#38627) (#38628)
* fix: delete repo-scoped rows of seven more tables when deleting a repository (#38534) (#38618)
* fix(webhook): remove slack channel name check (#38608) (#38612)
* fix: download dropdown menu clipped on the branches page (#38604) (#38609)
* fix(project): prevent database mutations on invalid MoveIssues payload (#38600) (#38602)
* fix(actions): make SingleWorkflow.Marshal round-trip multi-line run blocks (stop silent job stranding) (#38520) (#38599)
* fix(file-tree): handle submodule links and missing view container (#38033) (#38589)
* fix(actions): fail unexpandable reusable workflow callers and decouple the job emitter's cross-run processing (#38565) (#38587)
* fix: keep serving valid ACME cert when renewal fails at startup (#38554) (#38583)
* fix: branch protection user list (#38570) (#38584)
* fix(pulls): respect diff.orderFile in diff file tree (#38566) (#38578)
* fix(issue): make issue action (issue list batch operation) elements have correct attributes (#38575) (#38580)
* fix(actions): support `matrix` when evaluating workflow `if` expression (#38474) (#38557)
* fix(actions): align status icon span for Safari rendering (#38558) (#38562)
* fix: revert git clone http redirection forbidden (#38530) (#38545)
* fix: clean up orphaned user-keyed tables in deleteUser (#38511) (#38514)
* fix(actions): coerce workflow_dispatch boolean inputs to native types (#38472) (#38521)
* fix: make the merge box button red if some checks fail (#38508) (#38516)
* fix(pull): sign the commit when updating a branch by merge (#38441) (#38499)
* fix: make commit message merge correctly (#38490) (#38502)
* fix(actions): explain why a blocked or waiting job has not started (#38476) (#38498)
* fix(actions): make `cancelled()` work in job `if` evaluation (#38495) (#38497)
* fix(actions): show retention info on hover for expired artifacts (#38477) (#38493)
* fix(actions): group reusable-workflow matrix legs in the workflow graph (#38475) (#38492)
* fix: full file highlighting for git diff with CR char (#38484) (#38491)
* fix(packages): serve noarch Alpine index for any requested architecture (#38479) (#38486)
* fix: 500 error when updating user visibility (#38480) (#38483)
* fix(actions): make job list item fully clickable (#38462) (#38471)
* fix: mail template for push event (#38467) (#38468)
* fix: make "test push webhook" always work (#38425) (#38455)
* fix(actions): prevent bulk actions from affecting all runners (#38453) (#38457)
* fix(org): align follow button and wrap description (#38448) (#38454)
* fix(actions): populate `github.event` for scheduled runs (#38446) (#38452)
* MISC
* refactor: git patch apply (#38637) (#38638)
## [1.27.0](https://github.com/go-gitea/gitea/releases/tag/v1.27.0) - 2026-07-13
* BREAKING
+15 -3
View File
@@ -6,6 +6,7 @@ package cmd
import (
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"net"
"net/http"
@@ -93,10 +94,21 @@ func runACME(listenAddr string, m http.Handler) error {
myACME := certmagic.NewACMEIssuer(magic, certmagic.DefaultACME)
magic.Issuers = []certmagic.Issuer{myACME}
// this obtains certificates or renews them if necessary
err := magic.ManageSync(graceful.GetManager().HammerContext(), []string{setting.Domain})
// Obtain certificates or renew them if necessary. ManageSync fails closed on
// renewal errors even when a still-valid certificate is already on disk, which
// takes HTTPS down on restart (https://github.com/go-gitea/gitea/issues/38519).
// Prefer keeping the existing cert and retrying renewals asynchronously.
ctx := graceful.GetManager().ShutdownContext()
err := magic.ManageSync(ctx, []string{setting.Domain})
if err != nil {
return err
cert, cacheErr := magic.CacheManagedCertificate(ctx, setting.Domain)
if cacheErr != nil || cert.Expired() {
return errors.Join(err, cacheErr)
}
log.Error("ACME certificate manage failed; continuing with existing certificate: %v", err)
if err := magic.ManageAsync(ctx, []string{setting.Domain}); err != nil {
log.Error("Failed to start async ACME management: %v", err)
}
}
tlsConfig := magic.TLSConfig()
+9 -7
View File
@@ -62,14 +62,16 @@ func (attempt *ActionRunAttempt) LoadAttributes(ctx context.Context) (err error)
attempt.Run = run
}
if attempt.TriggerUser == nil {
attempt.TriggerUserID, attempt.TriggerUser, err = user_model.GetPossibleUserByID(ctx, attempt.TriggerUserID)
if err != nil {
return err
}
}
return attempt.LoadTriggerUser(ctx)
}
return nil
// LoadTriggerUser loads the attempt's trigger user if not already loaded.
func (attempt *ActionRunAttempt) LoadTriggerUser(ctx context.Context) (err error) {
if attempt.TriggerUser != nil {
return nil
}
attempt.TriggerUserID, attempt.TriggerUser, err = user_model.GetPossibleUserByID(ctx, attempt.TriggerUserID)
return err
}
func GetRunAttemptByRepoAndID(ctx context.Context, repoID, attemptID int64) (*ActionRunAttempt, error) {
+8
View File
@@ -333,6 +333,14 @@ func GetDirectChildJobsByParent(ctx context.Context, parentJob *ActionRunJob) (A
return jobs, nil
}
// DeleteDirectChildJobsByParent deletes the direct child jobs of a parent job.
func DeleteDirectChildJobsByParent(ctx context.Context, parentJob *ActionRunJob) error {
_, err := db.GetEngine(ctx).
Where("run_id=? AND parent_job_id=?", parentJob.RunID, parentJob.ID).
Delete(new(ActionRunJob))
return err
}
// CollectAllDescendantJobs returns every job in `allJobs` that lives under parent's subtree (recursively), excluding `parent` itself
func CollectAllDescendantJobs(parent *ActionRunJob, allJobs []*ActionRunJob) []*ActionRunJob {
parents := map[int64]bool{parent.ID: true}
+9
View File
@@ -159,3 +159,12 @@ func (opts FindRunJobOptions) ToOrders() string {
}
var _ db.FindOptionsOrder = FindRunJobOptions{}
// CountRunJobsByRunAndAttemptID counts the jobs belonging to the given run attempt.
// It is used to enforce MaxJobNumPerRun when reusable-workflow expansion inserts new jobs.
func CountRunJobsByRunAndAttemptID(ctx context.Context, runID, runAttemptID int64) (int64, error) {
return db.Count[ActionRunJob](ctx, FindRunJobOptions{
RunID: runID,
RunAttemptID: optional.Some(runAttemptID),
})
}
+21 -5
View File
@@ -270,15 +270,31 @@ func (opts FindRunnerOptions) ToConds() builder.Cond {
return cond
}
// runnerStatusOrderExpr builds an ORDER BY fragment that ranks runners by their
// computed status (see ActionRunner.Status): active (0), idle (1), offline (2).
// The thresholds are evaluated against the current time, mirroring ToConds, so
// sorting by status groups active and idle runners instead of interleaving them
// by raw last_online.
func runnerStatusOrderExpr() string {
now := time.Now()
offlineThreshold := now.Add(-RunnerOfflineTime).Unix()
idleThreshold := now.Add(-RunnerIdleTime).Unix()
return fmt.Sprintf("CASE WHEN last_online <= %d THEN 2 WHEN last_active <= %d THEN 1 ELSE 0 END", offlineThreshold, idleThreshold)
}
func (opts FindRunnerOptions) ToOrders() string {
// A unique tiebreaker (id) is appended so that runners sharing the same
// last_online or name keep a deterministic order across paginated queries,
// otherwise the same runner may appear on more than one page.
// status, last_online or name keep a deterministic order across paginated
// queries, otherwise the same runner may appear on more than one page.
statusRank := runnerStatusOrderExpr()
switch opts.Sort {
case "online":
return "last_online DESC, id ASC"
// Rank by computed status first so idle runners are not interleaved with
// active ones; disabled runners sink to the bottom of their status group
// (is_disabled ASC), then last_online breaks ties within a group.
return statusRank + " ASC, is_disabled ASC, last_online DESC, id ASC"
case "offline":
return "last_online ASC, id ASC"
return statusRank + " DESC, is_disabled ASC, last_online ASC, id ASC"
case "alphabetically":
return "name ASC, id ASC"
case "reversealphabetically":
@@ -288,7 +304,7 @@ func (opts FindRunnerOptions) ToOrders() string {
case "oldest":
return "id ASC"
}
return "last_online DESC, id ASC"
return statusRank + " ASC, is_disabled ASC, last_online DESC, id ASC"
}
// GetRunnerByUUID returns a runner via uuid
-66
View File
@@ -4,16 +4,12 @@
package actions
import (
"fmt"
"testing"
"time"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
"gitea.dev/modules/timeutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestShouldPersistLastOnline(t *testing.T) {
@@ -85,65 +81,3 @@ func TestShouldPersistLastActive(t *testing.T) {
})
}
}
func TestFindRunnerOptions_ToOrders_StableTiebreaker(t *testing.T) {
// Sorts on a non-unique column must end with the unique id tiebreaker so
// pagination is deterministic; without it, runners sharing the same
// last_online or name can appear on more than one page. Sorts already on
// the unique id need no tiebreaker.
expected := map[string]string{
"": "last_online DESC, id ASC",
"online": "last_online DESC, id ASC",
"offline": "last_online ASC, id ASC",
"alphabetically": "name ASC, id ASC",
"reversealphabetically": "name DESC, id ASC",
"newest": "id DESC",
"oldest": "id ASC",
}
for sort, want := range expected {
assert.Equal(t, want, FindRunnerOptions{Sort: sort}.ToOrders(), "sort %q", sort)
}
}
func TestFindRunners_PaginationNoDuplicates(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
// Create several runners that all share the same last_online value so the
// primary sort key (last_online) is tied for all of them.
const ownerID = 1000
const count = 6
for i := range count {
runner := &ActionRunner{
Name: "paginated-runner",
UUID: fmt.Sprintf("PAGINATE-TEST-0000-0000-00000000000%d", i),
TokenHash: fmt.Sprintf("paginate-test-token-hash-%d", i),
OwnerID: ownerID,
RepoID: 0,
LastOnline: 42,
}
require.NoError(t, db.Insert(ctx, runner))
}
// Page through the runners and ensure every id is returned exactly once.
seen := make(map[int64]int)
const pageSize = 2
for page := 1; ; page++ {
runners, err := db.Find[ActionRunner](ctx, FindRunnerOptions{
ListOptions: db.ListOptions{Page: page, PageSize: pageSize},
OwnerID: ownerID,
})
require.NoError(t, err)
if len(runners) == 0 {
break
}
for _, r := range runners {
seen[r.ID]++
}
}
assert.Len(t, seen, count, "each runner should be returned exactly once across all pages")
for id, n := range seen {
assert.Equal(t, 1, n, "runner %d appeared on %d pages", id, n)
}
}
+21 -4
View File
@@ -60,6 +60,12 @@ type ActionTask struct {
Updated timeutil.TimeStamp `xorm:"updated index"`
}
// taskReportTimeout is how long a task may go without contact from its runner before the
// runner is assumed gone. Runners report state and stream logs every few seconds, both of
// which refresh ActionTask.Updated. Shorter than setting.Actions.ZombieTaskTimeout because
// it only decides whether the runner is reachable, not whether the task should be killed.
const taskReportTimeout = time.Minute
var successfulTokenTaskCache *lru.Cache[string, any]
func init() {
@@ -85,11 +91,15 @@ func (task *ActionTask) IsStopped() bool {
return task.Stopped > 0
}
func (task *ActionTask) GetRunLink() string {
if task.Job == nil || task.Job.Run == nil {
func (task *ActionTask) GetRunJobLink() string {
// Run.Repo can be nil when the repository was deleted while task/run rows remain
// (TaskList.LoadAttributes copies job.Repo into run.Repo, leaving it nil on a miss).
// Run.Link() already returns "" in that case, so guard here to avoid emitting a
// broken relative "/jobs/N" link from the Sprintf below.
if task.Job == nil || task.Job.Run == nil || task.Job.Run.Repo == nil {
return ""
}
return task.Job.Run.Link()
return fmt.Sprintf("%s/jobs/%d", task.Job.Run.Link(), task.Job.ID)
}
func (task *ActionTask) GetCommitLink() string {
@@ -563,6 +573,10 @@ func StopTask(ctx context.Context, taskID int64, status Status) error {
status = StatusCancelled
} else if !runner.HasCancellingSupport {
status = StatusCancelled
} else if task.Updated.AddDuration(taskReportTimeout) < now {
// A runner that stopped reporting will never acknowledge the cancellation either,
// so skip the handshake instead of waiting for the zombie task cleanup.
status = StatusCancelled
}
}
@@ -577,7 +591,10 @@ func StopTask(ctx context.Context, taskID int64, status Status) error {
return err
}
return UpdateTask(ctx, task, "status")
// NoAutoTime keeps "updated" at the runner's last contact: re-cancelling an already
// cancelling task must not defer the timeout above or the zombie task cleanup.
_, err := e.ID(task.ID).Cols("status").NoAutoTime().Update(task)
return err
}
task.Status = status
+144 -171
View File
@@ -9,6 +9,7 @@ import (
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/timeutil"
@@ -18,6 +19,21 @@ import (
"google.golang.org/protobuf/types/known/timestamppb"
)
func TestActionTask_GetRunJobLink(t *testing.T) {
repo := &repo_model.Repository{OwnerName: "org", Name: "consumer"}
run := &ActionRun{ID: 10, Repo: repo}
job := &ActionRunJob{ID: 42, Run: run}
// a task with a loaded job links to that specific job, not just the run
task := &ActionTask{Job: job}
assert.Equal(t, run.Link()+"/jobs/42", task.GetRunJobLink())
// missing job, run or repo yields an empty link instead of a broken URL
assert.Empty(t, (&ActionTask{}).GetRunJobLink())
assert.Empty(t, (&ActionTask{Job: &ActionRunJob{ID: 42}}).GetRunJobLink())
assert.Empty(t, (&ActionTask{Job: &ActionRunJob{ID: 42, Run: &ActionRun{ID: 10}}}).GetRunJobLink())
}
func TestMakeTaskStepDisplayName(t *testing.T) {
tests := []struct {
name string
@@ -83,68 +99,11 @@ func TestMakeTaskStepDisplayName(t *testing.T) {
}
func TestTaskCancellingFinalizesToCancelled(t *testing.T) {
newRunningTask := func(t *testing.T) (*ActionTask, *ActionRunJob) {
t.Helper()
run := &ActionRun{
Title: "cancelling-test-run",
RepoID: 1,
OwnerID: 2,
WorkflowID: "test.yaml",
Index: 999,
TriggerUserID: 2,
Ref: "refs/heads/master",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
Status: StatusRunning,
Started: timeutil.TimeStampNow(),
}
require.NoError(t, db.Insert(t.Context(), run))
job := &ActionRunJob{
RunID: run.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
Name: "cancelling-finalization-job",
Attempt: 1,
JobID: "cancelling-finalization-job",
Status: StatusRunning,
}
require.NoError(t, db.Insert(t.Context(), job))
runner := &ActionRunner{
UUID: "runner-cancelling-supported",
Name: "runner-cancelling-supported",
HasCancellingSupport: true,
}
require.NoError(t, db.Insert(t.Context(), runner))
task := &ActionTask{
JobID: job.ID,
Attempt: 1,
RunnerID: runner.ID,
Status: StatusRunning,
Started: timeutil.TimeStampNow(),
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
}
require.NoError(t, db.Insert(t.Context(), task))
job.TaskID = task.ID
_, err := UpdateRunJob(t.Context(), job, nil, "task_id")
require.NoError(t, err)
return task, job
}
testResult := func(t *testing.T, result runnerv1.Result) {
t.Helper()
require.NoError(t, unittest.PrepareTestDatabase())
task, job := newRunningTask(t)
task, job := newRunningTaskForCancelling(t, "cancelling-finalization-job", true)
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
taskAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
@@ -174,137 +133,92 @@ func TestTaskCancellingFinalizesToCancelled(t *testing.T) {
})
}
func TestStopTaskCancellingFallsBackForLegacyRunner(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
// TestStopTaskCancellingFallsBackToCancelled covers the cases where the cancelling handshake can
// never complete, so StopTask must cancel right away instead of waiting for the zombie task cleanup.
func TestStopTaskCancellingFallsBackToCancelled(t *testing.T) {
assertCancelled := func(t *testing.T, task *ActionTask, job *ActionRunJob) {
t.Helper()
run := &ActionRun{
Title: "cancelling-test-run",
RepoID: 1,
OwnerID: 2,
WorkflowID: "test.yaml",
Index: 999,
TriggerUserID: 2,
Ref: "refs/heads/master",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
Status: StatusRunning,
Started: timeutil.TimeStampNow(),
taskAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
assert.Equal(t, StatusCancelled, taskAfterStop.Status)
assert.NotZero(t, taskAfterStop.Stopped)
jobAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
assert.Equal(t, StatusCancelled, jobAfterStop.Status)
assert.NotZero(t, jobAfterStop.Stopped)
}
require.NoError(t, db.Insert(t.Context(), run))
job := &ActionRunJob{
RunID: run.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
Name: "legacy-cancelling-job",
Attempt: 1,
JobID: "legacy-cancelling-job",
Status: StatusRunning,
}
require.NoError(t, db.Insert(t.Context(), job))
// A runner too old to know the cancelling state can only be stopped by a final status.
t.Run("legacy runner", func(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
task, job := newRunningTaskForCancelling(t, "legacy-cancelling-job", false)
runner := &ActionRunner{
UUID: "runner-legacy-no-cancelling",
Name: "runner-legacy-no-cancelling",
HasCancellingSupport: false,
}
require.NoError(t, db.Insert(t.Context(), runner))
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
assertCancelled(t, task, job)
})
task := &ActionTask{
JobID: job.ID,
Attempt: 1,
RunnerID: runner.ID,
Status: StatusRunning,
Started: timeutil.TimeStampNow(),
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
}
require.NoError(t, db.Insert(t.Context(), task))
// The runner is gone, e.g. an ephemeral runner was cleaned up, so nobody can acknowledge.
t.Run("missing runner", func(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
task, job := newRunningTaskForCancelling(t, "missing-runner-cancelling-job", true)
job.TaskID = task.ID
_, err := UpdateRunJob(t.Context(), job, nil, "task_id")
require.NoError(t, err)
_, err := db.DeleteByID[ActionRunner](t.Context(), task.RunnerID)
require.NoError(t, err)
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
assertCancelled(t, task, job)
})
taskAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
assert.Equal(t, StatusCancelled, taskAfterStop.Status)
assert.NotZero(t, taskAfterStop.Stopped)
// The runner went silent, e.g. it gave up while Gitea was restarting, so it never picks up the request.
t.Run("silent runner", func(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
task, job := newRunningTaskForCancelling(t, "silent-runner-cancelling-job", true)
jobAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
assert.Equal(t, StatusCancelled, jobAfterStop.Status)
assert.NotZero(t, jobAfterStop.Stopped)
// NoAutoTime because the point of the test is an "updated" older than xorm would write
task.Updated = timeutil.TimeStampNow().AddDuration(-2 * taskReportTimeout)
_, err := db.GetEngine(t.Context()).ID(task.ID).Cols("updated").NoAutoTime().Update(task)
require.NoError(t, err)
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
assertCancelled(t, task, job)
// A runner coming back still learns the outcome from the UpdateTask response,
// and its late result does not overwrite the cancellation.
late, err := UpdateTaskByState(t.Context(), task.RunnerID, &runnerv1.TaskState{
Id: task.ID,
Result: runnerv1.Result_RESULT_SUCCESS,
StoppedAt: timestamppb.Now(),
})
require.NoError(t, err)
assert.Equal(t, StatusCancelled, late.Status)
assert.Equal(t, runnerv1.Result_RESULT_CANCELLED, late.Status.AsResult())
})
}
func TestStopTaskCancellingFallsBackForMissingRunner(t *testing.T) {
// TestStopTaskCancellingKeepsReportTime makes sure persisting the cancelling status does not refresh
// "updated": re-cancelling would otherwise defer both the fallback to cancelled and the zombie task
// cleanup, no matter how long the runner has been silent.
func TestStopTaskCancellingKeepsReportTime(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
task, _ := newRunningTaskForCancelling(t, "repeated-cancelling-job", true)
run := &ActionRun{
Title: "cancelling-test-run",
RepoID: 1,
OwnerID: 2,
WorkflowID: "test.yaml",
Index: 999,
TriggerUserID: 2,
Ref: "refs/heads/master",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
Status: StatusRunning,
Started: timeutil.TimeStampNow(),
}
require.NoError(t, db.Insert(t.Context(), run))
job := &ActionRunJob{
RunID: run.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
Name: "missing-runner-cancelling-job",
Attempt: 1,
JobID: "missing-runner-cancelling-job",
Status: StatusRunning,
}
require.NoError(t, db.Insert(t.Context(), job))
runner := &ActionRunner{
UUID: "runner-cleaned-up-before-cancel",
Name: "runner-cleaned-up-before-cancel",
HasCancellingSupport: true,
}
require.NoError(t, db.Insert(t.Context(), runner))
task := &ActionTask{
JobID: job.ID,
Attempt: 1,
RunnerID: runner.ID,
Status: StatusRunning,
Started: timeutil.TimeStampNow(),
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
}
require.NoError(t, db.Insert(t.Context(), task))
job.TaskID = task.ID
_, err := UpdateRunJob(t.Context(), job, nil, "task_id")
require.NoError(t, err)
_, err = db.DeleteByID[ActionRunner](t.Context(), runner.ID)
// NoAutoTime because the point of the test is an "updated" older than xorm would write
lastReport := timeutil.TimeStampNow().AddDuration(-taskReportTimeout / 2)
task.Updated = lastReport
_, err := db.GetEngine(t.Context()).ID(task.ID).Cols("updated").NoAutoTime().Update(task)
require.NoError(t, err)
// the runner reported recently enough, so the task waits for it to acknowledge
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
taskAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
assert.Equal(t, StatusCancelled, taskAfterStop.Status)
assert.NotZero(t, taskAfterStop.Stopped)
assert.Equal(t, StatusCancelling, taskAfterStop.Status)
assert.Equal(t, lastReport, taskAfterStop.Updated)
jobAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
assert.Equal(t, StatusCancelled, jobAfterStop.Status)
assert.NotZero(t, jobAfterStop.Stopped)
// cancelling it again does not reset the clock either
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
taskAfterSecondStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
assert.Equal(t, StatusCancelling, taskAfterSecondStop.Status)
assert.Equal(t, lastReport, taskAfterSecondStop.Updated)
}
// TestReleaseTaskForRunner verifies that releasing a freshly-claimed task returns
@@ -442,3 +356,62 @@ func TestCreateTaskForRunnerPagination(t *testing.T) {
assert.Equal(t, StatusRunning, claimed.Status)
assert.Equal(t, task.ID, claimed.TaskID)
}
// 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
}
+1 -1
View File
@@ -507,7 +507,7 @@ func updateApprovalWhitelist(ctx context.Context, repo *repo_model.Repository, c
return currentWhitelist, nil
}
prUserIDs, err := access_model.GetUserIDsWithUnitAccess(ctx, repo, perm.AccessModeRead, unit.TypePullRequests)
prUserIDs, err := access_model.GetUserIDsWithAnyUnitAccess(ctx, repo, perm.AccessModeRead, unit.TypePullRequests)
if err != nil {
return nil, err
}
+5 -2
View File
@@ -544,6 +544,9 @@ func (c *Comment) GetSanitizedContentHTML() template.HTML {
// LoadLabel if comment.Type is CommentTypeLabel, then load Label
func (c *Comment) LoadLabel(ctx context.Context) error {
if c.LabelID == 0 {
return nil
}
var label Label
has, err := db.GetEngine(ctx).ID(c.LabelID).Get(&label)
if err != nil {
@@ -551,8 +554,8 @@ func (c *Comment) LoadLabel(ctx context.Context) error {
} else if has {
c.Label = &label
} else {
// Ignore Label is deleted, but not clear this table
log.Warn("Commit %d cannot load label %d", c.ID, c.LabelID)
// label was deleted but comment rows referencing it were not cleaned up
log.Debug("Comment %d references deleted label %d", c.ID, c.LabelID)
}
return nil
+1 -1
View File
@@ -81,7 +81,7 @@ func (comments CommentList) loadLabels(ctx context.Context) error {
}
for _, comment := range comments {
comment.Label = commentLabels[comment.ID]
comment.Label = commentLabels[comment.LabelID]
}
return nil
}
+4 -4
View File
@@ -599,8 +599,8 @@ func HasAnyUnitAccess(ctx context.Context, userID int64, repo *repo_model.Reposi
return perm.HasAnyUnitAccess(), nil
}
func GetUsersWithUnitAccess(ctx context.Context, repo *repo_model.Repository, mode perm_model.AccessMode, unitType unit.Type) (users []*user_model.User, err error) {
userIDs, err := GetUserIDsWithUnitAccess(ctx, repo, mode, unitType)
func GetUsersWithAnyUnitAccess(ctx context.Context, repo *repo_model.Repository, mode perm_model.AccessMode, unitType unit.Type, moreUnitTypes ...unit.Type) (users []*user_model.User, err error) {
userIDs, err := GetUserIDsWithAnyUnitAccess(ctx, repo, mode, unitType, moreUnitTypes...)
if err != nil {
return nil, err
}
@@ -613,7 +613,7 @@ func GetUsersWithUnitAccess(ctx context.Context, repo *repo_model.Repository, mo
return users, nil
}
func GetUserIDsWithUnitAccess(ctx context.Context, repo *repo_model.Repository, mode perm_model.AccessMode, unitType unit.Type) (container.Set[int64], error) {
func GetUserIDsWithAnyUnitAccess(ctx context.Context, repo *repo_model.Repository, mode perm_model.AccessMode, unitType unit.Type, moreUnitTypes ...unit.Type) (container.Set[int64], error) {
userIDs := container.Set[int64]{}
e := db.GetEngine(ctx)
accesses := make([]*Access, 0, 10)
@@ -630,7 +630,7 @@ func GetUserIDsWithUnitAccess(ctx context.Context, repo *repo_model.Repository,
if !repo.Owner.IsOrganization() {
userIDs.Add(repo.Owner.ID)
} else {
teamUserIDs, err := organization.GetTeamUserIDsWithAccessToAnyRepoUnit(ctx, repo.OwnerID, repo.ID, mode, unitType)
teamUserIDs, err := organization.GetTeamUserIDsWithAccessToAnyRepoUnit(ctx, repo.OwnerID, repo.ID, mode, unitType, moreUnitTypes...)
if err != nil {
return nil, err
}
+3 -3
View File
@@ -226,12 +226,12 @@ func testGetIndividualUserRepoPermission(t *testing.T) {
assert.Equal(t, perm_model.AccessModeNone, perm.unitsMode[unit.TypeCode])
assert.Equal(t, perm_model.AccessModeRead, perm.unitsMode[unit.TypeIssues])
users, err := GetUsersWithUnitAccess(ctx, repo3, perm_model.AccessModeRead, unit.TypeIssues)
users, err := GetUsersWithAnyUnitAccess(ctx, repo3, perm_model.AccessModeRead, unit.TypeIssues)
require.NoError(t, err)
require.Len(t, users, 1)
assert.Equal(t, user.ID, users[0].ID)
users, err = GetUsersWithUnitAccess(ctx, repo3, perm_model.AccessModeWrite, unit.TypeIssues)
users, err = GetUsersWithAnyUnitAccess(ctx, repo3, perm_model.AccessModeWrite, unit.TypeIssues)
require.NoError(t, err)
require.Empty(t, users)
})
@@ -245,7 +245,7 @@ func testGetIndividualUserRepoPermission(t *testing.T) {
assert.Equal(t, perm_model.AccessModeWrite, perm.unitsMode[unit.TypeCode])
assert.Equal(t, perm_model.AccessModeWrite, perm.unitsMode[unit.TypeIssues])
users, err := GetUsersWithUnitAccess(ctx, repo3, perm_model.AccessModeWrite, unit.TypeIssues)
users, err := GetUsersWithAnyUnitAccess(ctx, repo3, perm_model.AccessModeWrite, unit.TypeIssues)
require.NoError(t, err)
require.Len(t, users, 1)
assert.Equal(t, user.ID, users[0].ID)
+16 -1
View File
@@ -75,7 +75,22 @@ func (w *SingleWorkflow) SetJob(id string, job *Job) error {
}
func (w *SingleWorkflow) Marshal() ([]byte, error) {
return yaml.Marshal(w)
// Encode with the same indentation SetJob uses (2). yaml.Marshal's default
// indentation (4) makes the encoder emit multi-line block scalars (e.g. a
// `run:` step that begins with blank lines) with a wrong explicit indentation
// indicator (`run: |4`) that then fails to re-parse, which silently strands
// the job during concurrency evaluation. Keeping both encoders at indent 2
// makes the serialized single workflow round-trip.
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
if err := enc.Encode(w); err != nil {
return nil, err
}
if err := enc.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
type Job struct {
@@ -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)
}
+15 -1
View File
@@ -91,7 +91,7 @@ func syncGitConfig(ctx context.Context) (err error) {
}
}
// By default partial clones are disabled, enable them from git v2.22
// By default, partial clones are disabled, enable them from git v2.22
if !setting.Git.DisablePartialClone && DefaultFeatures().CheckVersionAtLeast("2.22") {
if err = configSet(ctx, "uploadpack.allowfilter", "true"); err != nil {
return err
@@ -114,9 +114,23 @@ func syncGitConfig(ctx context.Context) (err error) {
}
}
GlobalConfig = &GlobalConfigStruct{}
// HINT: GIT-DIFF-TREE-UI-CONFIG: Git's bug: git-diff-tree loads config with /* no "diff" UI options */ (since 20 years ago).
// https://github.com/git/git/blame/5d2e7709234afea1b6ddb25cd4f60d3d5fb3c200/builtin/diff-tree.c#L127
// Although document and manual say that "git-diff-tree" supports "diff.orderfile" option, but it is not actually supported.
// So we need to apply the diff.orderfile explicitly in our code.
GlobalConfig.DiffOrderFile, _ = configGet(ctx, "diff.orderfile")
return nil
}
func configGet(ctx context.Context, key string) (string, error) {
stdout, _, err := gitcmd.NewCommand("config", "--global", "--get").AddDynamicArguments(key).RunStdString(ctx)
if err != nil && !gitcmd.IsErrorExitCode(err, 1) {
return "", fmt.Errorf("failed to get git config %s, err: %w", key, err)
}
return strings.TrimRight(stdout, "\r\n"), nil
}
func configSet(ctx context.Context, key, value string) error {
stdout, _, err := gitcmd.NewCommand("config", "--global", "--get").
AddDynamicArguments(key).
+8 -1
View File
@@ -36,7 +36,14 @@ type Features struct {
SupportGitMergeTree bool // >= 2.40 // we also need "--merge-base"
}
var defaultFeatures *Features
type GlobalConfigStruct struct {
DiffOrderFile string
}
var (
defaultFeatures *Features
GlobalConfig *GlobalConfigStruct
)
func (f *Features) CheckVersionAtLeast(atLeast string) bool {
return f.gitVersion.Compare(version.Must(version.NewVersion(atLeast))) >= 0
+9 -1
View File
@@ -70,7 +70,15 @@ func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error
w := &orgWriter{rctx: ctx, HTMLWriter: htmlWriter}
htmlWriter.ExtendingWriter = w
res, err := org.New().Silent().Parse(input, "").Write(w)
cfg := org.New()
cfg.ReadFile = func(path string) ([]byte, error) {
// actually the orgmode render doesn't support rendering the content from the content again,
// so just leave the plain text to end users
content := fmt.Sprintf("#+INCLUDE: [[%s]]", path)
return []byte(content), nil
}
doc := cfg.Silent().Parse(input, "")
res, err := doc.Write(w)
if err != nil {
return fmt.Errorf("orgmode.Render failed: %w", err)
}
+31 -43
View File
@@ -21,86 +21,74 @@ func TestMain(m *testing.M) {
os.Exit(m.Run())
}
func TestRender_StandardLinks(t *testing.T) {
test := func(input, expected string) {
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
func testRender(t *testing.T, input, expected string) {
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
test("[[https://google.com/]]",
func TestRender_StandardLinks(t *testing.T) {
testRender(t, "[[https://google.com/]]",
`<p><a href="https://google.com/">https://google.com/</a></p>`)
test("[[ImageLink.svg][The Image Desc]]",
testRender(t, "[[ImageLink.svg][The Image Desc]]",
`<p><a href="ImageLink.svg">The Image Desc</a></p>`)
}
func TestRender_InternalLinks(t *testing.T) {
test := func(input, expected string) {
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
test("[[file:test.org][Test]]",
testRender(t, "[[file:test.org][Test]]",
`<p><a href="test.org">Test</a></p>`)
test("[[./test.org][Test]]",
testRender(t, "[[./test.org][Test]]",
`<p><a href="./test.org">Test</a></p>`)
test("[[test.org][Test]]",
testRender(t, "[[test.org][Test]]",
`<p><a href="test.org">Test</a></p>`)
test("[[path/to/test.org][Test]]",
testRender(t, "[[path/to/test.org][Test]]",
`<p><a href="path/to/test.org">Test</a></p>`)
}
func TestRender_Media(t *testing.T) {
test := func(input, expected string) {
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
test("[[file:../../.images/src/02/train.jpg]]",
testRender(t, "[[file:../../.images/src/02/train.jpg]]",
`<p><img src="../../.images/src/02/train.jpg" alt="../../.images/src/02/train.jpg"></p>`)
test("[[file:train.jpg]]",
testRender(t, "[[file:train.jpg]]",
`<p><img src="train.jpg" alt="train.jpg"></p>`)
// With description.
test("[[https://example.com][https://example.com/example.svg]]",
testRender(t, "[[https://example.com][https://example.com/example.svg]]",
`<p><a href="https://example.com"><img src="https://example.com/example.svg" alt="https://example.com/example.svg"></a></p>`)
test("[[https://example.com][pre https://example.com/example.svg post]]",
testRender(t, "[[https://example.com][pre https://example.com/example.svg post]]",
`<p><a href="https://example.com">pre <img src="https://example.com/example.svg" alt="https://example.com/example.svg"> post</a></p>`)
test("[[https://example.com][https://example.com/example.mp4]]",
testRender(t, "[[https://example.com][https://example.com/example.mp4]]",
`<p><a href="https://example.com"><video src="https://example.com/example.mp4">https://example.com/example.mp4</video></a></p>`)
test("[[https://example.com][pre https://example.com/example.mp4 post]]",
testRender(t, "[[https://example.com][pre https://example.com/example.mp4 post]]",
`<p><a href="https://example.com">pre <video src="https://example.com/example.mp4">https://example.com/example.mp4</video> post</a></p>`)
// Without description.
test("[[https://example.com/example.svg]]",
testRender(t, "[[https://example.com/example.svg]]",
`<p><img src="https://example.com/example.svg" alt="https://example.com/example.svg"></p>`)
test("[[https://example.com/example.mp4]]",
testRender(t, "[[https://example.com/example.mp4]]",
`<p><video src="https://example.com/example.mp4">https://example.com/example.mp4</video></p>`)
// test [[LINK][DESCRIPTION]] syntax with "file:" prefix
test(`[[https://example.com/][file:https://example.com/foo%20bar.svg]]`,
testRender(t, `[[https://example.com/][file:https://example.com/foo%20bar.svg]]`,
`<p><a href="https://example.com/"><img src="https://example.com/foo%20bar.svg" alt="https://example.com/foo%20bar.svg"></a></p>`)
test(`[[file:https://example.com/foo%20bar.svg][Goto Image]]`,
testRender(t, `[[file:https://example.com/foo%20bar.svg][Goto Image]]`,
`<p><a href="https://example.com/foo%20bar.svg">Goto Image</a></p>`)
test(`[[file:https://example.com/link][https://example.com/image.jpg]]`,
testRender(t, `[[file:https://example.com/link][https://example.com/image.jpg]]`,
`<p><a href="https://example.com/link"><img src="https://example.com/image.jpg" alt="https://example.com/image.jpg"></a></p>`)
test(`[[file:https://example.com/link][file:https://example.com/image.jpg]]`,
testRender(t, `[[file:https://example.com/link][file:https://example.com/image.jpg]]`,
`<p><a href="https://example.com/link"><img src="https://example.com/image.jpg" alt="https://example.com/image.jpg"></a></p>`)
}
func TestRender_Source(t *testing.T) {
test := func(input, expected string) {
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
assert.NoError(t, err)
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
test(`#+begin_src c
testRender(t, `#+begin_src c
int a;
#+end_src
`, `<div class="src src-c">
<pre><code class="chroma language-c"><span class="kt">int</span> <span class="n">a</span><span class="p">;</span></code></pre>
</div>`)
}
func TestRender_IncludeLink(t *testing.T) {
testRender(t, `#+INCLUDE: "./other.org" src text`, `<div class="src src-text">
<pre><code class="chroma language-plaintext">#+INCLUDE: [[other.org]]</code></pre>
</div>`)
}
+6
View File
@@ -8,4 +8,10 @@ const (
KeyUname = "uname"
KeyUserHasTwoFactorAuth = "userHasTwoFactorAuth"
// KeySignInMethod records how the current session was authenticated so logout
// can decide whether RP-initiated OIDC logout is appropriate.
KeySignInMethod = "signInMethod"
SignInMethodOAuth2 = "oauth2"
)
+5
View File
@@ -32,3 +32,8 @@ type RepoTopicOptions struct {
// list of topic names
Topics []string `json:"topics"`
}
// TopicListResponse returns a list of TopicResponse
type TopicListResponse struct {
Topics []*TopicResponse `json:"topics"`
}
+2 -1
View File
@@ -3742,7 +3742,7 @@
"actions.runners.runner_title": "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.run": "Run",
"actions.runners.task_list.job": "Job",
"actions.runners.task_list.status": "Status",
"actions.runners.task_list.repository": "Repository",
"actions.runners.task_list.commit": "Commit",
@@ -3807,6 +3807,7 @@
"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.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.summary": "Summary",
"actions.runs.all_jobs": "All jobs",
+8 -1
View File
@@ -1503,7 +1503,14 @@ func RerunFailedWorkflowRun(ctx *context.APIContext) {
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)
return
}
+2 -2
View File
@@ -300,7 +300,7 @@ func TopicSearch(ctx *context.APIContext) {
}
ctx.SetTotalCountHeader(total)
ctx.JSON(http.StatusOK, map[string]any{
"topics": topicResponses,
ctx.JSON(http.StatusOK, api.TopicListResponse{
Topics: topicResponses,
})
}
+1 -1
View File
@@ -348,7 +348,7 @@ type swaggerFileDeleteResponse struct {
// swagger:response TopicListResponse
type swaggerTopicListResponse struct {
// in: body
Body []api.TopicResponse `json:"body"`
Body api.TopicListResponse `json:"body"`
}
// TopicNames
+1 -1
View File
@@ -46,7 +46,7 @@ type swaggerResponseUserHeatmapData struct {
// swagger:response UserSettings
type swaggerResponseUserSettings struct {
// in:body
Body []api.UserSettings `json:"body"`
Body api.UserSettings `json:"body"`
}
// BadgeList
+8 -2
View File
@@ -484,20 +484,26 @@ func SignOut(ctx *context.Context) {
}
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 != "" {
return s
}
}
// 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 != "" {
return setting.ReverseProxyLogoutRedirect
}
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 {
ctx.Data["Title"] = ctx.Tr("sign_up")
ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/sign_up"
+26 -11
View File
@@ -154,16 +154,31 @@ func TestWebAuthOAuth2(t *testing.T) {
authSource, err := auth_model.GetActiveOAuth2SourceByAuthName(t.Context(), "oidc-auth-source")
require.NoError(t, err)
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("dummy-sid")}
ctx, resp := contexttest.MockContext(t, "/user/logout", mockOpt)
ctx.Doer = &user_model.User{ID: 1, LoginType: auth_model.OAuth2, LoginSource: authSource.ID}
SignOut(ctx)
assert.Equal(t, http.StatusSeeOther, resp.Code)
u, err := url.Parse(test.RedirectURL(resp))
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())
oauthUser := &user_model.User{ID: 1, LoginType: auth_model.OAuth2, LoginSource: authSource.ID}
t.Run("OAuth2SignInRedirectsToOIDC", func(t *testing.T) {
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockMemStore("dummy-sid-oauth")}
ctx, resp := contexttest.MockContext(t, "/user/logout", mockOpt)
ctx.Doer = oauthUser
require.NoError(t, ctx.Session.Set(session.KeySignInMethod, session.SignInMethodOAuth2))
SignOut(ctx)
assert.Equal(t, http.StatusSeeOther, resp.Code)
u, err := url.Parse(test.RedirectURL(resp))
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))
})
})
}
+5 -3
View File
@@ -11,6 +11,7 @@ import (
"gitea.dev/models/auth"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/session"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
@@ -171,9 +172,10 @@ func oauth2LinkAccount(ctx *context.Context, u *user_model.User, linkAccountData
if err := regenerateSession(ctx, nil, map[string]any{
// User needs to use 2FA, save data and redirect to 2FA page.
"twofaUid": u.ID,
"twofaRemember": remember,
"linkAccount": true,
"twofaUid": u.ID,
"twofaRemember": remember,
"linkAccount": true,
session.KeySignInMethod: session.SignInMethodOAuth2,
}); err != nil {
ctx.ServerError("RegenerateSession", err)
return
+4 -2
View File
@@ -432,6 +432,7 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
session.KeyUID: u.ID,
session.KeyUname: u.Name,
session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth,
session.KeySignInMethod: session.SignInMethodOAuth2,
}); err != nil {
ctx.ServerError("updateSession", err)
return
@@ -455,8 +456,9 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
if err := regenerateSession(ctx, nil, map[string]any{
// User needs to use 2FA, save data and redirect to 2FA page.
"twofaUid": u.ID,
"twofaRemember": false,
"twofaUid": u.ID,
"twofaRemember": false,
session.KeySignInMethod: session.SignInMethodOAuth2,
}); err != nil {
ctx.ServerError("updateSession", err)
return
+19
View File
@@ -207,9 +207,24 @@ func IntrospectOAuth(ctx *context.Context) {
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
func AuthorizeOAuth(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.AuthorizationForm)
if !oauthDoerAuthorizePreCheck(ctx, form.State) {
return
}
errs := binding.Errors{}
errs = form.Validate(ctx.Req, errs)
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
func GrantApplicationOAuth(ctx *context.Context) {
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 ||
ctx.Session.Get("redirect_uri") != form.RedirectURI {
ctx.HTTPError(http.StatusBadRequest)
+17 -12
View File
@@ -567,9 +567,6 @@ func ViewPost(ctx *context_module.Context) {
}
func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, jobs []*actions_model.ActionRunJob) {
// Latest when the run has no attempts yet (legacy) or the viewed attempt is the run's latest.
isLatestAttempt := run.LatestAttemptID == 0 || (attempt != nil && attempt.ID == run.LatestAttemptID)
resp.State.Run.RepoID = ctx.Repo.Repository.ID
resp.State.Run.Index = run.Index
// the title for the "run" is from the commit message
@@ -578,8 +575,12 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse,
resp.State.Run.Link = run.Link()
resp.State.Run.ViewLink = getRunViewLink(run, attempt)
resp.State.Run.Attempts = make([]*ViewRunAttempt, 0)
// Legacy runs (LatestAttemptID == 0) have no attempt; their artifacts and summaries all
// share run_attempt_id=0, so passing 0 here scopes to this run's legacy rows only.
var runAttemptID int64
var effectiveStatus actions_model.Status
if attempt != nil {
runAttemptID = attempt.ID
effectiveStatus = attempt.Status
resp.State.Run.RunAttempt = attempt.Attempt
resp.State.Run.Duration = attempt.Duration().String()
@@ -589,6 +590,9 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse,
resp.State.Run.Duration = run.Duration().String()
resp.State.Run.TriggeredAt = run.Created.AsTime().Unix()
}
// Latest when the run has no attempts yet (legacy) or the viewed attempt is the run's latest.
isLatestAttempt := run.LatestAttemptID == 0 || runAttemptID == run.LatestAttemptID
resp.State.Run.Status = effectiveStatus.String()
resp.State.Run.Done = effectiveStatus.IsDone()
@@ -648,7 +652,7 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse,
Status: runAttempt.Status.String(),
Done: runAttempt.Status.IsDone(),
Link: getRunViewLink(run, runAttempt),
Current: runAttempt.ID == attempt.ID,
Current: runAttempt.ID == runAttemptID,
Latest: runAttempt.ID == run.LatestAttemptID,
TriggeredAt: runAttempt.Created.AsTime().Unix(),
TriggerUserName: runAttempt.TriggerUser.GetDisplayName(),
@@ -673,13 +677,6 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse,
resp.State.Run.PullRequest = refInfo.PullRequest
resp.State.Run.TriggerEvent = run.TriggerEvent
// Legacy runs (LatestAttemptID == 0) have no attempt; their artifacts and summaries all
// share run_attempt_id=0, so passing 0 here scopes to this run's legacy rows only.
var runAttemptID int64
if attempt != nil {
runAttemptID = attempt.ID
}
// Each step's markdown is rendered independently so an unclosed construct
// in one step can't bleed into the next.
// On a single-job view only that job's summaries are needed; the run view shows all.
@@ -1007,7 +1004,15 @@ func RerunFailed(ctx *context_module.Context) {
return
}
if _, err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, ctx.Doer, actions_service.GetFailedJobsForRerun(jobs)); err != nil {
// An empty job list means "re-run the whole run" to RerunWorkflowRunJobs, which is right for the plain
// rerun button but wrong here, so a direct POST on a fully successful run cannot re-run everything.
failedJobs := actions_service.GetFailedJobsForRerun(jobs)
if len(failedJobs) == 0 {
ctx.JSONError(ctx.Locale.Tr("actions.runs.no_failed_jobs"))
return
}
if _, err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, ctx.Doer, failedJobs); err != nil {
handleWorkflowRerunError(ctx, err)
return
}
+6 -1
View File
@@ -35,7 +35,11 @@ func AddDependency(ctx *context.Context) {
}
// Redirect
defer ctx.Redirect(issue.Link())
defer func() {
if !ctx.Written() {
ctx.Redirect(issue.Link())
}
}()
// Dependency
dep, err := issues_model.GetIssueByID(ctx, depID)
@@ -152,6 +156,7 @@ func RemoveDependency(ctx *context.Context) {
if err = issues_model.RemoveIssueDependency(ctx, ctx.Doer, issue, dep, depType); err != nil {
if issues_model.IsErrDependencyNotExists(err) {
ctx.Flash.Error(ctx.Tr("repo.issues.dependency.add_error_dep_not_exist"))
ctx.Redirect(issue.Link())
return
}
ctx.ServerError("RemoveIssueDependency", err)
+1
View File
@@ -742,6 +742,7 @@ func MoveIssues(ctx *context.Context) {
form := &movedIssuesForm{}
if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil {
ctx.ServerError("DecodeMovedIssuesForm", err)
return
}
issueIDs := make([]int64, 0, len(form.Issues))
+1 -1
View File
@@ -74,7 +74,7 @@ func SettingsProtectedBranch(c *context.Context) {
c.Data["PageIsSettingsBranches"] = true
c.Data["Title"] = c.Locale.TrString("repo.settings.protected_branch") + " - " + rule.RuleName
users, err := access_model.GetUsersWithUnitAccess(c, c.Repo.Repository, perm.AccessModeRead, unit.TypePullRequests)
users, err := access_model.GetUsersWithAnyUnitAccess(c, c.Repo.Repository, perm.AccessModeRead, unit.TypeCode, unit.TypePullRequests)
if err != nil {
c.ServerError("GetUsersWithUnitAccess", err)
return
+1 -1
View File
@@ -149,7 +149,7 @@ func setTagsContext(ctx *context.Context) error {
}
ctx.Data["ProtectedTags"] = protectedTags
users, err := access_model.GetUsersWithUnitAccess(ctx, ctx.Repo.Repository, perm.AccessModeRead, unit.TypePullRequests)
users, err := access_model.GetUsersWithAnyUnitAccess(ctx, ctx.Repo.Repository, perm.AccessModeRead, unit.TypeCode, unit.TypePullRequests)
if err != nil {
ctx.ServerError("GetUsersWithUnitAccess", err)
return err
+3
View File
@@ -28,6 +28,9 @@ func ApproveRuns(ctx context.Context, repo *repo_model.Repository, doer *user_mo
if err != nil {
return err
}
if !run.NeedApproval {
continue
}
run.NeedApproval = false
run.ApprovedBy = doer.ID
if err := actions_model.UpdateRun(ctx, run, "need_approval", "approved_by"); err != nil {
+67
View File
@@ -0,0 +1,67 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestApproveRuns(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
insertRun := func(index int64, status actions_model.Status, needApproval bool, approvedBy int64) *actions_model.ActionRun {
run := &actions_model.ActionRun{
Title: "approve-run", RepoID: repo.ID, OwnerID: repo.OwnerID, WorkflowID: "test.yaml", Index: index,
TriggerUserID: doer.ID, Ref: "refs/heads/main",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0", Event: "push", TriggerEvent: "push",
Status: status, NeedApproval: needApproval, ApprovedBy: approvedBy,
}
require.NoError(t, db.Insert(t.Context(), run))
return run
}
insertJob := func(run *actions_model.ActionRun, status actions_model.Status) *actions_model.ActionRunJob {
job := &actions_model.ActionRunJob{
RunID: run.ID, RepoID: run.RepoID, OwnerID: run.OwnerID, CommitSHA: run.CommitSHA,
Name: "job1", Attempt: 1, JobID: "job1", Status: status,
RunsOn: []string{"ubuntu-latest"},
}
require.NoError(t, db.Insert(t.Context(), job))
return job
}
t.Run("approve blocked run", func(t *testing.T) {
run := insertRun(1001, actions_model.StatusBlocked, true, 0)
job := insertJob(run, actions_model.StatusBlocked)
require.NoError(t, ApproveRuns(t.Context(), repo, doer, []int64{run.ID}))
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
assert.False(t, run.NeedApproval)
assert.Equal(t, doer.ID, run.ApprovedBy)
assert.Equal(t, actions_model.StatusWaiting, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Status)
})
t.Run("re-approving an approved run is a no-op", func(t *testing.T) {
run := insertRun(1002, actions_model.StatusRunning, false, 4)
job := insertJob(run, actions_model.StatusRunning)
require.NoError(t, ApproveRuns(t.Context(), repo, doer, []int64{run.ID}))
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
assert.EqualValues(t, 4, run.ApprovedBy, "approver must not be overwritten")
assert.Equal(t, actions_model.StatusRunning, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Status, "started job must not be reset to waiting")
})
}
+4 -2
View File
@@ -26,11 +26,13 @@ func StopZombieTasks(ctx context.Context) error {
}, actions_model.StatusRunning, actions_model.StatusCancelling)
}
// StopEndlessTasks stops tasks in running/cancelling status with continuous updates that don't end for a long time
// StopEndlessTasks stops running tasks with continuous updates that don't end for a long time.
// StatusRunning only: the threshold is the task's *start* time, so including StatusCancelling would kill a
// task mid post-cancel cleanup. StopZombieTasks covers a stalled one, keying off the last update instead.
func StopEndlessTasks(ctx context.Context) error {
return stopTasksByStatuses(ctx, actions_model.FindTaskOptions{
StartedBefore: timeutil.TimeStamp(time.Now().Add(-setting.Actions.EndlessTaskTimeout).Unix()),
}, actions_model.StatusRunning, actions_model.StatusCancelling)
}, actions_model.StatusRunning)
}
func stopTasksByStatuses(ctx context.Context, opts actions_model.FindTaskOptions, statuses ...actions_model.Status) error {
+53
View File
@@ -4,11 +4,16 @@
package actions
import (
"fmt"
"testing"
"time"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/modules/timeutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -88,3 +93,51 @@ func TestShouldBlockRunByConcurrency_CancellingJobBlocks(t *testing.T) {
require.NoError(t, err)
assert.True(t, shouldBlock)
}
// TestStopEndlessTasksSkipsCancelling verifies that a task running its post-cancel cleanup is not
// force-stopped by the endless-task sweep just because the job started long ago.
func TestStopEndlessTasksSkipsCancelling(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
// StopEndlessTasks emits ready jobs onto the emitter queue, mock it
defer test.MockVariableValue(&EmitJobsIfReadyByRun, func(runID int64) error { return nil })()
// well past the endless-task threshold, keyed on the task's start time
longAgo := timeutil.TimeStamp(time.Now().Add(-2 * setting.Actions.EndlessTaskTimeout).Unix())
var seq int64
newTaskWithJob := func(status actions_model.Status) *actions_model.ActionTask {
seq++
run := &actions_model.ActionRun{
RepoID: 1, OwnerID: 2, TriggerUserID: 2, WorkflowID: "test.yml",
Index: 99500 + seq, Ref: "refs/heads/main", Status: actions_model.StatusRunning,
}
require.NoError(t, db.Insert(t.Context(), run))
attempt := &actions_model.ActionRunAttempt{
RepoID: run.RepoID, RunID: run.ID, Attempt: 1, TriggerUserID: run.TriggerUserID, Status: actions_model.StatusRunning,
}
require.NoError(t, db.Insert(t.Context(), attempt))
job := &actions_model.ActionRunJob{
RunID: run.ID, RunAttemptID: attempt.ID, AttemptJobID: 1, RepoID: run.RepoID, OwnerID: run.OwnerID,
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0", Name: "j", JobID: "j", Status: status,
}
require.NoError(t, db.Insert(t.Context(), job))
task := &actions_model.ActionTask{
JobID: job.ID, RepoID: run.RepoID, OwnerID: run.OwnerID,
CommitSHA: job.CommitSHA, Status: status, Started: longAgo,
TokenHash: fmt.Sprintf("endless-test-token-%d", seq), TokenSalt: "salt",
}
require.NoError(t, db.Insert(t.Context(), task))
return task
}
running := newTaskWithJob(actions_model.StatusRunning)
cancelling := newTaskWithJob(actions_model.StatusCancelling)
require.NoError(t, StopEndlessTasks(t.Context()))
runningAfter := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: running.ID})
cancellingAfter := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: cancelling.ID})
assert.Equal(t, actions_model.StatusFailure, runningAfter.Status, "long-running task should be force-stopped")
assert.Equal(t, actions_model.StatusCancelling, cancellingAfter.Status, "cancelling task should keep running its cleanup")
}
+4 -1
View File
@@ -265,7 +265,10 @@ func createWorkflowCommitStatus(ctx context.Context, repo *repo_model.Repository
return fmt.Errorf("GetLatestCommitStatus: %w", err)
}
for _, v := range statuses {
if v.ContextHash == legacyHash && v.Context == ctxName {
// Only adopt the legacy (Context-only) hash from a row the Actions user itself created before the
// #35699 fix, i.e. a pre-upgrade in-flight run. Adopting it from an external integration or the API
// would collapse two same-named workflows back into a single check.
if v.ContextHash == legacyHash && v.Context == ctxName && v.CreatorID == user_model.ActionsUserID {
ctxHash = legacyHash
break
}
+61 -1
View File
@@ -218,7 +218,8 @@ func TestCreateCommitStatus_LegacyHashRecovery(t *testing.T) {
legacyHash := git_model.HashCommitStatusContext(ctxName)
sha, err := git.NewIDFromString(branch.CommitID)
require.NoError(t, err)
creator := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
// Pre-#35699 in-flight rows were posted by the Actions user with the Context-only hash.
creator := user_model.NewActionsUser()
require.NoError(t, git_model.NewCommitStatus(t.Context(), git_model.NewCommitStatusOptions{
Repo: repo,
Creator: creator,
@@ -259,6 +260,65 @@ func TestCreateCommitStatus_LegacyHashRecovery(t *testing.T) {
assert.Equal(t, 1, matches)
}
// TestCreateCommitStatus_LegacyHashExternalNotAdopted: a status from a non-Actions creator sharing a
// workflow's Context must not pull the workflow into the legacy Context-only hash group.
func TestCreateCommitStatus_LegacyHashExternalNotAdopted(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: repo.DefaultBranch})
workflowID := "external.yaml"
ctxName := "external.yaml / my-job (push)"
legacyHash := git_model.HashCommitStatusContext(ctxName)
distinctHash := git_model.HashCommitStatusContext(ctxName + "\x00" + workflowID)
sha, err := git.NewIDFromString(branch.CommitID)
require.NoError(t, err)
// An external status (posted by a real user, not the Actions user) sharing the same Context.
externalCreator := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
require.NoError(t, git_model.NewCommitStatus(t.Context(), git_model.NewCommitStatusOptions{
Repo: repo,
Creator: externalCreator,
SHA: sha,
CommitStatus: &git_model.CommitStatus{
State: commitstatus.CommitStatusSuccess,
Context: ctxName,
ContextHash: legacyHash,
TargetURL: "https://example.invalid/external",
Description: "external check",
},
}))
run := &actions_model.ActionRun{
ID: 99311, Index: 99311, RepoID: repo.ID, Repo: repo, OwnerID: repo.OwnerID, TriggerUserID: repo.OwnerID,
WorkflowID: workflowID, CommitSHA: branch.CommitID,
}
require.NoError(t, db.Insert(t.Context(), run))
job := &actions_model.ActionRunJob{
ID: 99312, RunID: run.ID, RepoID: repo.ID, OwnerID: repo.OwnerID,
Name: "my-job", Status: actions_model.StatusSuccess,
}
require.NoError(t, db.Insert(t.Context(), job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, "", run, job))
latest, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, branch.CommitID, db.ListOptionsAll)
require.NoError(t, err)
// The external status and the workflow status must coexist under distinct hashes.
var external, workflow *git_model.CommitStatus
for _, s := range latest {
switch s.ContextHash {
case legacyHash:
external = s
case distinctHash:
workflow = s
}
}
require.NotNil(t, external, "external status must be preserved under the legacy hash")
require.NotNil(t, workflow, "workflow status must use its own distinct hash, not the external legacy hash")
assert.Equal(t, "https://example.invalid/external", external.TargetURL)
}
// TestCreateCommitStatus_UnnamedWorkflowUsesFileName: a workflow with no
// non-blank `name:` uses the file name in the Context, not an empty
// "/ job (event)" — covers both an omitted and a whitespace-only name.
+7 -2
View File
@@ -49,7 +49,7 @@ func GenerateGiteaContext(ctx context.Context, run *actions_model.ActionRun, att
// In GitHub's documentation, ref should be the branch or tag that triggered workflow. But when the TriggerEvent is pull_request_target,
// the ref will be the base branch.
if run.TriggerEvent == actions_module.GithubEventPullRequestTarget {
ref = git.BranchPrefix + pullPayload.PullRequest.Base.Name
ref = git.BranchPrefix + pullPayload.PullRequest.Base.Ref
sha = pullPayload.PullRequest.Base.Sha
}
}
@@ -134,7 +134,12 @@ func GenerateGiteaContext(ctx context.Context, run *actions_model.ActionRun, att
if attempt != nil {
gitContext["run_attempt"] = strconv.FormatInt(attempt.Attempt, 10)
if err := attempt.LoadAttributes(ctx); err == nil {
// Only the trigger user is needed for triggering_actor. attempt.LoadAttributes would also re-load
// the run this function already holds as a parameter, on the hot task-dispatch path.
if err := attempt.LoadTriggerUser(ctx); err != nil {
log.Error("GenerateGiteaContext: load trigger user of attempt %d: %v", attempt.ID, err)
}
if attempt.TriggerUser != nil {
gitContext["triggering_actor"] = attempt.TriggerUser.Name
}
}
+72
View File
@@ -9,9 +9,13 @@ import (
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/json"
api "gitea.dev/modules/structs"
webhook_module "gitea.dev/modules/webhook"
act_model "gitea.com/gitea/runner/act/model"
"github.com/stretchr/testify/assert"
@@ -318,3 +322,71 @@ func TestFindTaskNeeds(t *testing.T) {
assert.Equal(t, "abc", ret["job1"].Outputs["output_a"])
assert.Equal(t, "bbb", ret["job1"].Outputs["output_b"])
}
func TestGenerateGiteaContextPullRequestTarget(t *testing.T) {
payload := api.PullRequestPayload{
PullRequest: &api.PullRequest{
Base: &api.PRBranchInfo{
Name: "owner:main",
Ref: "main",
Sha: "1234567890abcdef",
},
Head: &api.PRBranchInfo{
Name: "fork:feature",
Ref: "feature",
Sha: "fedcba0987654321",
},
},
}
payloadBytes, err := json.Marshal(payload)
assert.NoError(t, err)
run := &actions_model.ActionRun{
Event: webhook_module.HookEventPullRequest,
TriggerEvent: string(actions_module.GithubEventPullRequestTarget),
EventPayload: string(payloadBytes),
TriggerUser: &user_model.User{Name: "test-user"},
Repo: &repo_model.Repository{Name: "test-repo", OwnerName: "test-owner"},
}
giteaCtx := GenerateGiteaContext(t.Context(), run, nil, nil)
assert.Equal(t, "refs/heads/main", giteaCtx["ref"])
assert.Equal(t, "main", giteaCtx["ref_name"])
}
// TestGenerateGiteaContext_NilAttempt verifies that, with no explicit attempt,
// use GetLatestAttempt to load the latest attempt and resolve attempt-related context variables.
func TestGenerateGiteaContext_NilAttempt(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
require.NoError(t, repo.LoadOwner(t.Context()))
actor := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) // initiated the run
triggerer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // initiated the latest attempt
run := &actions_model.ActionRun{
RepoID: repo.ID, Repo: repo, OwnerID: repo.OwnerID,
TriggerUserID: actor.ID, TriggerUser: actor,
WorkflowID: "test.yml", Index: 99600, Ref: "refs/heads/main",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0", TriggerEvent: "push",
Status: actions_model.StatusRunning,
}
require.NoError(t, db.Insert(t.Context(), run))
attempt := &actions_model.ActionRunAttempt{
RepoID: repo.ID, RunID: run.ID, Attempt: 3, TriggerUserID: triggerer.ID, Status: actions_model.StatusRunning,
}
require.NoError(t, db.Insert(t.Context(), attempt))
run.LatestAttemptID = attempt.ID
job := &actions_model.ActionRunJob{
RunID: run.ID, RunAttemptID: attempt.ID, AttemptJobID: 1, RepoID: repo.ID, OwnerID: repo.OwnerID,
Name: "j", JobID: "j", Attempt: attempt.Attempt, Status: actions_model.StatusRunning,
}
require.NoError(t, db.Insert(t.Context(), job))
// attempt == nil forces the fallback lookup via run.GetLatestAttempt.
gitCtx := GenerateGiteaContext(t.Context(), run, nil, job)
assert.Equal(t, actor.Name, gitCtx["actor"])
assert.Equal(t, triggerer.Name, gitCtx["triggering_actor"])
assert.Equal(t, "3", gitCtx["run_attempt"])
}
+66 -40
View File
@@ -15,6 +15,7 @@ import (
"gitea.dev/modules/log"
"gitea.dev/modules/queue"
"gitea.dev/modules/setting"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"xorm.io/builder"
@@ -26,7 +27,7 @@ type jobUpdate struct {
RunID int64
}
func EmitJobsIfReadyByRun(runID int64) error {
var EmitJobsIfReadyByRun = func(runID int64) error {
err := jobEmitterQueue.Push(&jobUpdate{
RunID: runID,
})
@@ -96,7 +97,7 @@ func checkJobsByRunID(ctx context.Context, runID int64) error {
continue
}
if err := EmitJobsIfReadyByRun(rid); err != nil {
log.Error("re-emit run %d after caller expansion: %v", rid, err)
log.Error("re-emit run %d: %v", rid, err)
}
}
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, result.CancelledJobs)
@@ -147,57 +148,66 @@ func createCommitStatusesForJobsByRun(ctx context.Context, jobs []*actions_model
return nil
}
// findBlockedRunIDByConcurrency finds a blocked concurrent run in a repo and returns 0 when there is no blocked run.
func findBlockedRunIDByConcurrency(ctx context.Context, repoID int64, concurrencyGroup string) (int64, error) {
// findConcurrencyWaiterToWake returns a run (other than excludeRunID) blocked on the group that can be woken now
func findConcurrencyWaiterToWake(ctx context.Context, repoID, excludeRunID int64, concurrencyGroup string) (int64, error) {
if concurrencyGroup == "" {
return 0, nil
}
// The slot should be free before any waiter can proceed.
holderAttempts, holderJobs, err := actions_model.GetConcurrentRunAttemptsAndJobs(ctx, repoID, concurrencyGroup, []actions_model.Status{actions_model.StatusRunning, actions_model.StatusCancelling})
if err != nil {
return 0, fmt.Errorf("find concurrency-group holders: %w", err)
}
if len(holderAttempts) > 0 || len(holderJobs) > 0 {
return 0, nil
}
cAttempts, cJobs, err := actions_model.GetConcurrentRunAttemptsAndJobs(ctx, repoID, concurrencyGroup, []actions_model.Status{actions_model.StatusBlocked})
if err != nil {
return 0, fmt.Errorf("find concurrent runs and jobs: %w", err)
return 0, fmt.Errorf("find blocked concurrent runs: %w", err)
}
if len(cAttempts) > 0 {
return cAttempts[0].RunID, nil
for _, a := range cAttempts {
if a.RunID != excludeRunID {
return a.RunID, nil
}
}
if len(cJobs) > 0 {
return cJobs[0].RunID, nil
for _, j := range cJobs {
if j.RunID != excludeRunID {
return j.RunID, nil
}
}
return 0, nil
}
func checkBlockedConcurrentRun(ctx context.Context, repoID, runID int64) (*jobsCheckResult, error) {
concurrentRun, err := actions_model.GetRunByRepoAndID(ctx, repoID, runID)
if err != nil {
return nil, fmt.Errorf("get run %d: %w", runID, err)
}
if concurrentRun.NeedApproval {
return &jobsCheckResult{}, nil
}
return checkJobsOfCurrentRunAttempt(ctx, concurrentRun)
}
// checkRunConcurrency rechecks runs blocked by concurrency that may become unblocked after the current run releases a workflow-level or job-level concurrency group.
// RunIDsToReEmit propagates from inner checkJobsOfCurrentRunAttempt calls; see that function's doc.
// checkRunConcurrency wakes a run blocked by concurrency that may become runnable now that
// the current run's activity may have freed a workflow-level or job-level concurrency group.
func checkRunConcurrency(ctx context.Context, run *actions_model.ActionRun) (*jobsCheckResult, error) {
result := &jobsCheckResult{}
checkedConcurrencyGroup := make(container.Set[string])
collect := func(concurrencyGroup string) error {
concurrentRunID, err := findBlockedRunIDByConcurrency(ctx, run.RepoID, concurrencyGroup)
if err != nil {
return fmt.Errorf("find blocked run by concurrency: %w", err)
}
if concurrentRunID > 0 {
r, err := checkBlockedConcurrentRun(ctx, run.RepoID, concurrentRunID)
if err != nil {
return err
}
result.merge(r)
}
checkedConcurrencyGroup.Add(concurrencyGroup)
// Exclude run.ID: this run's own jobs are resolved by checkJobsOfCurrentRunAttempt, no need to re-emit.
concurrentRunID, err := findConcurrencyWaiterToWake(ctx, run.RepoID, run.ID, concurrencyGroup)
if err != nil {
return err
}
if concurrentRunID == 0 {
return nil
}
concurrentRun, err := actions_model.GetRunByRepoAndID(ctx, run.RepoID, concurrentRunID)
if err != nil {
return fmt.Errorf("get concurrent run %d: %w", concurrentRunID, err)
}
// A run awaiting approval is not advanced by concurrency; ApproveRuns emits it once approved.
if concurrentRun.NeedApproval {
return nil
}
result.RunIDsToReEmit = append(result.RunIDsToReEmit, concurrentRunID)
return nil
}
@@ -282,10 +292,26 @@ func checkJobsOfCurrentRunAttempt(ctx context.Context, run *actions_model.Action
switch status {
case actions_model.StatusWaiting:
if err := expandReusableWorkflowCaller(ctx, run, attempt, job, vars); err != nil {
return fmt.Errorf("trigger caller-ready %d: %w", job.ID, err)
// Terminal expansion failure (an invalid/unresolvable reusable workflow): fail this caller.
log.Warn("caller %d cannot be expanded: %v", job.ID, err)
job.Status = actions_model.StatusFailure
job.Stopped = timeutil.TimeStampNow()
if n, uerr := actions_model.UpdateRunJob(ctx, job, builder.Eq{"status": actions_model.StatusBlocked, "is_expanded": false}, "status", "stopped"); uerr != nil {
return fmt.Errorf("mark unexpandable caller %d failed: %w", job.ID, uerr)
} else if n == 1 {
log.Warn("unexpandable caller %d has been marked as failed", job.ID)
result.UpdatedJobs = append(result.UpdatedJobs, job)
// Re-emit so the failed caller's dependents get resolved on the next pass.
expandedAnyCaller = true
} else {
// A concurrent writer advanced the caller; restore the in-memory state.
log.Warn("unexpandable caller %d has been advanced by a concurrent writer, not marking it failed", job.ID)
job.Status = actions_model.StatusBlocked
job.Stopped = 0
}
} else {
expandedAnyCaller = true
}
// expandReusableWorkflowCaller inserts children as Blocked. They need a follow-up resolver pass.
expandedAnyCaller = true
case actions_model.StatusSkipped:
job.Status = actions_model.StatusSkipped
if _, err := actions_model.UpdateRunJob(ctx, job, nil, "status"); err != nil {
@@ -477,7 +503,7 @@ type jobsCheckResult struct {
UpdatedJobs []*actions_model.ActionRunJob
// CancelledJobs are jobs cancelled by job-level concurrency while preparing to start.
CancelledJobs []*actions_model.ActionRunJob
// RunIDsToReEmit are runs whose newly expanded reusable workflow callers need another resolver pass.
// RunIDsToReEmit are runs that need another resolver pass in their own transaction.
RunIDsToReEmit []int64
}
+87 -6
View File
@@ -203,7 +203,7 @@ func Test_checkRunConcurrency_NoDuplicateConcurrencyGroupCheck(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
// Run A: the triggering run of attempt A
// Run A: the triggering run of attempt A. It is done, so it no longer holds "test-cg", which is what lets checkRunConcurrency wake the blocked waiter.
runA := &actions_model.ActionRun{
RepoID: 4,
OwnerID: 1,
@@ -211,16 +211,16 @@ func Test_checkRunConcurrency_NoDuplicateConcurrencyGroupCheck(t *testing.T) {
WorkflowID: "test.yml",
Index: 9901,
Ref: "refs/heads/main",
Status: actions_model.StatusRunning,
Status: actions_model.StatusSuccess,
}
assert.NoError(t, db.Insert(ctx, runA))
// Attempt A: an attempt of run A with concurrency group "test-cg"
// Attempt A: a done attempt of run A with concurrency group "test-cg"
runAAttempt := &actions_model.ActionRunAttempt{
RepoID: 4,
RunID: runA.ID,
Attempt: 1,
Status: actions_model.StatusRunning,
Status: actions_model.StatusSuccess,
ConcurrencyGroup: "test-cg",
}
assert.NoError(t, db.Insert(ctx, runAAttempt))
@@ -283,9 +283,11 @@ func Test_checkRunConcurrency_NoDuplicateConcurrencyGroupCheck(t *testing.T) {
result, err := checkRunConcurrency(ctx, runA)
assert.NoError(t, err)
if assert.Len(t, result.Jobs, 1) {
assert.Equal(t, jobBBlocked.ID, result.Jobs[0].ID)
// "test-cg" is free, so the single blocked waiter (run B) is collected for re-emit.
if assert.Len(t, result.RunIDsToReEmit, 1) {
assert.Equal(t, runB.ID, result.RunIDsToReEmit[0])
}
assert.Empty(t, result.Jobs)
}
// Test_checkJobsOfCurrentRunAttempt_RunLevelConcurrencyKeepsJobsBlocked verifies that
@@ -352,3 +354,82 @@ jobs:
refreshed := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: blockedJob.ID})
assert.Equal(t, actions_model.StatusBlocked, refreshed.Status)
}
// Test_checkRunConcurrency_HeldGroupDoesNotWake verifies that only an unoccupied concurrency group can wake up a blocked run/job.
func Test_checkRunConcurrency_HeldGroupDoesNotWake(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
// Run A holds "test-cg": its attempt is still running.
runA := &actions_model.ActionRun{
RepoID: 4, OwnerID: 1, TriggerUserID: 1, WorkflowID: "test.yml",
Index: 9911, Ref: "refs/heads/main", Status: actions_model.StatusRunning,
}
assert.NoError(t, db.Insert(ctx, runA))
runAAttempt := &actions_model.ActionRunAttempt{
RepoID: 4, RunID: runA.ID, Attempt: 1, Status: actions_model.StatusRunning, ConcurrencyGroup: "test-cg",
}
assert.NoError(t, db.Insert(ctx, runAAttempt))
_, err := db.Exec(ctx, "UPDATE `action_run` SET latest_attempt_id = ? WHERE id = ?", runAAttempt.ID, runA.ID)
assert.NoError(t, err)
// Run B is blocked on the same group.
runB := &actions_model.ActionRun{
RepoID: 4, OwnerID: 1, TriggerUserID: 1, WorkflowID: "test.yml",
Index: 9912, Ref: "refs/heads/main", Status: actions_model.StatusBlocked,
}
assert.NoError(t, db.Insert(ctx, runB))
runBAttempt := &actions_model.ActionRunAttempt{
RepoID: 4, RunID: runB.ID, Attempt: 1, Status: actions_model.StatusBlocked, ConcurrencyGroup: "test-cg",
}
assert.NoError(t, db.Insert(ctx, runBAttempt))
_, err = db.Exec(ctx, "UPDATE `action_run` SET latest_attempt_id = ? WHERE id = ?", runBAttempt.ID, runB.ID)
assert.NoError(t, err)
runA, _, _ = db.GetByID[actions_model.ActionRun](ctx, runA.ID)
result, err := checkRunConcurrency(ctx, runA)
assert.NoError(t, err)
// The group is held by run A, so run B must not be woken; A will wake it when it releases the group.
assert.Empty(t, result.RunIDsToReEmit)
}
// Test_findConcurrencyWaiterToWake covers the finder's contract: it skips the run being processed (excludeRunID),
// returns another blocked waiter when the group is free, and returns 0 while the group is still held.
func Test_findConcurrencyWaiterToWake(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
const repoID int64 = 4
seed := func(index int64, group string, status actions_model.Status) *actions_model.ActionRun {
run := &actions_model.ActionRun{
RepoID: repoID, OwnerID: 1, TriggerUserID: 1, WorkflowID: "test.yml",
Index: index, Ref: "refs/heads/main", Status: status,
}
assert.NoError(t, db.Insert(ctx, run))
assert.NoError(t, db.Insert(ctx, &actions_model.ActionRunAttempt{
RepoID: repoID, RunID: run.ID, Attempt: 1, Status: status, ConcurrencyGroup: group,
}))
return run
}
// Free group "excl-cg" with two blocked runs: excluding self returns the other waiter, not self.
self := seed(99701, "excl-cg", actions_model.StatusBlocked)
other := seed(99702, "excl-cg", actions_model.StatusBlocked)
id, err := findConcurrencyWaiterToWake(ctx, repoID, self.ID, "excl-cg")
assert.NoError(t, err)
assert.Equal(t, other.ID, id)
// Free group "solo-cg" with only self blocked: excluding it leaves no waiter.
solo := seed(99703, "solo-cg", actions_model.StatusBlocked)
id, err = findConcurrencyWaiterToWake(ctx, repoID, solo.ID, "solo-cg")
assert.NoError(t, err)
assert.Equal(t, int64(0), id)
// Held group "held-cg" (a running holder) has a blocked waiter, but nothing is woken while held.
seed(99704, "held-cg", actions_model.StatusRunning)
seed(99705, "held-cg", actions_model.StatusBlocked)
id, err = findConcurrencyWaiterToWake(ctx, repoID, 0, "held-cg")
assert.NoError(t, err)
assert.Equal(t, int64(0), id)
}
+54 -19
View File
@@ -5,6 +5,7 @@ package actions
import (
"context"
"errors"
"fmt"
"strings"
@@ -31,6 +32,20 @@ import (
// a top-level caller may have at most MaxReusableCallLevels nested callers below it.
const MaxReusableCallLevels = 9
// checkRunJobLimit rejects an expansion that would push the attempt over actions_model.MaxJobNumPerRun.
// checkCallerChain bounds nesting *depth*, but a reusable graph also fans out in *breadth*: without a
// cumulative cap a tiny set of files can drive exponential job-row insertion and exhaust the database.
func checkRunJobLimit(ctx context.Context, runID, attemptID int64, adding int) error {
existing, err := actions_model.CountRunJobsByRunAndAttemptID(ctx, runID, attemptID)
if err != nil {
return fmt.Errorf("count existing jobs of run %d attempt %d: %w", runID, attemptID, err)
}
if existing+int64(adding) > actions_model.MaxJobNumPerRun {
return fmt.Errorf("workflow run exceeds the maximum of %d jobs", actions_model.MaxJobNumPerRun)
}
return nil
}
// loadReusableWorkflowSource resolves the workflow file referenced by a caller's `uses:` and returns its raw bytes,
// along with the (repo_id, commit_sha) the file was loaded from.
func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRun, caller *actions_model.ActionRunJob, ref *jobparser.UsesRef) (content []byte, sourceRepoID int64, sourceCommitSHA string, err error) {
@@ -236,30 +251,33 @@ func expandReusableWorkflowCaller(ctx context.Context, run *actions_model.Action
return fmt.Errorf("build call payload: %w", err)
}
// 8. Insert direct children of this caller.
existingChildren, err := actions_model.GetDirectChildJobsByParent(ctx, caller)
if err != nil {
return fmt.Errorf("get existing children of caller %d: %w", caller.ID, err)
}
if len(existingChildren) > 0 {
// Should not happen - child jobs cannot be expanded before the caller gets ready
return fmt.Errorf("invariant violation: caller %d has %d pre-existing children", caller.ID, len(existingChildren))
}
if err := insertCallerChildren(ctx, run, attempt, caller, content, contentSourceRepoID, contentSourceCommitSHA, vars, workflowCallInputs); err != nil {
return err
}
// 9. Update caller-related cols.
caller.CallPayload = string(callPayload)
// 8. Claim the expansion by flipping is_expanded false->true BEFORE inserting any children.
// Two concurrent expanders serialize on this row: exactly one winner matches (n==1) and owns the expansion.
// Children are only ever inserted by the claim winner, so no duplicate child rows can arise.
caller.IsExpanded = true
n, err := actions_model.UpdateRunJob(ctx, caller,
n, err := actions_model.UpdateRunJob(ctx, caller, builder.And(
builder.Eq{"is_expanded": false},
"call_secrets", "reusable_workflow_content", "call_payload", "is_expanded")
builder.In("status", actions_model.StatusBlocked, actions_model.StatusWaiting),
), "is_expanded")
if err != nil {
return fmt.Errorf("commit caller %d expansion: %w", caller.ID, err)
caller.IsExpanded = false // the claim was not established
return fmt.Errorf("claim caller %d expansion: %w", caller.ID, err)
}
if n == 0 {
return fmt.Errorf("caller %d already expanded by another writer", caller.ID)
// Another writer won the expansion, or the caller has been moved to a terminal status (e.g. failed/cancelled).
return nil
}
// 9. We own the expansion: insert the direct children.
if err := insertCallerChildren(ctx, run, attempt, caller, content, contentSourceRepoID, contentSourceCommitSHA, vars, workflowCallInputs); err != nil {
// On failure, undo the partial expansion so an error return always leaves the caller unexpanded and childless.
return errors.Join(err, undoExpansion(ctx, caller))
}
// 10. Persist the remaining caller metadata (the row is already ours via the claim above).
caller.CallPayload = string(callPayload)
if _, err := actions_model.UpdateRunJob(ctx, caller, nil, "call_secrets", "reusable_workflow_content", "call_payload"); err != nil {
return errors.Join(fmt.Errorf("persist caller %d expansion metadata: %w", caller.ID, err), undoExpansion(ctx, caller))
}
return nil
}
@@ -285,6 +303,10 @@ func insertCallerChildren(ctx context.Context, run *actions_model.ActionRun, att
return fmt.Errorf("called workflow for caller %d (uses %q) has no jobs", caller.ID, caller.CallUses)
}
if err := checkRunJobLimit(ctx, run.ID, attempt.ID, len(childWorkflows)); err != nil {
return err
}
priorChildren, err := actions_model.GetPriorAttemptChildrenByParent(ctx, run.ID, attempt.ID, caller.AttemptJobID)
if err != nil {
return fmt.Errorf("lookup prior-attempt children of caller %d: %w", caller.ID, err)
@@ -375,3 +397,16 @@ func ResolveUses(ctx context.Context, uses string) (*jobparser.UsesRef, error) {
}
return ref, nil
}
// undoExpansion rolls back a partial expansion owned by the current transaction:
// it removes the inserted children and releases the is_expanded claim itself.
func undoExpansion(ctx context.Context, caller *actions_model.ActionRunJob) error {
if err := actions_model.DeleteDirectChildJobsByParent(ctx, caller); err != nil {
return fmt.Errorf("delete children of caller %d: %w", caller.ID, err)
}
caller.IsExpanded = false
if _, err := actions_model.UpdateRunJob(ctx, caller, nil, "is_expanded"); err != nil {
return fmt.Errorf("release caller %d expansion claim: %w", caller.ID, err)
}
return nil
}
@@ -206,3 +206,75 @@ func TestResolveUses(t *testing.T) {
assert.ErrorContains(t, err, "must point to this Gitea instance")
})
}
func TestCheckRunJobLimit(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
const (
runID = 900100
attemptA = 910001
attemptB = 910002
)
seed := func(attemptID int64, n int) {
for i := range n {
name := fmt.Sprintf("job-%d-%d", attemptID, i)
require.NoError(t, db.Insert(t.Context(), &actions_model.ActionRunJob{
RunID: runID,
RunAttemptID: attemptID,
RepoID: 1,
OwnerID: 1,
CommitSHA: "abcdef",
Name: name,
JobID: name,
AttemptJobID: attemptID*1000 + int64(i),
Status: actions_model.StatusBlocked,
}))
}
}
seed(attemptA, 5)
seed(attemptB, 3) // a different attempt of the same run must not count toward attempt A
limit := actions_model.MaxJobNumPerRun
// attempt A already holds 5 jobs: filling up to the cap is allowed, one more is rejected.
require.NoError(t, checkRunJobLimit(t.Context(), runID, attemptA, limit-5))
require.ErrorContains(t, checkRunJobLimit(t.Context(), runID, attemptA, limit-4), "maximum")
require.ErrorContains(t, checkRunJobLimit(t.Context(), runID, attemptA, limit), "maximum")
// the count is scoped to the attempt: attempt B only holds 3 jobs, so attempt A's 5 must not leak in.
require.NoError(t, checkRunJobLimit(t.Context(), runID, attemptB, limit-3))
require.ErrorContains(t, checkRunJobLimit(t.Context(), runID, attemptB, limit-2), "maximum")
}
func TestUndoExpansion(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
// A claimed caller with two children inserted by the aborted expansion, plus a sibling that must survive.
caller := &actions_model.ActionRunJob{
RunID: 991, RepoID: 4, OwnerID: 1, JobID: "caller", Name: "caller",
Status: actions_model.StatusBlocked, IsReusableCaller: true, IsExpanded: true,
}
require.NoError(t, db.Insert(ctx, caller))
for _, jobID := range []string{"child1", "child2"} {
require.NoError(t, db.Insert(ctx, &actions_model.ActionRunJob{
RunID: 991, RepoID: 4, OwnerID: 1, JobID: jobID, Name: jobID,
Status: actions_model.StatusBlocked, ParentJobID: caller.ID,
}))
}
sibling := &actions_model.ActionRunJob{
RunID: 991, RepoID: 4, OwnerID: 1, JobID: "sibling", Name: "sibling",
Status: actions_model.StatusBlocked,
}
require.NoError(t, db.Insert(ctx, sibling))
require.NoError(t, undoExpansion(ctx, caller))
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRunJob{ParentJobID: caller.ID}))
assert.False(t, caller.IsExpanded)
refreshed := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: caller.ID})
assert.False(t, refreshed.IsExpanded)
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: sibling.ID})
}
+2 -2
View File
@@ -142,7 +142,7 @@ func getWhitelistEntities[T *user_model.User | *organization.Team](entities []T,
// ToBranchProtection convert a ProtectedBranch to api.BranchProtection
func ToBranchProtection(ctx context.Context, bp *git_model.ProtectedBranch, repo *repo_model.Repository) *api.BranchProtection {
readers, err := access_model.GetUsersWithUnitAccess(ctx, repo, perm.AccessModeRead, unit.TypePullRequests)
readers, err := access_model.GetUsersWithAnyUnitAccess(ctx, repo, perm.AccessModeRead, unit.TypeCode, unit.TypePullRequests)
if err != nil {
log.Error("GetRepoReaders: %v", err)
}
@@ -941,7 +941,7 @@ func ToAnnotatedTagObject(repo *repo_model.Repository, commit *git.Commit) *api.
// ToTagProtection convert a git.ProtectedTag to an api.TagProtection
func ToTagProtection(ctx context.Context, pt *git_model.ProtectedTag, repo *repo_model.Repository) *api.TagProtection {
readers, err := access_model.GetUsersWithUnitAccess(ctx, repo, perm.AccessModeRead, unit.TypePullRequests)
readers, err := access_model.GetUsersWithAnyUnitAccess(ctx, repo, perm.AccessModeRead, unit.TypeCode, unit.TypePullRequests)
if err != nil {
log.Error("GetRepoReaders: %v", err)
}
+5
View File
@@ -60,6 +60,11 @@ func runGitDiffTree(ctx context.Context, gitRepo *git.Repository, useMergeBase b
cmd := gitcmd.NewCommand("diff-tree", "--raw", "-r", "--root").
AddOptionFormat("--find-renames=%s", setting.Git.DiffRenameSimilarityThreshold)
// HINT: GIT-DIFF-TREE-UI-CONFIG: apply the diff.orderfile explicitly
if git.GlobalConfig.DiffOrderFile != "" {
cmd.AddOptionFormat("-O%s", git.GlobalConfig.DiffOrderFile)
}
if useMergeBase {
cmd.AddArguments("--merge-base")
}
+40
View File
@@ -4,10 +4,13 @@
package gitdiff
import (
"os"
"path/filepath"
"strings"
"testing"
"gitea.dev/modules/git"
"gitea.dev/modules/git/gitcmd"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -217,6 +220,43 @@ func TestGitDiffTree(t *testing.T) {
}
}
func TestGitDiffTreeRespectsDiffOrderFile(t *testing.T) {
gitRepo, err := git.OpenRepository(t.Context(), "../../modules/git/tests/repos/repo5_pulls")
require.NoError(t, err)
defer gitRepo.Close()
testDiffTree := func(t *testing.T) (filePaths []string) {
t.Helper()
diffTree, err := GetDiffTree(t.Context(), gitRepo, false, "72866af952e98d02a73003501836074b286a78f6", "d8e0bbb45f200e67d9a784ce55bd90821af45ebd")
require.NoError(t, err)
for _, f := range diffTree.Files {
filePaths = append(filePaths, f.HeadPath)
}
return filePaths
}
t.Run("NoDiffOrderFile", func(t *testing.T) {
assert.Equal(t, []string{"LICENSE", "README.md"}, testDiffTree(t))
})
t.Run("GlobalDiffOrderFile", func(t *testing.T) {
diffOrderFilePath := filepath.Join(t.TempDir(), "test-diff-order.txt")
err = os.WriteFile(diffOrderFilePath, []byte("README.md\nLICENSE\n"), 0o644)
require.NoError(t, err)
_, _, err = gitcmd.NewCommand("config", "set", "--global").AddDynamicArguments("diff.orderFile", diffOrderFilePath).RunStdString(t.Context())
require.NoError(t, err)
require.NoError(t, git.InitFull())
defer func() {
_, _, err = gitcmd.NewCommand("config", "unset", "--global").AddDynamicArguments("diff.orderFile").RunStdString(t.Context())
require.NoError(t, err)
require.NoError(t, git.InitFull())
}()
assert.Equal(t, []string{"README.md", "LICENSE"}, testDiffTree(t))
})
}
func TestParseGitDiffTree(t *testing.T) {
test := []struct {
Name string
+7
View File
@@ -150,7 +150,10 @@ func DeleteRepositoryDirectly(ctx context.Context, repoID int64, ignoreOrgTeams
&repo_model.Collaboration{RepoID: repoID},
&issues_model.Comment{RefRepoID: repoID},
&git_model.CommitStatus{RepoID: repoID},
&git_model.CommitStatusIndex{RepoID: repoID},
&git_model.CommitStatusSummary{RepoID: repoID},
&git_model.Branch{RepoID: repoID},
&git_model.RenamedBranch{RepoID: repoID},
&git_model.LFSLock{RepoID: repoID},
&repo_model.LanguageStat{RepoID: repoID},
&repo_model.RepoLicense{RepoID: repoID},
@@ -163,22 +166,26 @@ func DeleteRepositoryDirectly(ctx context.Context, repoID int64, ignoreOrgTeams
&repo_model.Release{RepoID: repoID},
&repo_model.RepoIndexerStatus{RepoID: repoID},
&repo_model.Redirect{RedirectRepoID: repoID},
&repo_model.RepoTransfer{RepoID: repoID}, // this column doesn't have index, maybe it's fine since the table shouldn't be too large.
&repo_model.RepoUnit{RepoID: repoID},
&repo_model.Star{RepoID: repoID},
&admin_model.Task{RepoID: repoID},
&repo_model.Watch{RepoID: repoID},
&webhook.Webhook{RepoID: repoID},
&secret_model.Secret{RepoID: repoID},
&actions_model.ActionVariable{RepoID: repoID},
&actions_model.ActionTaskStep{RepoID: repoID},
&actions_model.ActionTask{RepoID: repoID},
&actions_model.ActionRunJob{RepoID: repoID},
&actions_model.ActionRun{RepoID: repoID},
&actions_model.ActionRunAttempt{RepoID: repoID},
&actions_model.ActionRunner{RepoID: repoID},
&actions_model.ActionScheduleSpec{RepoID: repoID},
&actions_model.ActionSchedule{RepoID: repoID},
&actions_model.ActionArtifact{RepoID: repoID},
&actions_model.ActionRunJobSummary{RepoID: repoID},
&actions_model.ActionRunnerToken{RepoID: repoID},
&actions_model.ActionTasksVersion{RepoID: repoID},
&actions_model.ActionScopedWorkflowSource{SourceRepoID: repoID},
&issues_model.IssuePin{RepoID: repoID},
); err != nil {
+28
View File
@@ -6,6 +6,9 @@ package repository_test
import (
"testing"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
"gitea.dev/models/organization"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
@@ -52,3 +55,28 @@ func TestDeleteOwnerRepositoriesDirectly(t *testing.T) {
assert.NoError(t, repo_service.DeleteOwnerRepositoriesDirectly(t.Context(), user))
}
func TestDeleteRepositoryDirectlyPurgesRepoScopedRows(t *testing.T) {
unittest.PrepareTestEnv(t)
// One row per table that repository deletion used to leave behind (#38494).
assert.NoError(t, db.Insert(t.Context(),
&actions_model.ActionVariable{RepoID: 1, Name: "to_purge", Data: "value"},
&actions_model.ActionRunAttempt{RepoID: 1, RunID: unittest.NonexistentID, Attempt: 1},
&actions_model.ActionTasksVersion{RepoID: 1, Version: 1},
&git_model.RenamedBranch{RepoID: 1, From: "old-name", To: "new-name"},
&git_model.CommitStatusSummary{RepoID: 1, SHA: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", State: "success"},
&repo_model.RepoTransfer{RepoID: 1, DoerID: 2, RecipientID: 3},
))
unittest.AssertExistsAndLoadBean(t, &git_model.CommitStatusIndex{RepoID: 1})
assert.NoError(t, repo_service.DeleteRepositoryDirectly(t.Context(), 1))
unittest.AssertNotExistsBean(t, &actions_model.ActionVariable{RepoID: 1})
unittest.AssertNotExistsBean(t, &actions_model.ActionRunAttempt{RepoID: 1})
unittest.AssertNotExistsBean(t, &actions_model.ActionTasksVersion{RepoID: 1})
unittest.AssertNotExistsBean(t, &git_model.RenamedBranch{RepoID: 1})
unittest.AssertNotExistsBean(t, &git_model.CommitStatusSummary{RepoID: 1})
unittest.AssertNotExistsBean(t, &git_model.CommitStatusIndex{RepoID: 1})
unittest.AssertNotExistsBean(t, &repo_model.RepoTransfer{RepoID: 1})
}
+10 -89
View File
@@ -4,7 +4,6 @@
package files
import (
"context"
"errors"
"fmt"
"strings"
@@ -13,7 +12,7 @@ import (
user_model "gitea.dev/models/user"
"gitea.dev/modules/git"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/log"
"gitea.dev/modules/reqctx"
"gitea.dev/modules/structs"
"gitea.dev/services/pull"
)
@@ -35,57 +34,23 @@ func (err ErrCommitIDDoesNotMatch) Error() string {
}
// CherryPick cherry-picks or reverts a commit to the given repository
func CherryPick(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, revert bool, opts *ApplyDiffPatchOptions) (*structs.FileResponse, error) {
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo)
func CherryPick(ctx reqctx.RequestContext, repo *repo_model.Repository, doer *user_model.User, revert bool, opts *ApplyDiffPatchOptions) (*structs.FileResponse, error) {
gitRepo, err := gitrepo.RepositoryFromRequestContextOrOpen(ctx, repo)
if err != nil {
return nil, err
}
defer closer.Close()
if err := opts.Validate(ctx, repo, gitRepo, doer); err != nil {
return nil, err
}
message := strings.TrimSpace(opts.Message)
t, err := NewTemporaryUploadRepository(repo)
t, err := gitPatchPrepare(ctx, repo, gitRepo, doer, opts)
if err != nil {
log.Error("NewTemporaryUploadRepository failed: %v", err)
return nil, err
}
defer t.Close()
if err := t.Clone(ctx, opts.OldBranch, false); err != nil {
return nil, err
}
if err := t.SetDefaultIndex(ctx); err != nil {
return nil, err
}
if err := t.RefreshIndex(ctx); err != nil {
return nil, err
}
// Get the commit of the original branch
commit, err := t.GetBranchCommit(opts.OldBranch)
err = t.RefreshIndex(ctx)
if err != nil {
return nil, err // Couldn't get a commit for the branch
return nil, err
}
// Assigned LastCommitID in opts if it hasn't been set
if opts.LastCommitID == "" {
opts.LastCommitID = commit.ID.String()
} else {
lastCommitID, err := t.gitRepo.ConvertToGitID(opts.LastCommitID)
if err != nil {
return nil, fmt.Errorf("CherryPick: Invalid last commit ID: %w", err)
}
opts.LastCommitID = lastCommitID.String()
if commit.ID.String() != opts.LastCommitID {
return nil, ErrCommitIDDoesNotMatch{
GivenCommitID: opts.LastCommitID,
CurrentCommitID: opts.LastCommitID,
}
}
}
commit, err = t.GetCommit(strings.TrimSpace(opts.Content))
commit, err := t.GetCommit(strings.TrimSpace(opts.Content))
if err != nil {
return nil, err
}
@@ -101,8 +66,7 @@ func CherryPick(ctx context.Context, repo *repo_model.Repository, doer *user_mod
}
description := fmt.Sprintf("CherryPick %s onto %s", right, opts.OldBranch)
conflict, _, err := pull.AttemptThreeWayMerge(ctx,
t.basePath, t.gitRepo, base, opts.LastCommitID, right, description)
conflict, _, err := pull.AttemptThreeWayMerge(ctx, t.basePath, t.gitRepo, base, opts.LastCommitID, right, description)
if err != nil {
return nil, fmt.Errorf("failed to three-way merge %s onto %s: %w", right, opts.OldBranch, err)
}
@@ -111,48 +75,5 @@ func CherryPick(ctx context.Context, repo *repo_model.Repository, doer *user_mod
return nil, errors.New("failed to merge due to conflicts")
}
treeHash, err := t.WriteTree(ctx)
if err != nil {
// likely non-sensical tree due to merge conflicts...
return nil, err
}
// Now commit the tree
commitOpts := &CommitTreeUserOptions{
ParentCommitID: "HEAD",
TreeHash: treeHash,
CommitMessage: message,
SignOff: opts.Signoff,
DoerUser: doer,
AuthorIdentity: opts.Author,
AuthorTime: nil,
CommitterIdentity: opts.Committer,
CommitterTime: nil,
}
if opts.Dates != nil {
commitOpts.AuthorTime, commitOpts.CommitterTime = &opts.Dates.Author, &opts.Dates.Committer
}
commitHash, err := t.CommitTree(ctx, commitOpts)
if err != nil {
return nil, err
}
// Then push this tree to NewBranch
if err := t.Push(ctx, doer, commitHash, opts.NewBranch, false); err != nil {
return nil, err
}
commit, err = t.GetCommit(commitHash)
if err != nil {
return nil, err
}
fileCommitResponse, _ := GetFileCommitResponse(repo, commit) // ok if fails, then will be nil
verification := GetPayloadCommitVerification(ctx, commit)
fileResponse := &structs.FileResponse{
Commit: fileCommitResponse,
Verification: verification,
}
return fileResponse, nil
return gitPatchCommitPush(ctx, t, repo, doer, opts)
}
+31 -19
View File
@@ -14,7 +14,7 @@ import (
"gitea.dev/modules/git"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/log"
"gitea.dev/modules/reqctx"
"gitea.dev/modules/structs"
"gitea.dev/modules/util"
asymkey_service "gitea.dev/services/asymkey"
@@ -110,31 +110,27 @@ func (opts *ApplyDiffPatchOptions) Validate(ctx context.Context, repo *repo_mode
return nil
}
// ApplyDiffPatch applies a patch to the given repository
func ApplyDiffPatch(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, opts *ApplyDiffPatchOptions) (*structs.FileResponse, error) {
func gitPatchPrepare(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, doer *user_model.User, opts *ApplyDiffPatchOptions) (_ *TemporaryUploadRepository, retErr error) {
err := repo.MustNotBeArchived()
if err != nil {
return nil, err
}
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo)
if err != nil {
return nil, err
}
defer closer.Close()
if err := opts.Validate(ctx, repo, gitRepo, doer); err != nil {
return nil, err
}
message := strings.TrimSpace(opts.Message)
t, err := NewTemporaryUploadRepository(repo)
if err != nil {
log.Error("NewTemporaryUploadRepository failed: %v", err)
return nil, fmt.Errorf("NewTemporaryUploadRepository failed: %w", err)
}
defer t.Close()
if err := t.Clone(ctx, opts.OldBranch, true); err != nil {
defer func() {
if retErr != nil {
t.Close()
}
}()
// here must NOT use bare repo, because the following git commands might operate working tree ("--index") directly
if err := t.Clone(ctx, opts.OldBranch, false); err != nil {
return nil, err
}
if err := t.SetDefaultIndex(ctx); err != nil {
@@ -153,7 +149,7 @@ func ApplyDiffPatch(ctx context.Context, repo *repo_model.Repository, doer *user
} else {
lastCommitID, err := t.gitRepo.ConvertToGitID(opts.LastCommitID)
if err != nil {
return nil, fmt.Errorf("ApplyPatch: Invalid last commit ID: %w", err)
return nil, fmt.Errorf("invalid last commit ID: %w", err)
}
opts.LastCommitID = lastCommitID.String()
if commit.ID.String() != opts.LastCommitID {
@@ -163,6 +159,20 @@ func ApplyDiffPatch(ctx context.Context, repo *repo_model.Repository, doer *user
}
}
}
return t, nil
}
// ApplyDiffPatch applies a patch to the given repository
func ApplyDiffPatch(ctx reqctx.RequestContext, repo *repo_model.Repository, doer *user_model.User, opts *ApplyDiffPatchOptions) (*structs.FileResponse, error) {
gitRepo, err := gitrepo.RepositoryFromRequestContextOrOpen(ctx, repo)
if err != nil {
return nil, err
}
t, err := gitPatchPrepare(ctx, repo, gitRepo, doer, opts)
if err != nil {
return nil, err
}
defer t.Close()
cmdApply := gitcmd.NewCommand("apply", "--index", "--recount", "--cached", "--ignore-whitespace", "--whitespace=fix", "--binary")
if git.DefaultFeatures().CheckVersionAtLeast("2.32") {
@@ -175,17 +185,19 @@ func ApplyDiffPatch(ctx context.Context, repo *repo_model.Repository, doer *user
return nil, fmt.Errorf("git apply error: %w", err)
}
// Now write the tree
return gitPatchCommitPush(ctx, t, repo, doer, opts)
}
func gitPatchCommitPush(ctx context.Context, t *TemporaryUploadRepository, repo *repo_model.Repository, doer *user_model.User, opts *ApplyDiffPatchOptions) (*structs.FileResponse, error) {
treeHash, err := t.WriteTree(ctx)
if err != nil {
return nil, err
}
// Now commit the tree
commitOpts := &CommitTreeUserOptions{
ParentCommitID: "HEAD",
TreeHash: treeHash,
CommitMessage: message,
CommitMessage: strings.TrimSpace(opts.Message),
SignOff: opts.Signoff,
DoerUser: doer,
AuthorIdentity: opts.Author,
@@ -206,7 +218,7 @@ func ApplyDiffPatch(ctx context.Context, repo *repo_model.Repository, doer *user
return nil, err
}
commit, err = t.GetCommit(commitHash)
commit, err := t.GetCommit(commitHash)
if err != nil {
return nil, err
}
+36
View File
@@ -0,0 +1,36 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package files
import (
"os"
"path/filepath"
"testing"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/gitrepo"
"github.com/stretchr/testify/require"
)
func TestGitPatchPrepare(t *testing.T) {
unittest.PrepareTestEnv(t)
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
gitRepo, err := gitrepo.OpenRepository(t.Context(), repo1)
require.NoError(t, err)
defer gitRepo.Close()
opts := &ApplyDiffPatchOptions{}
tmpRepo, err := gitPatchPrepare(t.Context(), repo1, gitRepo, user2, opts)
require.NoError(t, err)
defer tmpRepo.Close()
// temporary repository for patch should not be a bare repo because "--index" argument is used,
// while it is questionable whether "--index" argument should be really used, need to figure out in the future.
_, err = os.Stat(filepath.Join(tmpRepo.basePath, ".git"))
require.NoError(t, err)
}
+1 -1
View File
@@ -175,7 +175,7 @@ func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use
t, err := NewTemporaryUploadRepository(repo)
if err != nil {
log.Error("NewTemporaryUploadRepository failed: %v", err)
return nil, fmt.Errorf("NewTemporaryUploadRepository failed: %w", err)
}
defer t.Close()
hasOldBranch := true
+5 -8
View File
@@ -7,7 +7,6 @@ import (
"context"
"fmt"
"net/http"
"regexp"
"strings"
webhook_model "gitea.dev/models/webhook"
@@ -317,12 +316,10 @@ func init() {
RegisterWebhookRequester(webhook_module.SLACK, newSlackRequest)
}
var slackChannel = regexp.MustCompile(`^#?[a-z0-9_-]{1,80}$`)
// IsValidSlackChannel validates a channel name conforms to what slack expects:
// https://api.slack.com/methods/conversations.rename#naming
// Conversation names can only contain lowercase letters, numbers, hyphens, and underscores, and must be 80 characters or less.
// Gitea accepts if it starts with a #.
func IsValidSlackChannel(name string) bool {
return slackChannel.MatchString(name)
// Some documents: https://api.slack.com/methods/conversations.rename#naming
// 1. Internal channel name should "only contain lowercase letters, numbers, hyphens, and underscores, and must be 80 characters or less"
// 2. Slack would also "modify it to meet the above criteria"
// Since we know nothing about the details, don't do any validation here.
return name != ""
}
-19
View File
@@ -191,22 +191,3 @@ func TestSlackJSONPayload(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, "[<http://localhost:3000/test/repo|test/repo>:<http://localhost:3000/test/repo/src/branch/test|test>] 2 new commits pushed by user1", body.Text)
}
func TestIsValidSlackChannel(t *testing.T) {
tt := []struct {
channelName string
expected bool
}{
{"gitea", true},
{"#gitea", true},
{" ", false},
{"#", false},
{" #", false},
{"gitea ", false},
{" gitea", false},
}
for _, v := range tt {
assert.Equal(t, v.expected, IsValidSlackChannel(v.channelName))
}
}
+2 -2
View File
@@ -6,8 +6,8 @@
{{if or .Addition .Deletion}}
<div class="flex-text-block tw-flex-shrink-0 tw-text-[13px] {{if .Classes}}{{.Classes}}{{end}}">
<span>
{{if .Addition}}<span class="tw-text-diff-added-fg">+{{.Addition}}</span>{{end}}
{{if .Deletion}}<span class="tw-text-diff-removed-fg">-{{.Deletion}}</span>{{end}}
{{if .Addition}}<strong class="tw-text-diff-added-fg">+{{.Addition}}</strong>{{end}}
{{if .Deletion}}<strong class="tw-text-diff-removed-fg">-{{.Deletion}}</strong>{{end}}
</span>
<span class="diff-stats-bar" data-tooltip-content="{{ctx.Locale.Tr "repo.diff.stats_desc_file" (Eval .Addition "+" .Deletion) .Addition .Deletion}}">
{{/* if the denominator is zero, then the float result is "width: NaNpx", as before, it just works */}}
+2 -5
View File
@@ -44,7 +44,7 @@
</span>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu">
<div class="item issue-action" data-element-id="0" data-url="{{$.Link}}/milestone">
<div class="item issue-action" data-element-id="" data-url="{{$.Link}}/milestone">
{{ctx.Locale.Tr "repo.issues.action_milestone_no_select"}}
</div>
{{if .OpenMilestones}}
@@ -75,7 +75,7 @@
</span>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu">
<div class="item issue-action" data-element-id="0" data-url="{{$.Link}}/projects">
<div class="item issue-action" data-element-id="" data-url="{{$.Link}}/projects">
{{ctx.Locale.Tr "repo.issues.new.clear_projects"}}
</div>
{{if .OpenProjects}}
@@ -113,9 +113,6 @@
<div class="item issue-action" data-action="clear" data-url="{{$.Link}}/assignee">
{{ctx.Locale.Tr "repo.issues.new.clear_assignees"}}
</div>
<div class="item issue-action" data-element-id="0" data-url="{{$.Link}}/assignee">
{{ctx.Locale.Tr "repo.issues.action_assignee_no_select"}}
</div>
{{range .Assignees}}
<div class="item issue-action" data-element-id="{{.ID}}" data-url="{{$.RepoLink}}/issues/assignee">
{{ctx.AvatarUtils.Avatar . 20}} {{.GetDisplayName}}
+1 -1
View File
@@ -1 +1 @@
<span class="username-display">{{.Name}} {{if .FullName}}<span class="username-fullname gt-ellipsis">({{.FullName}})</span>{{end}}</span>
<span class="username-display">{{.Name}} {{if .FullName}}<span class="username-fullname">({{.FullName}})</span>{{end}}</span>
+2 -2
View File
@@ -65,7 +65,7 @@
<table class="ui very basic table unstackable">
<thead>
<tr>
<th>{{ctx.Locale.Tr "actions.runners.task_list.run"}}</th>
<th>{{ctx.Locale.Tr "actions.runners.task_list.job"}}</th>
<th>{{ctx.Locale.Tr "actions.runners.task_list.status"}}</th>
<th>{{ctx.Locale.Tr "actions.runners.task_list.repository"}}</th>
<th>{{ctx.Locale.Tr "actions.runners.task_list.commit"}}</th>
@@ -75,7 +75,7 @@
<tbody>
{{range .Tasks}}
<tr>
<td><a href="{{.GetRunLink}}" target="_blank">{{.ID}}</a></td>
<td>{{if .Job}}<a href="{{.GetRunJobLink}}" target="_blank">{{.Job.ID}}</a>{{else}}{{.ID}}{{end}}</td>
<td><span class="ui label task-status-{{.Status.String}}">{{.Status.LocaleString ctx.Locale}}</span></td>
<td><a href="{{.GetRepoLink}}" target="_blank">{{.GetRepoName}}</a></td>
<td>
+7 -7
View File
@@ -62,6 +62,10 @@
{{if .AllowBulkActions}}
<th class="tw-w-8"><div class="ui checkbox tw-flex"><input type="checkbox" class="runner-bulk-select-all" aria-label="{{ctx.Locale.Tr "admin.notices.select_all"}}"></div></th>
{{end}}
<th data-sortt-asc="alphabetically" data-sortt-desc="reversealphabetically">
{{ctx.Locale.Tr "actions.runners.name"}}
{{SortArrow "alphabetically" "reversealphabetically" .SortType false}}
</th>
<th data-sortt-asc="online" data-sortt-desc="offline">
{{ctx.Locale.Tr "actions.runners.status"}}
{{SortArrow "online" "offline" .SortType false}}
@@ -70,10 +74,6 @@
{{ctx.Locale.Tr "actions.runners.id"}}
{{SortArrow "oldest" "newest" .SortType false}}
</th>
<th data-sortt-asc="alphabetically" data-sortt-desc="reversealphabetically">
{{ctx.Locale.Tr "actions.runners.name"}}
{{SortArrow "alphabetically" "reversealphabetically" .SortType false}}
</th>
<th>{{ctx.Locale.Tr "actions.runners.version"}}</th>
<th>{{ctx.Locale.Tr "actions.runners.owner_type"}}</th>
<th>{{ctx.Locale.Tr "actions.runners.labels"}}</th>
@@ -87,12 +87,12 @@
{{if $.AllowBulkActions}}
<td><div class="ui checkbox tw-flex"><input type="checkbox" class="runner-bulk-select" data-runner-id="{{.ID}}" aria-label="{{ctx.Locale.Tr "repo.issues.action_check"}}: {{.Name}}"></div></td>
{{end}}
<td><p data-tooltip-content="{{.Description}}">{{.Name}}</p></td>
<td>
<span class="ui label {{if .IsOnline}}green{{end}}">{{.StatusLocaleName ctx.Locale}}</span>
{{if .IsDisabled}}<span class="ui grey label">{{ctx.Locale.Tr "actions.runners.disabled"}}</span>{{end}}
<span class="ui label {{if eq .StatusName "active"}}green{{else if eq .StatusName "idle"}}yellow{{else if eq .StatusName "offline"}}red{{end}}">{{.StatusLocaleName ctx.Locale}}</span>
{{if .IsDisabled}}<span class="ui grey label">{{ctx.Locale.Tr "disabled"}}</span>{{end}}
</td>
<td>{{.ID}}</td>
<td><p data-tooltip-content="{{.Description}}">{{.Name}}</p></td>
<td>{{if .Version}}{{.Version}}{{else}}{{ctx.Locale.Tr "unknown"}}{{end}}</td>
<td><span data-tooltip-content="{{.BelongsToOwnerName}}">{{.BelongsToOwnerType.LocaleString ctx.Locale}}</span></td>
<td>
+16 -8
View File
@@ -30294,6 +30294,20 @@
},
"x-go-package": "gitea.dev/modules/structs"
},
"TopicListResponse": {
"description": "TopicListResponse returns a list of TopicResponse",
"type": "object",
"properties": {
"topics": {
"type": "array",
"items": {
"$ref": "#/definitions/TopicResponse"
},
"x-go-name": "Topics"
}
},
"x-go-package": "gitea.dev/modules/structs"
},
"TopicName": {
"description": "TopicName a list of repo topic names",
"type": "object",
@@ -31920,10 +31934,7 @@
"TopicListResponse": {
"description": "TopicListResponse",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/TopicResponse"
}
"$ref": "#/definitions/TopicListResponse"
}
},
"TopicNames": {
@@ -31974,10 +31985,7 @@
"UserSettings": {
"description": "UserSettings",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/UserSettings"
}
"$ref": "#/definitions/UserSettings"
}
},
"VariableList": {
+16 -8
View File
@@ -1452,10 +1452,7 @@
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/TopicResponse"
},
"type": "array"
"$ref": "#/components/schemas/TopicListResponse"
}
}
},
@@ -1534,10 +1531,7 @@
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/UserSettings"
},
"type": "array"
"$ref": "#/components/schemas/UserSettings"
}
}
},
@@ -10110,6 +10104,20 @@
"type": "object",
"x-go-package": "gitea.dev/modules/structs"
},
"TopicListResponse": {
"description": "TopicListResponse returns a list of TopicResponse",
"properties": {
"topics": {
"items": {
"$ref": "#/components/schemas/TopicResponse"
},
"type": "array",
"x-go-name": "Topics"
}
},
"type": "object",
"x-go-package": "gitea.dev/modules/structs"
},
"TopicName": {
"description": "TopicName a list of repo topic names",
"properties": {
+22
View File
@@ -143,6 +143,28 @@ jobs:
job2LatestAttempt := getLatestAttemptJobByTemplateJobID(t, run.ID, job2.ID)
assert.Equal(t, runLatestAttempt.LatestAttemptID, job2LatestAttempt.RunAttemptID)
t.Run("RerunFailedWithNoFailedJobs", func(t *testing.T) {
// The run is fully successful, so an empty failed-job list must be rejected rather than fall
// through to re-running the whole run.
before := getRunLatestAttemptNum(t, run.ID)
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun-failed", user2.Name, repo.Name, run.ID))
resp := session.MakeRequest(t, req, http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "no failed jobs")
assert.Equal(t, before, getRunLatestAttemptNum(t, run.ID))
runner.fetchNoTask(t) // no new tasks were scheduled
})
t.Run("RerunFailedWithNoFailedJobsAPI", func(t *testing.T) {
// Same rejection on the API route: an empty failed-job list must not fall through to a full re-run.
before := getRunLatestAttemptNum(t, run.ID)
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/rerun-failed-jobs", user2.Name, repo.Name, run.ID)).
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "no failed jobs")
assert.Equal(t, before, getRunLatestAttemptNum(t, run.ID))
runner.fetchNoTask(t)
})
t.Run("AttemptAPI", func(t *testing.T) {
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/attempts/2", user2.Name, repo.Name, run.ID)).
AddTokenAuth(token)
@@ -574,6 +574,49 @@ jobs:
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
})
t.Run("Nested caller with missing callee fails instead of blocking", func(t *testing.T) {
// When the expansion hits a terminal error (e.g. missing callee), the emitter must fail the caller and let the run finish as failed, not retry the expansion forever.
apiRepo := createActionsTestRepo(t, user2Token, "nested-caller-missing-callee", false)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
runner := newMockRunner()
runner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-runner", []string{"ubuntu-latest"}, false)
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/caller.yaml",
`name: Caller
on: push
jobs:
plain_job:
runs-on: ubuntu-latest
steps:
- run: echo 'job'
bad_caller:
needs: plain_job
uses: ./.gitea/workflows/does-not-exist.yml
`)
// plain_job runs first; bad_caller is Blocked on needs and is NOT expanded at creation.
plainTask := runner.fetchTask(t)
_, plainJob, run := getTaskAndJobAndRunByTaskID(t, plainTask.Id)
assert.Equal(t, "plain_job", plainJob.JobID)
badCallerPre := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "bad_caller"})
assert.Equal(t, actions_model.StatusBlocked, badCallerPre.Status)
assert.False(t, badCallerPre.IsExpanded)
runner.execTask(t, plainTask, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
// The emitter now tries to expand bad_caller, hits the missing callee, and fails the caller.
badCaller := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: badCallerPre.ID})
assert.Equal(t, actions_model.StatusFailure, badCaller.Status)
// No children were inserted (the terminal error precedes the child inserts).
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRunJob{ParentJobID: badCallerPre.ID}))
finalRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
assert.Equal(t, actions_model.StatusFailure, finalRun.Status)
runner.fetchNoTask(t) // no task scheduled for the failed caller; the run is not stuck
})
t.Run("Fork PR with secrets: inherit does not leak base repo secrets", func(t *testing.T) {
// user2 owns the base repo, configures a secret, and registers a reusable workflow that declares a required secret.
// The caller workflow uses `secrets: inherit`.
@@ -0,0 +1,130 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"testing"
"time"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/modules/timeutil"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestFindRunnersSortByStatus verifies that sorting by status ranks runners by
// their computed status (active, then idle, then offline) instead of interleaving
// idle runners with active ones by raw last_online, and that a disabled runner
// sinks to the bottom of its status group rather than mixing with enabled runners.
//
// It lives in tests/integration rather than a unit test because the status rank is
// a database-evaluated CASE expression; unit tests only run against SQLite, so the
// expression must be exercised against MySQL/PostgreSQL/MSSQL in CI to catch dialect
// differences.
func TestFindRunnersSortByStatus(t *testing.T) {
defer tests.PrepareTestEnv(t)()
ctx := t.Context()
const ownerID = 1001
now := time.Now()
// An idle runner that went online most recently would sort before an active
// runner when ordering by last_online alone; the status rank must override that.
insert := func(name string, lastOnline, lastActive time.Time, disabled bool) {
require.NoError(t, db.Insert(ctx, &actions_model.ActionRunner{
Name: name,
UUID: "STATUS-SORT-" + name,
TokenHash: "status-sort-token-" + name,
OwnerID: ownerID,
LastOnline: timeutil.TimeStamp(lastOnline.Unix()),
LastActive: timeutil.TimeStamp(lastActive.Unix()),
IsDisabled: disabled,
}))
}
// Each disabled runner has the most recent last_online within its status group,
// so it would sort first in that group if the disabled flag were ignored; the
// last_active value keeps it in the intended group (offline<idle<active).
insert("active", now.Add(-8*time.Second), now.Add(-5*time.Second), false)
insert("active-disabled", now, now.Add(-2*time.Second), true)
insert("idle", now.Add(-30*time.Second), now.Add(-20*time.Second), false)
insert("idle-disabled", now, now.Add(-20*time.Second), true)
insert("offline", now.Add(-3*time.Minute), now.Add(-3*time.Minute), false)
insert("offline-disabled", now.Add(-90*time.Second), now.Add(-90*time.Second), true)
names := func(runners []*actions_model.ActionRunner) []string {
out := make([]string, len(runners))
for i, r := range runners {
out[i] = r.Name
}
return out
}
// Active group first, then idle, then offline; within each group enabled before disabled.
runners, err := db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{OwnerID: ownerID, Sort: "online"})
require.NoError(t, err)
assert.Equal(t, []string{
"active", "active-disabled",
"idle", "idle-disabled",
"offline", "offline-disabled",
}, names(runners))
// The descending status sort reverses the group order but still keeps disabled
// runners at the bottom of their group.
runners, err = db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{OwnerID: ownerID, Sort: "offline"})
require.NoError(t, err)
assert.Equal(t, []string{
"offline", "offline-disabled",
"idle", "idle-disabled",
"active", "active-disabled",
}, names(runners))
}
// TestFindRunnersPaginationNoDuplicates verifies that the unique id tiebreaker in
// FindRunnerOptions.ToOrders keeps pagination deterministic when the primary sort
// key (last_online) is tied for every runner. It is an integration test so the
// ordering is validated against every supported database, not only SQLite.
func TestFindRunnersPaginationNoDuplicates(t *testing.T) {
defer tests.PrepareTestEnv(t)()
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 {
require.NoError(t, db.Insert(ctx, &actions_model.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,
}))
}
// 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[actions_model.ActionRunner](ctx, actions_model.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)
}
}
+5 -1
View File
@@ -441,7 +441,6 @@ select.ui.dropdown {
}
.ui.multiple.dropdown > .label {
display: inline-block;
white-space: normal;
font-size: 1em;
padding: 0.35714286em 0.78571429em;
@@ -449,6 +448,11 @@ select.ui.dropdown {
box-shadow: 0 0 0 1px var(--color-secondary) inset;
}
.ui.multiple.dropdown > .label img {
width: auto;
max-height: 20px;
}
/* Text */
.ui.multiple.dropdown > .text {
position: static;
-6
View File
@@ -33,12 +33,6 @@ a.ui.label {
opacity: 1;
}
.ui.label > img {
width: auto;
vertical-align: middle;
height: 2.1666em;
}
.ui.label > .icon {
width: auto;
margin: 0 0.75em 0 0;
-5
View File
@@ -161,11 +161,6 @@
.ui.fixed.table {
table-layout: fixed;
}
.ui.fixed.table th,
.ui.fixed.table td {
overflow: hidden;
text-overflow: ellipsis;
}
.ui.selectable.table > tbody > tr:hover,
.ui.table tbody tr td.selectable:hover {
+8
View File
@@ -645,6 +645,10 @@ td .commit-summary {
width: 100%;
}
.repository.view.issue .ui.depending .item.dependency {
display: flex;
}
.repository.view.issue .ui.depending .item.is-closed .issue-dependency-title {
text-decoration: line-through;
}
@@ -1836,10 +1840,14 @@ tbody.commit-list {
align-items: center;
max-width: 100%; /* min/max widths are for "gt-ellipsis", see the comment of other "flex-xxx" family classes */
min-width: 0;
overflow: hidden;
}
.username-display > .username-fullname {
color: var(--color-text-light-2);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
#issue-pins {
+3 -3
View File
@@ -163,14 +163,14 @@ gitea-theme-meta-info {
--color-grey-light: #898d96;
--color-gold: #b1983b;
--color-white: #ffffff;
--color-diff-added-fg: #87ab63;
--color-diff-added-fg: #93b373;
--color-diff-added-linenum-bg: #274227;
--color-diff-added-row-bg: #203224;
--color-diff-added-row-border: #314a37;
--color-diff-added-word-bg: #3c653c;
--color-diff-moved-row-bg: #818044;
--color-diff-moved-row-border: #bcca6f;
--color-diff-removed-fg: #cc4848;
--color-diff-removed-fg: #fb5f5b;
--color-diff-removed-linenum-bg: #482121;
--color-diff-removed-row-bg: #301e1e;
--color-diff-removed-row-border: #634343;
@@ -270,7 +270,7 @@ gitea-theme-meta-info {
--color-syntax-keyword: #ff8854;
--color-syntax-bool: #25bbc9;
--color-syntax-control: #dd9e17;
--color-syntax-name: #c7a618;
--color-syntax-name: #fabd2f;
--color-syntax-type: #eb8cb3;
--color-syntax-number: #63b2dd;
--color-syntax-operator: #ff8854;
@@ -8,11 +8,11 @@ gitea-theme-meta-info {
/* red/green colorblind-friendly colors */
:root {
--color-diff-added-fg: #2185d0;
--color-diff-added-fg: #0860cc;
--color-diff-added-linenum-bg: #b6e3ff;
--color-diff-added-row-bg: #ddf4ff;
--color-diff-added-word-bg: #b6e3ff;
--color-diff-removed-fg: #fc6500;
--color-diff-removed-fg: #a84400;
--color-diff-removed-linenum-bg: #ffd8b5;
--color-diff-removed-row-bg: #fff1e5;
--color-diff-removed-word-bg: #ffd8b5;
@@ -8,7 +8,7 @@ gitea-theme-meta-info {
/* blue/yellow colorblind-friendly colors */
:root {
--color-diff-added-fg: #2185d0;
--color-diff-added-fg: #0860cc;
--color-diff-added-linenum-bg: #b6e3ff;
--color-diff-added-row-bg: #ddf4ff;
--color-diff-added-word-bg: #b6e3ff;
+2 -2
View File
@@ -163,14 +163,14 @@ gitea-theme-meta-info {
--color-grey-light: #7c838a;
--color-gold: #a1882b;
--color-white: #ffffff;
--color-diff-added-fg: #21ba45;
--color-diff-added-fg: #177231;
--color-diff-added-linenum-bg: #d1f8d9;
--color-diff-added-row-bg: #e6ffed;
--color-diff-added-row-border: #e6ffed;
--color-diff-added-word-bg: #acf2bd;
--color-diff-moved-row-bg: #f1f8d1;
--color-diff-moved-row-border: #d0e27f;
--color-diff-removed-fg: #db2828;
--color-diff-removed-fg: #c61f2b;
--color-diff-removed-linenum-bg: #ffcecb;
--color-diff-removed-row-bg: #ffeef0;
--color-diff-removed-row-border: #f1c0c0;
+1 -1
View File
@@ -723,7 +723,7 @@ async function hashChangeListener() {
.job-step-logs .job-log-line .log-msg {
flex: 1;
white-space: break-spaces;
white-space: break-spaces; /* decoded commands like "::error::foo%0Abar" contain "\n" */
margin-left: 12px;
overflow-wrap: anywhere;
}
@@ -14,6 +14,13 @@ test('LogLineMessage', () => {
'##[debug] foo': '<span class="log-msg log-cmd-debug"><span class="log-msg-label">Debug:</span><span> foo</span></span>',
'::error::foo': '<span class="log-msg log-cmd-error"><span class="log-msg-label">Error:</span><span> foo</span></span>',
'::warning file=test.js,line=1::foo': '<span class="log-msg log-cmd-warning"><span class="log-msg-label">Warning:</span><span> foo</span></span>',
'::error::foo%0Abar': '<span class="log-msg log-cmd-error"><span class="log-msg-label">Error:</span><span> foo\nbar</span></span>',
'::error::foo%0D%0Abar': '<span class="log-msg log-cmd-error"><span class="log-msg-label">Error:</span><span> foo\nbar</span></span>',
'::error::100%25 done%250A': '<span class="log-msg log-cmd-error"><span class="log-msg-label">Error:</span><span> 100% done%0A</span></span>',
'::error::keep%5Dsemi%3B': '<span class="log-msg log-cmd-error"><span class="log-msg-label">Error:</span><span> keep%5Dsemi%3B</span></span>',
'::group::foo%0Abar': '<span class="log-msg log-cmd-group">foo\nbar</span>',
'##[error]foo%0Abar%3B%5D': '<span class="log-msg log-cmd-error"><span class="log-msg-label">Error:</span><span> foo\nbar;]</span></span>',
'##[command]foo%0Abar': '<span class="log-msg log-cmd-command">foo%0Abar</span>',
'::notice::foo': '<span class="log-msg log-cmd-notice"><span class="log-msg-label">Notice:</span><span> foo</span></span>',
'::debug::foo': '<span class="log-msg log-cmd-debug"><span class="log-msg-label">Debug:</span><span> foo</span></span>',
'##[command] foo': '<span class="log-msg log-cmd-command"> foo</span>',
+15 -5
View File
@@ -9,7 +9,8 @@ import {POST} from '../modules/fetch.ts';
// * Workflow command outputs log commands like "::group::the-title", "::add-matcher::...."
// * Workflow runner parses and processes the commands to "##[group]", apply "matchers", hide secrets, etc.
// * The reported logs are the processed logs.
// HOWEVER: Gitea runner does not completely process those commands. Many works are done by the frontend at the moment.
// HOWEVER: Gitea cannot, a decoded message may contain newlines and FormatLog drops them,
// so the commands arrive here still escaped and the frontend decodes them.
const LogLinePrefixCommandMap: Record<string, LogLineCommandName> = {
'::group::': 'group',
'##[group]': 'group',
@@ -67,14 +68,23 @@ const LogLineLabelMap: Partial<Record<LogLineCommandName, string>> = {
'debug': 'Debug',
};
function decodeLineMessage(line: LogLine, cmd: LogLineCommand | null): string {
// TODO: for some commands (::group::), the "prefix removal" works well, for some commands with "arguments" (::remove-matcher ...::),
// it needs to do further processing in the future (fortunately, at the moment we don't need to handle these commands)
if (!cmd) return line.message;
let msg = line.message.substring(cmd.prefix.length);
if (cmd.name === 'command') return msg; // "command" is only an output tag, do not parse or escape it
// "##[cmd]" also escapes ";" and "]" which delimit its header, "::cmd::" does not
if (!cmd.prefix.startsWith('::')) msg = msg.replace(/%3B/g, ';').replace(/%5D/g, ']');
// renderAnsiInto breaks a line per "\r", so "%0D%0A" is one break. "%25" last keeps "%250A" literal
return msg.replace(/(?:%0D)?%0A/g, '\n').replace(/%0D/g, '\r').replace(/%25/g, '%');
}
export function createLogLineMessage(line: LogLine, cmd: LogLineCommand | null) {
const logMsgAttrs = {class: 'log-msg'};
if (cmd?.name) logMsgAttrs.class += ` log-cmd-${cmd.name}`; // make it easier to add styles to some commands like "error"
// TODO: for some commands (::group::), the "prefix removal" works well, for some commands with "arguments" (::remove-matcher ...::),
// it needs to do further processing in the future (fortunately, at the moment we don't need to handle these commands)
const msgContent = cmd ? line.message.substring(cmd.prefix.length) : line.message;
const msgContent = decodeLineMessage(line, cmd);
const logMsg = createElementFromAttrs('span', logMsgAttrs);
const label = cmd ? LogLineLabelMap[cmd.name] : null;
if (label) {
@@ -32,6 +32,8 @@ const onItemClick = (e: MouseEvent) => {
// - the user didn't press any special key like "Ctrl+Click" (which may have custom browser behavior)
// - the editor/commit form isn't dirty (a full page reload shows a confirmation dialog if the form contains unsaved changes)
if (!isPlainClick(e) || shouldTriggerAreYouSure()) return;
// submodules (commit entry mode) point to external repos, let the browser handle navigation normally
if (props.item.entryMode === 'commit') return;
e.preventDefault();
if (props.item.entryMode === 'tree') doLoadChildren();
store.navigateTreeView(props.item.fullPath);
+3 -9
View File
@@ -57,18 +57,12 @@ function initRepoIssueListCheckboxes() {
const url = el.getAttribute('data-url')!;
let action = el.getAttribute('data-action')!;
let elementId = el.getAttribute('data-element-id')!;
const elementId = el.getAttribute('data-element-id')!;
const issueIDList: string[] = Array.from(document.querySelectorAll('.issue-checkbox:checked'), (el) => (el.getAttribute('data-issue-id')!));
const issueIDs = issueIDList.join(',');
if (!issueIDs) return;
// for assignee
if (elementId === '0' && url.endsWith('/assignee')) {
elementId = '';
action = 'clear';
}
// for toggle
// for label toggle
if (action === 'toggle' && e.altKey) {
action = 'toggle-alt';
}
@@ -139,7 +133,7 @@ function initDropdownUserRemoteSearch(el: Element) {
processedResults.length = 0;
for (const item of resp.results) {
const htmlAvatar = html`<img class="ui avatar tw-align-middle" src="${item.avatar_link}" aria-hidden="true" alt width="20" height="20">`;
const htmlFullName = item.full_name ? html`<span class="username-fullname gt-ellipsis">(${item.full_name})</span>` : '';
const htmlFullName = item.full_name ? html`<span class="username-fullname">(${item.full_name})</span>` : '';
const htmlItem = html`<span class="username-display">${htmlRaw(htmlAvatar)}<span>${item.username}</span>${htmlRaw(htmlFullName)}</span>`;
if (selectedUsername.toLowerCase() === item.username.toLowerCase()) selectedUsername = item.username;
processedResults.push({value: item.username, name: htmlItem});