diff --git a/.mokogitea/workflows/ci-issue-reporter.yml b/.mokogitea/workflows/ci-issue-reporter.yml new file mode 100644 index 0000000000..7ad19c8a7a --- /dev/null +++ b/.mokogitea/workflows/ci-issue-reporter.yml @@ -0,0 +1,68 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: mokocli.Universal +# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli +# PATH: /.mokogitea/workflows/ci-issue-reporter.yml +# VERSION: 01.00.00 +# BRIEF: Reusable workflow — creates/updates a Gitea issue when a CI gate fails. +# Clones MokoCLI and runs cli/ci_issue_reporter.sh. + +name: "Universal: CI Issue Reporter" + +on: + workflow_call: + inputs: + gate: + description: "CI gate name (e.g. PR Validation, Repository Health)" + required: true + type: string + details: + description: "Human-readable failure description" + required: true + type: string + severity: + description: "error or warning" + required: false + type: string + default: "error" + workflow: + description: "Workflow name for the issue title" + required: false + type: string + default: "" + secrets: + MOKOGITEA_TOKEN: + required: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + report: + name: "Report: ${{ inputs.gate }}" + runs-on: ubuntu-latest + + steps: + - name: Clone MokoCLI + env: + MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + run: | + MOKOGITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}" + git clone --depth 1 --filter=blob:none --sparse "${MOKOGITEA_URL}/MokoConsulting/MokoCLI.git" /tmp/mokocli + cd /tmp/mokocli && git sparse-checkout set cli/ci_issue_reporter.sh + + - name: Report CI failure + env: + MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + run: | + chmod +x /tmp/mokocli/cli/ci_issue_reporter.sh + /tmp/mokocli/cli/ci_issue_reporter.sh \ + --gate "${{ inputs.gate }}" \ + --details "${{ inputs.details }}" \ + --severity "${{ inputs.severity }}" \ + --workflow "${{ inputs.workflow }}" diff --git a/.mokogitea/workflows/cascade-dev.yml b/.mokogitea/workflows/custom/cascade-dev.yml similarity index 100% rename from .mokogitea/workflows/cascade-dev.yml rename to .mokogitea/workflows/custom/cascade-dev.yml diff --git a/.mokogitea/workflows/deploy-dev.yml b/.mokogitea/workflows/custom/deploy-dev.yml similarity index 100% rename from .mokogitea/workflows/deploy-dev.yml rename to .mokogitea/workflows/custom/deploy-dev.yml diff --git a/.mokogitea/workflows/deploy-mokogitea.yml b/.mokogitea/workflows/custom/deploy-mokogitea.yml similarity index 97% rename from .mokogitea/workflows/deploy-mokogitea.yml rename to .mokogitea/workflows/custom/deploy-mokogitea.yml index 2a69322862..43a36f598d 100644 --- a/.mokogitea/workflows/deploy-mokogitea.yml +++ b/.mokogitea/workflows/custom/deploy-mokogitea.yml @@ -36,11 +36,10 @@ env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: - check-dev: - name: "Verify dev environment is healthy" + deploy: runs-on: ubuntu-latest steps: - - name: Check dev health + - name: Verify dev environment is healthy run: | echo "Checking git.dev.mokoconsulting.tech health..." if curl -sf --max-time 10 https://git.dev.mokoconsulting.tech/api/healthz; then @@ -51,10 +50,6 @@ jobs: exit 1 fi - deploy: - needs: check-dev - runs-on: ubuntu-latest - steps: - name: Checkout source (for version detection) uses: actions/checkout@v4 with: diff --git a/.mokogitea/workflows/pr-rc-release.yml b/.mokogitea/workflows/custom/pr-rc-release.yml similarity index 100% rename from .mokogitea/workflows/pr-rc-release.yml rename to .mokogitea/workflows/custom/pr-rc-release.yml diff --git a/.mokogitea/workflows/test-mokogitea.yml b/.mokogitea/workflows/custom/test-mokogitea.yml similarity index 100% rename from .mokogitea/workflows/test-mokogitea.yml rename to .mokogitea/workflows/custom/test-mokogitea.yml diff --git a/.mokogitea/workflows/upstream-bug-sync.yml b/.mokogitea/workflows/custom/upstream-bug-sync.yml similarity index 100% rename from .mokogitea/workflows/upstream-bug-sync.yml rename to .mokogitea/workflows/custom/upstream-bug-sync.yml diff --git a/.mokogitea/workflows/pre-release.yml b/.mokogitea/workflows/pre-release.yml index 34d83ac654..efb3d1b4f6 100644 --- a/.mokogitea/workflows/pre-release.yml +++ b/.mokogitea/workflows/pre-release.yml @@ -7,7 +7,7 @@ # INGROUP: mokocli.Release # REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli # PATH: /templates/workflows/universal/pre-release.yml.template -# VERSION: 05.01.00 +# VERSION: 05.02.00 # BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches name: "Universal: Pre-Release" @@ -40,7 +40,7 @@ permissions: contents: write env: - MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }} GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }} @@ -59,6 +59,11 @@ jobs: fetch-depth: 0 token: ${{ secrets.MOKOGITEA_TOKEN }} ref: ${{ github.ref_name }} + submodules: recursive + + - name: Update submodules to main + run: | + git submodule foreach --quiet 'git checkout main && git pull --quiet origin main' 2>/dev/null || true - name: Setup mokocli tools env: @@ -182,7 +187,7 @@ jobs: run: | TAG="${{ steps.meta.outputs.tag }}" VERSION="${{ steps.meta.outputs.version }}" - API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" php ${MOKO_CLI}/release_create.php \ --path . --version "$VERSION" --tag "$TAG" \ --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \ @@ -193,7 +198,7 @@ jobs: run: | TAG="${{ steps.meta.outputs.tag }}" VERSION="${{ steps.meta.outputs.version }}" - API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" # Extract [Unreleased] section from changelog (everything between [Unreleased] and next ## heading) if [ -f "CHANGELOG.md" ]; then @@ -230,7 +235,7 @@ jobs: run: | VERSION="${{ steps.meta.outputs.version }}" TAG="${{ steps.meta.outputs.tag }}" - API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" php ${MOKO_CLI}/release_package.php \ --path . --version "$VERSION" --tag "$TAG" \ --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \ @@ -243,7 +248,7 @@ jobs: if: steps.eligibility.outputs.proceed == 'true' continue-on-error: true run: | - API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" php ${MOKO_CLI}/release_cascade.php \ diff --git a/.mokogitea/workflows/rc-revert.yml b/.mokogitea/workflows/rc-revert.yml index 5e61de81dc..8271593839 100644 --- a/.mokogitea/workflows/rc-revert.yml +++ b/.mokogitea/workflows/rc-revert.yml @@ -29,12 +29,20 @@ jobs: steps: - name: Rename branch + env: + BRANCH: ${{ github.event.pull_request.head.ref }} + REPO: ${{ github.repository }} + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} run: | - BRANCH="${{ github.event.pull_request.head.ref }}" + set -euo pipefail + # BRANCH is attacker-controlled (PR head ref). Strict allowlist before ANY use. + if ! printf '%s' "$BRANCH" | grep -Eq '^rc/[A-Za-z0-9._/-]+$'; then + echo "::error::Refusing unsafe branch name: $BRANCH"; exit 1 + fi SUFFIX="${BRANCH#rc/}" DEV_BRANCH="dev/${SUFFIX}" - API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches" - TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" + API="${GITEA_URL}/api/v1/repos/${REPO}/branches" # Create dev/ branch from rc/ branch STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \ @@ -42,25 +50,22 @@ jobs: -H "Content-Type: application/json" \ -d "{\"new_branch_name\": \"${DEV_BRANCH}\", \"old_branch_name\": \"${BRANCH}\"}" \ "${API}" 2>/dev/null || true) - if [ "$STATUS" = "201" ]; then - echo "Created branch: ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY + echo "Created branch: ${DEV_BRANCH}" >> "$GITHUB_STEP_SUMMARY" else - echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})" - exit 1 + echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"; exit 1 fi - # Delete rc/ branch - ENCODED=$(php -r "echo rawurlencode('${BRANCH}');") + # Read BRANCH from the environment inside PHP (getenv, no string interpolation -> no PHP injection) + ENCODED=$(php -r 'echo rawurlencode(getenv("BRANCH"));') STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \ -H "Authorization: token ${TOKEN}" \ "${API}/${ENCODED}" 2>/dev/null || true) - if [ "$STATUS" = "204" ]; then - echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY + echo "Deleted branch: ${BRANCH}" >> "$GITHUB_STEP_SUMMARY" else echo "::warning::Failed to delete ${BRANCH} (HTTP ${STATUS})" fi - echo "### RC Reverted" >> $GITHUB_STEP_SUMMARY - echo "${BRANCH} → ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY + echo "### RC Reverted" >> "$GITHUB_STEP_SUMMARY" + echo "${BRANCH} → ${DEV_BRANCH}" >> "$GITHUB_STEP_SUMMARY" diff --git a/CHANGELOG.md b/CHANGELOG.md index 59578248c4..ec2a42e041 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] ### Added +- Workflow subdirectory discovery: workflows in subdirectories of `.mokogitea/workflows/` are now auto-discovered (#693) - API token scope `read:licensing` / `write:licensing` for licensing endpoints (#697) - Edit API token scopes: PATCH /users/{username}/tokens/{id} API endpoint + web UI edit button (#697) - Wiki full-text search: case-insensitive search across all wiki page titles and content (#550) @@ -33,11 +34,18 @@ - Wiki page rename with automatic redirects via YAML frontmatter (#672) ### Fixed +- Cherry-pick upstream v1.26.2: handle empty pull request files view to allow reviews (#37783) +- Cherry-pick upstream v1.26.2: fix "run as root" check with snap container detection (#37622) +- Cherry-pick upstream: ack re-sent UpdateLog finalize idempotently (#37885) +- Cherry-pick upstream: reject workflow_dispatch for workflows without that trigger (#37660) +- Cherry-pick upstream: keep action run title clickable when commit subject is a URL (#37867) +- Cherry-pick upstream: exclude workflow_call from workflow trigger detection (#37894) - API token edit: reject empty scope update requests with 400 instead of silently succeeding - Workflow token auth: pr-check.yml pre-release dispatch was silently failing due to env var / curl reference mismatch - Workflow tokens: standardize all GA_TOKEN/GITEA_TOKEN/GITEA_URL env vars to MOKOGITEA_TOKEN/MOKOGITEA_URL across all workflow files in 5 template repos + MokoCLI (65+ files) - CI issue reporter: rename GITEA_TOKEN/GITEA_URL to MOKOGITEA_TOKEN/MOKOGITEA_URL in automation/ci-issue-reporter.sh - Workflow sync trigger: add workflow_dispatch event, fix if-condition to allow manual dispatch, add PHP install step for non-PHP runners +- Deploy workflow: merge dev health check into deploy job to avoid runner status reporting failures on inter-job handoff - Licensing API: handle DB write errors in UpdateLicense, UpdateTier, DeleteTier instead of silently discarding - Wiki API: fix findEntryForFile URL-decode fallback for non-ASCII page names - Metadata settings template 500 error: removed reference to deleted Version field @@ -47,6 +55,7 @@ - Issue statuses template: garbled em-dash character replaced ### Changed +- Custom workflows moved to `.mokogitea/workflows/custom/`: deploy-mokogitea, deploy-dev, cascade-dev, pr-rc-release, test-mokogitea, upstream-bug-sync - Issue status seed defaults: Open, In Progress, Waiting, In Review, Closed, Won't Fix - Pre-release workflow: auto-bump skipped for non-Joomla repos (platform check) - CI issue reporter: moved to MokoCLI (cli/ci_issue_reporter.sh), pr-check and repo-health now use ci-issue-reporter.yml reusable workflow diff --git a/README.md b/README.md index a78ed1923c..8e887760d8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MokoGitea -Custom Gitea fork with enhanced wiki system, DLID licensing, issue statuses, org metadata, and project board API. +Custom Gitea fork with enhanced wiki system, DLID licensing, issue statuses, org metadata, CI standardization, and project board API. ![Language](https://img.shields.io/badge/Go-00ADD8?style=flat-square&logo=go&logoColor=white) ![License](https://img.shields.io/badge/license-GPL--3.0--or--later-green?style=flat-square) @@ -14,6 +14,7 @@ Custom Gitea fork with enhanced wiki system, DLID licensing, issue statuses, org - **Issue Statuses** -- custom workflow statuses per org with required baseline protection - **Org Metadata** -- per-repo metadata API (public GET, admin PUT), platform detection for versioning - **Project Board API** -- REST endpoints for project columns and cards +- **CI Infrastructure** -- reusable workflows, centralized ci-issue-reporter, standardized MOKOGITEA_TOKEN naming - **Dev Deploy Gate** -- builds deploy to dev environment first, production checks dev health ## Documentation @@ -21,6 +22,7 @@ Custom Gitea fork with enhanced wiki system, DLID licensing, issue statuses, org - [Org Wiki](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki/) -- standards, CLI reference, API docs - [Wiki Features](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki/standards/Wiki-Features) -- all 10 wiki enhancements - [Licensing API](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki/api/Licensing-API) +- [Repo Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoGitea-Fork/wiki/) -- feature docs, API reference, operations ## Contributing diff --git a/modules/actions/jobparser/model.go b/modules/actions/jobparser/model.go index 7132c278e9..eb17a43b01 100644 --- a/modules/actions/jobparser/model.go +++ b/modules/actions/jobparser/model.go @@ -298,6 +298,9 @@ func toGitContext(input map[string]any) *model.GithubContext { return gitContext } +// workflowCallEvent is only fired by another workflow's `uses:`, so it is excluded from trigger detection. +const workflowCallEvent = "workflow_call" + func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) { switch rawOn.Kind { case yaml.ScalarNode: @@ -306,6 +309,9 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) { if err != nil { return nil, err } + if val == workflowCallEvent { + return []*Event{}, nil + } return []*Event{ {Name: val}, }, nil @@ -319,6 +325,9 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) { for _, v := range val { switch t := v.(type) { case string: + if t == workflowCallEvent { + continue + } res = append(res, &Event{Name: t}) default: return nil, fmt.Errorf("invalid type %T", t) @@ -332,6 +341,9 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) { } res := make([]*Event, 0, len(events)) for i, k := range events { + if k == workflowCallEvent { + continue + } v := triggers[i] switch v.Kind { case yaml.ScalarNode: diff --git a/modules/actions/jobparser/model_test.go b/modules/actions/jobparser/model_test.go index d0e8204161..7b0be62c26 100644 --- a/modules/actions/jobparser/model_test.go +++ b/modules/actions/jobparser/model_test.go @@ -254,6 +254,53 @@ func TestParseRawOn(t *testing.T) { }, }, }, + { + // `workflow_call` is only fired by another workflow's `uses:`, so ParseRawOn intentionally excludes it from trigger detection. + input: `on: + workflow_call: + inputs: + env: + type: string + required: true + outputs: + sha: + value: ${{ jobs.build.outputs.commit }} + secrets: + DEPLOY_KEY: + required: true +`, + result: []*Event{}, + }, + { + // Mixed: a workflow that is both callable AND triggered by push. Only the "push" event surfaces. + input: `on: + workflow_call: + inputs: + env: + type: string + push: + branches: [main] +`, + result: []*Event{ + { + Name: "push", + acts: map[string][]string{"branches": {"main"}}, + }, + }, + }, + { + // Scalar form: a purely reusable workflow has no event triggers. + input: "on: workflow_call", + result: []*Event{}, + }, + { + // Sequence form: `workflow_call` is excluded while sibling events are kept. + input: "on:\n - push\n - workflow_call\n - pull_request", + result: []*Event{ + {Name: "push"}, + {Name: "pull_request"}, + }, + }, } for _, kase := range kases { t.Run(kase.input, func(t *testing.T) { diff --git a/modules/actions/workflows_test.go b/modules/actions/workflows_test.go index 5472d1dd60..69aad59607 100644 --- a/modules/actions/workflows_test.go +++ b/modules/actions/workflows_test.go @@ -78,6 +78,18 @@ func TestIsWorkflow(t *testing.T) { path: ".gitea/workflows2/test.yml", expected: false, }, + { + name: "subdirectory workflow", + dirs: []string{".gitea/workflows"}, + path: ".gitea/workflows/custom/deploy.yml", + expected: true, + }, + { + name: "deeply nested subdirectory workflow", + dirs: []string{".mokogitea/workflows"}, + path: ".mokogitea/workflows/custom/deploy/staging.yaml", + expected: true, + }, { name: "unrelated path", dirs: []string{".gitea/workflows", ".github/workflows"}, diff --git a/modules/markup/html.go b/modules/markup/html.go index 0097833a71..dd41b4e2e9 100644 --- a/modules/markup/html.go +++ b/modules/markup/html.go @@ -175,16 +175,25 @@ var emojiProcessors = []processor{ emojiProcessor, } +// isBareURLSubject reports whether the (HTML-escaped) commit subject content +// is entirely a single URL, ignoring leading/trailing whitespace. +func isBareURLSubject(content string) bool { + s := strings.TrimSpace(html.UnescapeString(content)) + if s == "" { + return false + } + m := common.GlobalVars().LinkRegex.FindStringIndex(s) + return m != nil && m[0] == 0 && m[1] == len(s) +} + // PostProcessCommitMessageSubject will use the same logic as PostProcess and // PostProcessCommitMessage, but will disable the shortLinkProcessor and -// emailAddressProcessor, will add a defaultLinkProcessor if defaultLink is set, -// which changes every text node into a link to the passed default link. +// emailAddressProcessor, and wraps the whole subject in defaultLink. func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink, content string) (string, error) { procs := []processor{ fullIssuePatternProcessor, comparePatternProcessor, fullHashPatternProcessor, - linkProcessor, mentionProcessor, issueIndexPatternProcessor, commitCrossReferencePatternProcessor, @@ -192,6 +201,15 @@ func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink, content st emojiShortCodeProcessor, emojiProcessor, } + // When the whole subject is a bare URL, linkProcessor would turn it into + // a competing anchor and hijack the surrounding defaultLink wrapper, leaving + // the subject visually unclickable. Match GitHub: render such subjects as + // plain text inside defaultLink. Partial URLs inside larger text still become + // their own links (nested anchors aren't legal HTML, so the outer defaultLink + // naturally breaks on that span, same as on GitHub). + if !isBareURLSubject(content) { + procs = append(procs, linkProcessor) + } procs = append(procs, func(ctx *RenderContext, node *html.Node) { ch := &html.Node{Parent: node, Type: html.TextNode, Data: node.Data} node.Type = html.ElementNode diff --git a/modules/setting/setting.go b/modules/setting/setting.go index 11d8d37ce4..6ed0e09a25 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -14,6 +14,7 @@ import ( "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/optional" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/user" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/util" ) // settings @@ -197,32 +198,38 @@ func loadLoginNotificationFrom(cfg ConfigProvider) { func loadRunModeFrom(rootCfg ConfigProvider) { rootSec := rootCfg.Section("") + mustNotRunAsRoot(rootSec) + + runModeValue := os.Getenv("GITEA_RUN_MODE") + runModeValue = util.IfZero(runModeValue, rootSec.Key("RUN_MODE").String()) + // non-dev mode is treated as prod mode, to protect users from accidentally running in dev mode if there is a typo in this value. + IsProd = !strings.EqualFold(runModeValue, "dev") // TODO: can use case-sensitive comparing in the future + RunMode = util.Iif(IsProd, "prod", "dev") + + // there is a separate check: mustCurrentRunUserMatch (IsRunUserMatchCurrentUser) RunUser = rootSec.Key("RUN_USER").MustString(user.CurrentUsername()) +} + +func mustNotRunAsRoot(rootSec ConfigSection) { + if os.Getuid() != 0 { + return + } + + mustRunAsRoot := os.Getenv("SNAP") != "" && os.Getenv("SNAP_NAME") != "" // snap container runs the app as uid=0 + if mustRunAsRoot { + return + } // The following is a purposefully undocumented option. Please do not run Gitea as root. It will only cause future headaches. // Please don't use root as a bandaid to "fix" something that is broken, instead the broken thing should instead be fixed properly. - unsafeAllowRunAsRoot := ConfigSectionKeyBool(rootSec, "I_AM_BEING_UNSAFE_RUNNING_AS_ROOT") - unsafeAllowRunAsRoot = unsafeAllowRunAsRoot || optional.ParseBool(os.Getenv("GITEA_I_AM_BEING_UNSAFE_RUNNING_AS_ROOT")).Value() - RunMode = os.Getenv("GITEA_RUN_MODE") - if RunMode == "" { - RunMode = rootSec.Key("RUN_MODE").MustString("prod") - } + allowRunAsRoot := ConfigSectionKeyBool(rootSec, "I_AM_BEING_UNSAFE_RUNNING_AS_ROOT") || // check gitea config + optional.ParseBool(os.Getenv("GITEA_I_AM_BEING_UNSAFE_RUNNING_AS_ROOT")).Value() // check gitea env var - // non-dev mode is treated as prod mode, to protect users from accidentally running in dev mode if there is a typo in this value. - RunMode = strings.ToLower(RunMode) - if RunMode != "dev" { - RunMode = "prod" - } - IsProd = RunMode != "dev" - - // check if we run as root - if os.Getuid() == 0 { - if !unsafeAllowRunAsRoot { - // Special thanks to VLC which inspired the wording of this messaging. - log.Fatal("Gitea is not supposed to be run as root. Sorry. If you need to use privileged TCP ports please instead use setcap and the `cap_net_bind_service` permission") - } - log.Critical("You are running Gitea using the root user, and have purposely chosen to skip built-in protections around this. You have been warned against this.") + if !allowRunAsRoot { + // Special thanks to VLC which inspired the wording of this messaging. + log.Fatal("Gitea is not supposed to be run as root. If you need to use privileged TCP ports please instead use `setcap` and the `cap_net_bind_service` permission.") } + log.Warn("You are running Gitea using the root user, and have purposely chosen to skip built-in protections around this. You have been warned against this.") } // HasInstallLock checks the install-lock in ConfigProvider directly, because sometimes the config file is not loaded into setting variables yet. diff --git a/modules/templates/util_render_test.go b/modules/templates/util_render_test.go index 5518858c53..322fee4d66 100644 --- a/modules/templates/util_render_test.go +++ b/modules/templates/util_render_test.go @@ -140,6 +140,18 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject(testInput(), "https://example.com/link", mockRepo)) }) + t.Run("RenderCommitMessageLinkSubjectURLOnly", func(t *testing.T) { + // a bare URL in the subject must not hijack the default link + expected := `https://example.com/file.bin` + assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject("https://example.com/file.bin", "https://example.com/link", mockRepo)) + }) + + t.Run("RenderCommitMessageLinkSubjectPartialURL", func(t *testing.T) { + // a URL embedded in larger subject text still becomes its own link + expected := `see https://example.com/x here` + assert.EqualValues(t, expected, newTestRenderUtils(t).RenderCommitMessageLinkSubject("see https://example.com/x here", "https://example.com/link", mockRepo)) + }) + t.Run("RenderIssueTitle", func(t *testing.T) { defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)() expected := ` space @mention-user diff --git a/routers/api/actions/runner/runner.go b/routers/api/actions/runner/runner.go index d37c6d72a9..9412dc944f 100644 --- a/routers/api/actions/runner/runner.go +++ b/routers/api/actions/runner/runner.go @@ -261,16 +261,32 @@ func (s *Service) UpdateLog( } ack := task.LogLength - if len(req.Msg.Rows) == 0 || req.Msg.Index > ack || int64(len(req.Msg.Rows))+req.Msg.Index <= ack { + // Trim rows the runner already had acked. + var rows []*runnerv1.LogRow + if req.Msg.Index <= ack && int64(len(req.Msg.Rows))+req.Msg.Index > ack { + rows = req.Msg.Rows[ack-req.Msg.Index:] + } + + // Ack a re-sent finalize idempotently. Appending new rows past the seal errors. + if task.LogInStorage { + if len(rows) > 0 { + return nil, status.Errorf(codes.AlreadyExists, "log file has been archived") + } res.Msg.AckIndex = ack return res, nil } - if task.LogInStorage { - return nil, status.Errorf(codes.AlreadyExists, "log file has been archived") + // Bail unless we have new rows or a NoMore to finalize. Even with + // NoMore, bail when the runner has outrun the server — archiving a + // log with a gap is worse than asking it to retry. + if len(rows) == 0 && (!req.Msg.NoMore || req.Msg.Index > ack) { + res.Msg.AckIndex = ack + return res, nil } - rows := req.Msg.Rows[ack-req.Msg.Index:] + // WriteLogs is called even with no rows: with offset==0 it bootstraps + // an empty DBFS file so TransferLogs below has something to read when + // the runner finalizes a task that produced no log output. ns, err := actions.WriteLogs(ctx, task.LogFilename, task.LogSize, rows) if err != nil { return nil, status.Errorf(codes.Internal, "unable to append logs to dbfs file: %v", err) diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 7fb4eb6c4c..d1a161d818 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -1081,6 +1081,8 @@ func ActionsDispatchWorkflow(ctx *context.APIContext) { ctx.APIError(http.StatusNotFound, err) } else if errors.Is(err, util.ErrPermissionDenied) { ctx.APIError(http.StatusForbidden, err) + } else if errors.Is(err, util.ErrInvalidArgument) { + ctx.APIError(http.StatusUnprocessableEntity, err) } else { ctx.APIErrorInternal(err) } diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index c2c026e9cc..3708a1052c 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -681,6 +681,8 @@ func indexCommit(commits []*git.Commit, commitID string) *git.Commit { // ViewPullFiles render pull request changed files list page func viewPullFiles(ctx *context.Context, beforeCommitID, afterCommitID string) { + var err error + ctx.Data["PageIsPullList"] = true ctx.Data["PageIsPullFiles"] = true @@ -705,44 +707,47 @@ func viewPullFiles(ctx *context.Context, beforeCommitID, afterCommitID string) { headCommitID := prCompareInfo.HeadCommitID isSingleCommit := beforeCommitID == "" && afterCommitID != "" - ctx.Data["IsShowingOnlySingleCommit"] = isSingleCommit isShowAllCommits := (beforeCommitID == "" || beforeCommitID == prCompareInfo.CompareBase) && (afterCommitID == "" || afterCommitID == headCommitID) + + ctx.Data["IsShowingOnlySingleCommit"] = isSingleCommit ctx.Data["IsShowingAllCommits"] = isShowAllCommits - if afterCommitID == "" || afterCommitID == headCommitID { - afterCommitID = headCommitID - } + afterCommitID = util.IfZero(afterCommitID, headCommitID) afterCommit := indexCommit(prCompareInfo.Commits, afterCommitID) + if afterCommit == nil && afterCommitID == headCommitID { + afterCommit, err = gitRepo.GetCommit(afterCommitID) + if err != nil { + ctx.ServerError("GetCommit(afterCommitID)", err) + return + } + } if afterCommit == nil { - ctx.HTTPError(http.StatusBadRequest, "after commit not found in PR commits") + ctx.NotFound(nil) return } var beforeCommit *git.Commit - var err error - if !isSingleCommit { - if beforeCommitID == "" || beforeCommitID == prCompareInfo.CompareBase { - beforeCommitID = prCompareInfo.CompareBase - // merge base commit is not in the list of the pull request commits - beforeCommit, err = gitRepo.GetCommit(beforeCommitID) - if err != nil { - ctx.ServerError("GetCommit", err) - return - } - } else { - beforeCommit = indexCommit(prCompareInfo.Commits, beforeCommitID) - if beforeCommit == nil { - ctx.HTTPError(http.StatusBadRequest, "before commit not found in PR commits") - return - } - } - } else { + if isSingleCommit { beforeCommit, err = afterCommit.Parent(0) if err != nil { - ctx.ServerError("Parent", err) + ctx.ServerError("afterCommit.Parent", err) return } beforeCommitID = beforeCommit.ID.String() + } else { + beforeCommitID = util.IfZero(beforeCommitID, prCompareInfo.CompareBase) + beforeCommit = indexCommit(prCompareInfo.Commits, beforeCommitID) + if beforeCommit == nil && beforeCommitID == prCompareInfo.CompareBase { + beforeCommit, err = gitRepo.GetCommit(beforeCommitID) + if err != nil { + ctx.ServerError("GetCommit(beforeCommitID)", err) + return + } + } + } + if beforeCommit == nil { + ctx.NotFound(nil) + return } ctx.Data["CompareInfo"] = prCompareInfo diff --git a/services/actions/workflow.go b/services/actions/workflow.go index df58e548bf..6b6308be67 100644 --- a/services/actions/workflow.go +++ b/services/actions/workflow.go @@ -140,11 +140,17 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re workflow := &model.Workflow{ RawOn: singleWorkflow.RawOn, } + workflowDispatch := workflow.WorkflowDispatchConfig() + if workflowDispatch == nil { + return 0, util.ErrorWrapTranslatable( + util.NewInvalidArgumentErrorf("workflow %q has no workflow_dispatch event trigger", workflowID), + "actions.workflow.has_no_workflow_dispatch", workflowID, + ) + } + inputsWithDefaults := make(map[string]any) - if workflowDispatch := workflow.WorkflowDispatchConfig(); workflowDispatch != nil { - if err = processInputs(workflowDispatch, inputsWithDefaults); err != nil { - return 0, err - } + if err = processInputs(workflowDispatch, inputsWithDefaults); err != nil { + return 0, err } // ctx.Req.PostForm -> WorkflowDispatchPayload.Inputs -> ActionRun.EventPayload -> runner: ghc.Event diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index f78fc3e5f1..6996a91d9e 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -73,7 +73,6 @@ parts: override-build: | set -x - sed -i 's/os.Getuid()/1/g' modules/setting/setting.go npm install -g pnpm TAGS="bindata pam cert" make build install -D gitea "${SNAPCRAFT_PART_INSTALL}/gitea" diff --git a/tests/integration/actions_log_finalize_test.go b/tests/integration/actions_log_finalize_test.go new file mode 100644 index 0000000000..103d59c289 --- /dev/null +++ b/tests/integration/actions_log_finalize_test.go @@ -0,0 +1,91 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/url" + "os" + "testing" + + actions_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/actions" + auth_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/auth" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/dbfs" + repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unittest" + user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user" + actions_module "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/actions" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/storage" + + runnerv1 "code.gitea.io/actions-proto-go/runner/v1" + "connectrpc.com/connect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Regression for https://gitea.com/gitea/runner/issues/950: a runner that +// finalizes a task with no log output sends UpdateLog{Rows:[], NoMore:true}. +// The previous short-circuit on len(Rows)==0 skipped TransferLogs, leaving +// an orphan dbfs_data row. Verify the row is now archived and removed. +func TestActionsLogFinalizeWithoutRows(t *testing.T) { + onGiteaRun(t, func(t *testing.T, _ *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user2.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + + apiRepo := createActionsTestRepo(t, token, "actions-finalize-no-rows", false) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID}) + + runner := newMockRunner() + runner.registerAsRepoRunner(t, user2.Name, repo.Name, "mock-runner", []string{"ubuntu-latest"}, false) + + const wfTreePath = ".gitea/workflows/finalize-no-rows.yml" + wfFileContent := fmt.Sprintf(`name: finalize-no-rows +on: + push: + paths: + - '%s' +jobs: + job1: + runs-on: ubuntu-latest + steps: + - run: noop +`, wfTreePath) + createWorkflowFile(t, token, user2.Name, repo.Name, wfTreePath, getWorkflowCreateFileOptions(user2, repo.DefaultBranch, "trigger", wfFileContent)) + + task := runner.fetchTask(t) + + resp, err := runner.client.runnerServiceClient.UpdateLog(t.Context(), connect.NewRequest(&runnerv1.UpdateLogRequest{ + TaskId: task.Id, + Index: 0, + Rows: nil, + NoMore: true, + })) + require.NoError(t, err) + assert.EqualValues(t, 0, resp.Msg.AckIndex) + + freshTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.Id}) + require.True(t, freshTask.LogInStorage, "log_in_storage must flip after empty NoMore=true") + + _, err = storage.Actions.Stat(freshTask.LogFilename) + assert.NoError(t, err, "archived log must exist in storage") + + _, err = dbfs.Open(t.Context(), actions_module.DBFSPrefix+freshTask.LogFilename) + assert.ErrorIs(t, err, os.ErrNotExist, "DBFS row must be cleaned up after TransferLogs") + + // The runner re-sends its final UpdateLog when the response was lost. + // A sealed log must ack the re-send and still reject new appended rows. + t.Run("re-sent finalize is idempotent", func(t *testing.T) { + finalize := &runnerv1.UpdateLogRequest{TaskId: task.Id, Index: 0, Rows: nil, NoMore: true} + resp, err := runner.client.runnerServiceClient.UpdateLog(t.Context(), connect.NewRequest(finalize)) + require.NoError(t, err) + assert.EqualValues(t, 0, resp.Msg.AckIndex) + + _, err = runner.client.runnerServiceClient.UpdateLog(t.Context(), connect.NewRequest(&runnerv1.UpdateLogRequest{ + TaskId: task.Id, Index: 0, Rows: []*runnerv1.LogRow{{Content: "late"}}, NoMore: true, + })) + require.Error(t, err, "appending rows past the seal must be rejected") + }) + }) +} diff --git a/tests/integration/actions_trigger_test.go b/tests/integration/actions_trigger_test.go index 0f001f2bcd..85cde114ac 100644 --- a/tests/integration/actions_trigger_test.go +++ b/tests/integration/actions_trigger_test.go @@ -931,6 +931,76 @@ jobs: }) } +func TestWorkflowDispatchPublicApiRequiresWorkflowDispatchTrigger(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user2.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + repo, err := repo_service.CreateRepository(t.Context(), user2, user2, repo_service.CreateRepoOptions{ + Name: "workflow-dispatch-requires-trigger", + Description: "test workflow dispatch requires workflow_dispatch", + AutoInit: true, + Gitignores: "Go", + License: "MIT", + Readme: "Default", + DefaultBranch: "main", + IsPrivate: false, + }) + require.NoError(t, err) + require.NotNil(t, repo) + + addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(t.Context(), repo, user2, &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: ".gitea/workflows/push-only.yml", + ContentReader: strings.NewReader(` +on: + push: +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo helloworld +`), + }, + }, + Message: "add workflow", + OldBranch: "main", + NewBranch: "main", + Author: &files_service.IdentityOptions{ + GitUserName: user2.Name, + GitUserEmail: user2.Email, + }, + Committer: &files_service.IdentityOptions{ + GitUserName: user2.Name, + GitUserEmail: user2.Email, + }, + Dates: &files_service.CommitDateOptions{ + Author: time.Now(), + Committer: time.Now(), + }, + }) + require.NoError(t, err) + require.NotNil(t, addWorkflowToBaseResp) + + values := url.Values{} + values.Set("ref", "main") + req := NewRequestWithURLValues(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/push-only.yml/dispatches", repo.FullName()), values). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusUnprocessableEntity) + apiError := DecodeJSON(t, resp, &api.APIError{}) + assert.Contains(t, apiError.Message, "has no workflow_dispatch event trigger") + + unittest.AssertNotExistsBean(t, &actions_model.ActionRun{ + RepoID: repo.ID, + Event: "workflow_dispatch", + WorkflowID: "push-only.yml", + }) + }) +} + func TestWorkflowDispatchPublicApiWithInputs(t *testing.T) { onGiteaRun(t, func(t *testing.T, u *url.URL) { user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) diff --git a/tests/integration/pull_status_test.go b/tests/integration/pull_status_test.go index 65a28e79c1..d346182498 100644 --- a/tests/integration/pull_status_test.go +++ b/tests/integration/pull_status_test.go @@ -140,20 +140,29 @@ func TestPullCreate_EmptyChangesWithDifferentCommits(t *testing.T) { func TestPullCreate_EmptyChangesWithSameCommits(t *testing.T) { onGiteaRun(t, func(t *testing.T, u *url.URL) { session := loginUser(t, "user1") - testRepoFork(t, session, "user2", "repo1", "user1", "repo1", "") - testCreateBranch(t, session, "user1", "repo1", "branch/master", "status1", http.StatusSeeOther) - req := NewRequestWithValues(t, "POST", "/user1/repo1/compare/master...status1", - map[string]string{ - "title": "pull request from status1", - }, - ) - session.MakeRequest(t, req, http.StatusOK) - req = NewRequest(t, "GET", "/user1/repo1/pulls/1") - resp := session.MakeRequest(t, req, http.StatusOK) - doc := NewHTMLParser(t, resp.Body) + testCreateBranch(t, session, "user2", "repo1", "branch/master", "empty-pr-branch", http.StatusSeeOther) + resp := testPullCreateDirectly(t, session, createPullRequestOptions{ + BaseRepoOwner: "user2", + BaseRepoName: "repo1", + BaseBranch: "master", + HeadBranch: "empty-pr-branch", + Title: "empty pr test", + }) + prURL := test.RedirectURL(resp) + + // check the "merge box" text + req := NewRequest(t, "GET", prURL) + resp = session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) text := strings.TrimSpace(doc.doc.Find(".merge-section").Text()) assert.Contains(t, text, "This branch is already included in the target branch. There is nothing to merge.") + + // check the "files" tab content + req = NewRequest(t, "GET", prURL+"/files") + resp = session.MakeRequest(t, req, http.StatusOK) + doc = NewHTMLParser(t, resp.Body) + assert.Equal(t, "Diff Content Not Available", strings.TrimSpace(doc.Find("#diff-container").Text())) }) }