diff --git a/modules/actions/jobparser/aliases.go b/modules/actions/jobparser/aliases.go
new file mode 100644
index 00000000000..664e6943c73
--- /dev/null
+++ b/modules/actions/jobparser/aliases.go
@@ -0,0 +1,121 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package jobparser
+
+import (
+ "errors"
+ "io"
+
+ "gitea.dev/actionslib/pkg/model"
+
+ "go.yaml.in/yaml/v4"
+)
+
+// maxExpandedNodes bounds how many nodes alias expansion may create. go-yaml's own alias guard does
+// not cover us: it only counts while decoding into values, and a workflow is kept as raw yaml.Nodes.
+const maxExpandedNodes = 50000
+
+var errTooManyYamlNodes = errors.New("maximum YAML nodes exceeded")
+
+// ReadWorkflow decodes a workflow file with its aliases expanded. Callers inspect the workflow's
+// raw nodes by kind, and an alias is a kind none of them expect.
+func ReadWorkflow(content []byte) (*model.Workflow, error) {
+ doc, err := resolveYamlAliases(content)
+ if err != nil {
+ return nil, err
+ }
+ return readWorkflowDoc(doc)
+}
+
+func readWorkflowDoc(doc *yaml.Node) (*model.Workflow, error) {
+ if doc.Kind == 0 {
+ return nil, io.EOF // what a yaml decoder reports for an empty file
+ }
+ w := new(model.Workflow)
+ return w, doc.Decode(w)
+}
+
+// decodeResolved is yaml.Unmarshal with aliases expanded first.
+func decodeResolved(content []byte, out any) error {
+ doc, err := resolveYamlAliases(content)
+ if err != nil {
+ return err
+ }
+ return decodeYamlDoc(doc, out)
+}
+
+func decodeYamlDoc(doc *yaml.Node, out any) error {
+ if doc.Kind == 0 {
+ return nil // an empty document, as yaml.Unmarshal treats it
+ }
+ return doc.Decode(out)
+}
+
+// resolveYamlAliases parses content and replaces every alias with a copy of the node its anchor names.
+func resolveYamlAliases(content []byte) (*yaml.Node, error) {
+ doc := &yaml.Node{}
+ if err := yaml.Unmarshal(content, doc); err != nil {
+ return nil, err
+ }
+ budget := maxExpandedNodes
+ return doc, expandAliases(doc, &budget)
+}
+
+// expandAliases replaces node's alias descendants in place.
+func expandAliases(node *yaml.Node, budget *int) error {
+ node.Anchor = "" // a name for a node, not part of the workflow: keep it out of the payloads
+ if err := rejectMergeKeys(node); err != nil {
+ return err
+ }
+ for i, child := range node.Content {
+ if child.Kind != yaml.AliasNode {
+ if err := expandAliases(child, budget); err != nil {
+ return err
+ }
+ continue
+ }
+ copied, err := copyExpanded(child.Alias, budget)
+ if err != nil {
+ return err
+ }
+ node.Content[i] = copied
+ }
+ return nil
+}
+
+// copyExpanded deep copies a node expandAliases already expanded and validated, since an anchor is
+// declared before the alias naming it. An anchor aliased from inside itself is the exception, and
+// recurses here until it exhausts budget.
+func copyExpanded(node *yaml.Node, budget *int) (*yaml.Node, error) {
+ if *budget--; *budget < 0 {
+ return nil, errTooManyYamlNodes
+ }
+ if node.Kind == yaml.AliasNode {
+ return copyExpanded(node.Alias, budget)
+ }
+
+ copied := *node
+ copied.Content = make([]*yaml.Node, len(node.Content))
+ for i, child := range node.Content {
+ child, err := copyExpanded(child, budget)
+ if err != nil {
+ return nil, err
+ }
+ copied.Content[i] = child
+ }
+ return &copied, nil
+}
+
+// rejectMergeKeys refuses `<<: *anchor`, same as GitHub does
+func rejectMergeKeys(node *yaml.Node) error {
+ if node.Kind != yaml.MappingNode {
+ return nil
+ }
+ for i := 0; i < len(node.Content)-1; i += 2 {
+ if node.Content[i].Tag == "!!merge" {
+ return errors.New("merge keys (`<<`) are not supported, alias the whole value instead")
+ }
+ }
+ return nil
+}
diff --git a/modules/actions/jobparser/aliases_test.go b/modules/actions/jobparser/aliases_test.go
new file mode 100644
index 00000000000..71f385e9e52
--- /dev/null
+++ b/modules/actions/jobparser/aliases_test.go
@@ -0,0 +1,85 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package jobparser
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParseResolvesAliases(t *testing.T) {
+ got, err := Parse([]byte(`on: push
+env: &common_env
+ SHARED: "1"
+jobs:
+ a:
+ runs-on: linux
+ env: *common_env
+ steps: &common_steps [{run: echo hi}]
+ b:
+ runs-on: linux
+ env: *common_env
+ steps: *common_steps
+`))
+ require.NoError(t, err)
+ require.Len(t, got, 2)
+
+ for _, workflow := range got {
+ _, job := workflow.Job()
+ var env map[string]string
+ require.NoError(t, job.Env.Decode(&env))
+ assert.Equal(t, map[string]string{"SHARED": "1"}, env)
+ require.Len(t, job.Steps, 1)
+
+ payload, err := workflow.Marshal()
+ require.NoError(t, err)
+ assert.NotContains(t, string(payload), "common_")
+ }
+}
+
+func TestParseRejectsAliases(t *testing.T) {
+ job := func(body string) []byte {
+ return []byte("on: push\njobs:\n a:\n runs-on: linux\n" + body)
+ }
+
+ for _, tt := range []struct {
+ name, wantErr string
+ content []byte
+ }{
+ {
+ name: "nested aliases exceed the node limit",
+ content: []byte(`on: push
+x0: &x0 [1, 2, 3, 4, 5, 6, 7, 8, 9]
+x1: &x1 [*x0, *x0, *x0, *x0, *x0, *x0, *x0, *x0, *x0]
+x2: &x2 [*x1, *x1, *x1, *x1, *x1, *x1, *x1, *x1, *x1]
+x3: &x3 [*x2, *x2, *x2, *x2, *x2, *x2, *x2, *x2, *x2]
+x4: &x4 [*x3, *x3, *x3, *x3, *x3, *x3, *x3, *x3, *x3]
+jobs: {a: {runs-on: linux, steps: [{run: echo}]}}
+`),
+ wantErr: "maximum YAML nodes exceeded",
+ },
+ {
+ name: "anchor aliased from inside itself",
+ content: job(" steps: &s [{run: echo}, *s]\n"),
+ wantErr: "maximum YAML nodes exceeded",
+ },
+ {
+ name: "merge key",
+ content: job(" env: &e {X: \"1\"}\n container:\n image: alpine\n env:\n <<: *e\n"),
+ wantErr: "merge keys (`<<`) are not supported",
+ },
+ {
+ name: "alias before its anchor",
+ content: job(" env: *e\n container: {image: alpine, env: &e {X: \"1\"}}\n"),
+ wantErr: "unknown anchor 'e' referenced",
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ _, err := Parse(tt.content)
+ require.ErrorContains(t, err, tt.wantErr)
+ })
+ }
+}
diff --git a/modules/actions/jobparser/jobparser.go b/modules/actions/jobparser/jobparser.go
index ec43b6e94f0..1368cd34bd3 100644
--- a/modules/actions/jobparser/jobparser.go
+++ b/modules/actions/jobparser/jobparser.go
@@ -4,7 +4,6 @@
package jobparser
import (
- "bytes"
"errors"
"fmt"
"slices"
@@ -43,7 +42,7 @@ func rawMatrixReadsNeeds(node *yaml.Node) bool {
// a scalar), neither of which describes the one job the payload stands for.
func ParseRawSingleWorkflow(payload []byte) (*SingleWorkflow, *Job, error) {
swf := &SingleWorkflow{}
- if err := yaml.Unmarshal(payload, swf); err != nil {
+ if err := decodeResolved(payload, swf); err != nil {
return nil, nil, fmt.Errorf("unmarshal single workflow: %w", err)
}
id, job := swf.Job()
@@ -96,14 +95,21 @@ func expressionReadsContext(value, contextName string) bool {
}
func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
- origin, err := model.ReadWorkflow(bytes.NewReader(content))
+ // The workflow is split into one document per job below, which would strand an alias whose
+ // anchor lands in another one.
+ doc, err := resolveYamlAliases(content)
if err != nil {
- return nil, fmt.Errorf("model.ReadWorkflow: %w", err)
+ return nil, fmt.Errorf("resolve aliases: %w", err)
+ }
+
+ origin, err := readWorkflowDoc(doc)
+ if err != nil {
+ return nil, fmt.Errorf("read workflow: %w", err)
}
workflow := &SingleWorkflow{}
- if err := yaml.Unmarshal(content, workflow); err != nil {
- return nil, fmt.Errorf("yaml.Unmarshal: %w", err)
+ if err := decodeYamlDoc(doc, workflow); err != nil {
+ return nil, fmt.Errorf("decode workflow: %w", err)
}
pc := &parseContext{}
@@ -248,9 +254,6 @@ func validateMatrixFilters(job *model.Job) error {
entries = value.Content
}
for _, entry := range entries {
- if entry.Kind == yaml.AliasNode {
- entry = entry.Alias
- }
if entry.Kind != yaml.MappingNode {
return fmt.Errorf("matrix %s must be a list of mappings", name)
}
diff --git a/modules/actions/jobparser/model.go b/modules/actions/jobparser/model.go
index 51a86863517..7be29b6d247 100644
--- a/modules/actions/jobparser/model.go
+++ b/modules/actions/jobparser/model.go
@@ -259,9 +259,11 @@ func (evt *Event) Inputs() []WorkflowDispatchInput {
}
func ReadWorkflowRawConcurrency(content []byte) (*model.RawConcurrency, error) {
- w := new(model.Workflow)
- err := yaml.NewDecoder(bytes.NewReader(content)).Decode(w)
- return w.RawConcurrency, err
+ w, err := ReadWorkflow(content)
+ if err != nil {
+ return nil, err
+ }
+ return w.RawConcurrency, nil
}
func EvaluateConcurrency(rc *model.RawConcurrency, jobID string, job *Job, gitCtx map[string]any, results map[string]*JobResult, vars map[string]string, inputs map[string]any) (string, bool, error) {
diff --git a/modules/actions/jobparser/workflow_call.go b/modules/actions/jobparser/workflow_call.go
index c81ee2dce4a..bba4aeb54ef 100644
--- a/modules/actions/jobparser/workflow_call.go
+++ b/modules/actions/jobparser/workflow_call.go
@@ -63,7 +63,7 @@ func ParseWorkflowCallSpec(content []byte) (*WorkflowCallSpec, error) {
var doc struct {
On yaml.Node `yaml:"on"`
}
- if err := yaml.Unmarshal(content, &doc); err != nil {
+ if err := decodeResolved(content, &doc); err != nil {
return nil, fmt.Errorf("parse workflow yaml: %w", err)
}
diff --git a/modules/actions/workflows.go b/modules/actions/workflows.go
index 97af9e38bd3..2a1535faa35 100644
--- a/modules/actions/workflows.go
+++ b/modules/actions/workflows.go
@@ -4,7 +4,6 @@
package actions
import (
- "bytes"
"context"
"fmt"
"path"
@@ -121,7 +120,7 @@ func GetContentFromEntry(ctx context.Context, gitRepo *git.Repository, entry *gi
}
func GetEventsFromContent(content []byte) ([]*jobparser.Event, error) {
- workflow, err := model.ReadWorkflow(bytes.NewReader(content))
+ workflow, err := jobparser.ReadWorkflow(content)
if err != nil {
return nil, err
}
diff --git a/routers/web/devtest/mock_actions.go b/routers/web/devtest/mock_actions.go
index 4efba4354ba..bf99f461bd6 100644
--- a/routers/web/devtest/mock_actions.go
+++ b/routers/web/devtest/mock_actions.go
@@ -268,13 +268,18 @@ func MockActionsRunsJobs(ctx *context.Context) {
{jobID: "prep-jdk", name: "prep-jdk", status: actions_model.StatusSuccess, duration: "3s", needs: nil},
{jobID: "code-analysis", name: "code-analysis", status: actions_model.StatusSuccess, duration: "3s", needs: nil},
- // Matrix expansion (the " (...)" suffix is the heuristic the frontend uses to group rows)
- {jobID: "matrix-e2e-1-chromium", name: "matrix-e2e (1, chromium)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
- {jobID: "matrix-e2e-1-firefox", name: "matrix-e2e (1, firefox)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
- {jobID: "matrix-e2e-2-chromium", name: "matrix-e2e (2, chromium)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
- {jobID: "matrix-e2e-3-chromium", name: "matrix-e2e (3, chromium)", status: actions_model.StatusSuccess, duration: "4s", needs: []string{"prep-jdk"}},
- {jobID: "matrix-e2e-3-firefox", name: "matrix-e2e (3, firefox)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
- {jobID: "matrix-e2e-99-webkit", name: "matrix-e2e (99, webkit)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
+ // Matrix expansion: the legs share a single JobID, which is what the frontend groups rows on
+ {jobID: "matrix-e2e", name: "matrix-e2e (1, chromium)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
+ {jobID: "matrix-e2e", name: "matrix-e2e (1, firefox)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
+ {jobID: "matrix-e2e", name: "matrix-e2e (2, chromium)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
+ {jobID: "matrix-e2e", name: "matrix-e2e (3, chromium)", status: actions_model.StatusSuccess, duration: "4s", needs: []string{"prep-jdk"}},
+ {jobID: "matrix-e2e", name: "matrix-e2e (3, firefox)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
+ {jobID: "matrix-e2e", name: "matrix-e2e (99, webkit)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
+
+ // Matrix legs whose `name:` interpolates matrix values, so no " (...)" suffix is derived
+ {jobID: "e2e-browsers", name: "E2E on chromium", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
+ {jobID: "e2e-browsers", name: "E2E on firefox", status: actions_model.StatusSuccess, duration: "3s", needs: []string{"prep-jdk"}},
+ {jobID: "e2e-browsers", name: "E2E on webkit", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
{jobID: "unit-test", name: "unit-test", status: actions_model.StatusSuccess, duration: "3s", needs: []string{"prep-jdk"}},
{jobID: "arch-test", name: "arch-test", status: actions_model.StatusSuccess, duration: "3s", needs: []string{"prep-jdk"}},
@@ -285,13 +290,13 @@ func MockActionsRunsJobs(ctx *context.Context) {
"arch-test",
"integration-test",
"code-analysis",
- "matrix-e2e-1-chromium",
- "matrix-e2e-1-firefox",
- "matrix-e2e-2-chromium",
- "matrix-e2e-3-chromium",
- "matrix-e2e-3-firefox",
- "matrix-e2e-99-webkit",
+ "matrix-e2e",
+ "e2e-browsers",
}},
+
+ // Separate jobs that only look like matrix legs, so they must stay separate nodes
+ {jobID: "deploy-staging", name: "Deploy (staging)", status: actions_model.StatusSuccess, duration: "5s", needs: []string{"build-image"}},
+ {jobID: "deploy-prod", name: "Deploy (prod)", status: actions_model.StatusSuccess, duration: "6s", needs: []string{"deploy-staging"}},
}
resp.State.Run.Jobs = nil
diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go
index 1048abd4ed8..f38ae4413dc 100644
--- a/routers/web/repo/actions/actions.go
+++ b/routers/web/repo/actions/actions.go
@@ -4,7 +4,6 @@
package actions
import (
- "bytes"
stdCtx "context"
"errors"
"fmt"
@@ -21,6 +20,7 @@ import (
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/modules/actions"
+ "gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/base"
"gitea.dev/modules/container"
"gitea.dev/modules/git"
@@ -220,7 +220,7 @@ func prepareWorkflowTemplate(ctx *context.Context, commit *git.Commit) (workflow
ctx.ServerError("GetContentFromEntry", err)
return nil, ""
}
- wf, err := act_model.ReadWorkflow(bytes.NewReader(content))
+ wf, err := jobparser.ReadWorkflow(content)
if err != nil {
workflow.ErrMsg = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error())
workflows = append(workflows, workflow)
@@ -390,7 +390,7 @@ func loadScopedWorkflowModel(ctx *context.Context, repo *repo_model.Repository,
if content == nil {
return nil // the workflow does not exist on the source's default branch
}
- wf, err := act_model.ReadWorkflow(bytes.NewReader(content))
+ wf, err := jobparser.ReadWorkflow(content)
if err != nil {
return nil
}
diff --git a/services/actions/notifier_helper.go b/services/actions/notifier_helper.go
index 527e9da5caf..2955e5f2dc8 100644
--- a/services/actions/notifier_helper.go
+++ b/services/actions/notifier_helper.go
@@ -4,13 +4,11 @@
package actions
import (
- "bytes"
"context"
"fmt"
"slices"
"strings"
- "gitea.dev/actionslib/pkg/model"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
issues_model "gitea.dev/models/issues"
@@ -20,6 +18,7 @@ import (
unit_model "gitea.dev/models/unit"
user_model "gitea.dev/models/user"
actions_module "gitea.dev/modules/actions"
+ "gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/container"
"gitea.dev/modules/git"
"gitea.dev/modules/json"
@@ -553,7 +552,7 @@ func handleSchedules(
crons := make([]*actions_model.ActionSchedule, 0, len(detectedWorkflows))
for _, dwf := range detectedWorkflows {
// Check cron job condition. Only working in default branch
- workflow, err := model.ReadWorkflow(bytes.NewReader(dwf.Content))
+ workflow, err := jobparser.ReadWorkflow(dwf.Content)
if err != nil {
log.Error("ReadWorkflow: %v", err)
continue
diff --git a/services/actions/permission_parser.go b/services/actions/permission_parser.go
index a94bc466c23..2e35815e06d 100644
--- a/services/actions/permission_parser.go
+++ b/services/actions/permission_parser.go
@@ -40,17 +40,13 @@ func parseRawPermissionsExplicit(rawPerms *yaml.Node) *repo_model.ActionsTokenPe
return nil
}
- // Unwrap DocumentNode and resolve AliasNode
+ // Unwrap DocumentNode
node := rawPerms
- for node.Kind == yaml.DocumentNode || node.Kind == yaml.AliasNode {
- if node.Kind == yaml.DocumentNode {
- if len(node.Content) == 0 {
- return nil
- }
- node = node.Content[0]
- } else {
- node = node.Alias
+ for node.Kind == yaml.DocumentNode {
+ if len(node.Content) == 0 {
+ return nil
}
+ node = node.Content[0]
}
if node.Kind == yaml.ScalarNode && node.Value == "" {
diff --git a/services/actions/workflow.go b/services/actions/workflow.go
index 39d98408072..7d0726f164f 100644
--- a/services/actions/workflow.go
+++ b/services/actions/workflow.go
@@ -22,8 +22,6 @@ import (
"gitea.dev/modules/util"
"gitea.dev/services/context"
"gitea.dev/services/convert"
-
- "go.yaml.in/yaml/v4"
)
func EnableOrDisableWorkflow(ctx *context.APIContext, workflowID string, isEnable bool) error {
@@ -125,12 +123,12 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
return 0, err
}
- singleWorkflow := &jobparser.SingleWorkflow{}
- if err := yaml.Unmarshal(content, singleWorkflow); err != nil {
+ workflow, err := jobparser.ReadWorkflow(content)
+ if err != nil {
return 0, fmt.Errorf("failed to unmarshal workflow content: %w", err)
}
// get inputs from post
- workflowDispatch := singleWorkflow.WorkflowDispatchConfig()
+ workflowDispatch := workflow.WorkflowDispatchConfig()
if workflowDispatch == nil {
return 0, util.ErrorWrapTranslatable(
util.NewInvalidArgumentErrorf("workflow %q has no workflow_dispatch event trigger", workflowID),
diff --git a/services/convert/convert.go b/services/convert/convert.go
index 1db9f193cbf..5f8d1f2fdf8 100644
--- a/services/convert/convert.go
+++ b/services/convert/convert.go
@@ -5,7 +5,6 @@
package convert
import (
- "bytes"
"context"
"errors"
"fmt"
@@ -16,7 +15,6 @@ import (
"strconv"
"time"
- "gitea.dev/actionslib/pkg/model"
runnerv1 "gitea.dev/actionslib/runner/v1"
actions_model "gitea.dev/models/actions"
asymkey_model "gitea.dev/models/asymkey"
@@ -31,6 +29,7 @@ import (
"gitea.dev/models/unit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/actions"
+ "gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/container"
"gitea.dev/modules/git"
"gitea.dev/modules/httplib"
@@ -566,7 +565,7 @@ func getActionWorkflowEntry(ctx context.Context, repo *repo_model.Repository, gi
content, err := actions.GetContentFromEntry(ctx, gitRepo, entry)
name := entry.Name()
if err == nil {
- workflow, err := model.ReadWorkflow(bytes.NewReader(content))
+ workflow, err := jobparser.ReadWorkflow(content)
if err == nil {
// Only use the name when specified in the workflow file
if workflow.Name != "" {
diff --git a/web_src/css/base.css b/web_src/css/base.css
index fdcf55848d8..5611e9bb87c 100644
--- a/web_src/css/base.css
+++ b/web_src/css/base.css
@@ -224,6 +224,16 @@ progress::-moz-progress-bar {
background-color: var(--color-accent);
}
+@media (forced-colors: active) {
+ progress {
+ outline: 1px solid CanvasText;
+ outline-offset: -1px;
+ }
+ progress::-webkit-progress-value {
+ background: CanvasText;
+ }
+}
+
* {
caret-color: var(--color-caret);
}
diff --git a/web_src/css/modules/checkbox.css b/web_src/css/modules/checkbox.css
index a90082c1a7a..b986301f3b4 100644
--- a/web_src/css/modules/checkbox.css
+++ b/web_src/css/modules/checkbox.css
@@ -172,6 +172,29 @@ input[type="checkbox"]:indeterminate::before {
background: var(--color-primary) !important;
}
+@media (forced-colors: active) {
+ input[type="checkbox"]:checked,
+ input[type="checkbox"]:indeterminate {
+ background: CanvasText;
+ border-color: CanvasText;
+ }
+ input[type="checkbox"]:disabled:checked,
+ input[type="checkbox"]:disabled:indeterminate {
+ background: GrayText;
+ border-color: GrayText;
+ }
+ input[type="radio"]:disabled:checked {
+ border-color: GrayText;
+ }
+ .ui.toggle.checkbox label::before {
+ outline: 1px solid CanvasText;
+ outline-offset: -1px;
+ }
+ .ui.toggle.checkbox label::after {
+ background: CanvasText;
+ }
+}
+
label.gt-checkbox {
display: inline-flex;
align-items: center;
diff --git a/web_src/css/modules/label.css b/web_src/css/modules/label.css
index 4be3f32a7e6..a0c1f43e6ea 100644
--- a/web_src/css/modules/label.css
+++ b/web_src/css/modules/label.css
@@ -305,3 +305,10 @@ it can also be rendered as flex by adding flex-related classes (the general ".it
filter: grayscale(0.5);
opacity: 0.5;
}
+
+@media (forced-colors: active) {
+ .ui.label {
+ outline: 1px solid CanvasText;
+ outline-offset: -1px;
+ }
+}
diff --git a/web_src/css/repo.css b/web_src/css/repo.css
index 1ab1e579767..b2f5f717025 100644
--- a/web_src/css/repo.css
+++ b/web_src/css/repo.css
@@ -720,6 +720,12 @@ td .commit-summary {
left: 0;
}
+@media (forced-colors: active) {
+ .repository.branches .commit-divergence .bar {
+ background: CanvasText;
+ }
+}
+
.repository.commits .header .search input {
font-weight: var(--font-weight-normal);
padding: 5px 10px;
diff --git a/web_src/js/components/ActivityHeatmap.vue b/web_src/js/components/ActivityHeatmap.vue
index 23fb9b956ed..48d92229314 100644
--- a/web_src/js/components/ActivityHeatmap.vue
+++ b/web_src/js/components/ActivityHeatmap.vue
@@ -7,6 +7,7 @@ import {getCurrentLocale} from '../utils.ts';
type HeatmapValue = {date: Date; count: number};
type HeatmapCell = {date: Date; colorIndex: number; ariaLabel: string; tooltip: string};
type MonthLabel = {monthIdx: number; weekIdx: number};
+type DayLabel = {dayIdx: number; rowIdx: number};
const props = defineProps<{
values: HeatmapValue[];
@@ -47,10 +48,26 @@ function shiftDate(d: Date, days: number): Date {
return out;
}
+function getWeekFirstDay() {
+ const userLocale = navigator.language || 'en-US';
+ try {
+ // Intl.Locale.prototype.getWeekInfo() is still "Limited availability"
+ const localeInfo = new Intl.Locale(userLocale);
+ const weekInfo = localeInfo.getWeekInfo();
+ return weekInfo.firstDay;
+ } catch {
+ // Fallback: countries that widely use Sunday as the first day of the week
+ const region = userLocale.split('-')[1];
+ const sundayRegions = ['US', 'CA', 'MX', 'JP', 'KR', 'IL', 'SA', 'IN', 'BR'];
+ return !region || sundayRegions.includes(region) ? 7 : 1;
+ }
+}
+
const grid = computed(() => {
const start = shiftDate(now, -trailingDays);
- const padStart = start.getDay();
- const padEnd = daysInWeek - 1 - now.getDay();
+ const firstDayIdx = getWeekFirstDay() % daysInWeek; // 0 to 6
+ const padStart = (start.getDay() - firstDayIdx + daysInWeek) % daysInWeek;
+ const padEnd = (firstDayIdx - now.getDay() - 1 + daysInWeek) % daysInWeek;
const weekCount = (trailingDays + 1 + padStart + padEnd) / daysInWeek;
const maxCount = props.values.length ? Math.max(...props.values.map((v) => v.count)) : 0;
@@ -95,9 +112,17 @@ const grid = computed(() => {
}
}
+ const dayLabels: DayLabel[] = [];
+ for (let i = 0; i < daysInWeek; i++) {
+ const labelDay = firstDayIdx + i;
+ if (labelDay % 2 === 0) continue; // only show "Mon/Wed/Fri/Sun" because of limited vertical space
+ const dayIdx = labelDay % daysInWeek;
+ dayLabels.push({dayIdx, rowIdx: i});
+ }
+
const width = gridLeft + (cellSize * weekCount) + squareBorder;
const height = gridTop + (cellSize * daysInWeek);
- return {calendar, monthLabels, width, height};
+ return {calendar, monthLabels, dayLabels, width, height};
});
const legendViewBox = `${cellSize} 0 ${squareSize * (colorRange.length + 2)} ${squareSize}`;
@@ -162,9 +187,15 @@ function handleDayClick(date: Date) {
- {{ locale.heatMapLocale.days[1] }}
- {{ locale.heatMapLocale.days[3] }}
- {{ locale.heatMapLocale.days[5] }}
+
+ {{ locale.heatMapLocale.days[day.dayIdx] }}
+
{
- expect(matrixKeyFromJobName('matrix-e2e (1, chromium)')).toBe('matrix-e2e');
- expect(matrixKeyFromJobName('plain-job')).toBeNull();
+const mockJob = (id: number, jobId: string, name: string, needs?: string[]): ActionsJob =>
+ ({id, link: '', jobId, name, status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '1s', needs});
+
+test('matrix nodes key on job id, not on the display name', () => {
+ const legs = createWorkflowGraphModel([mockJob(1, 'explicit', 'leg one'), mockJob(2, 'explicit', 'leg two')]);
+ expect(legs.nodes).toHaveLength(1);
+ expect(legs.nodes[0].type).toBe('matrix');
+ expect(legs.nodes[0].name).toBe('explicit');
+ expect(legs.nodes[0].jobs.map((j) => j.id)).toEqual([1, 2]);
+
+ const lookalikes = createWorkflowGraphModel([
+ mockJob(1, 'setup', 'setup'),
+ mockJob(2, 'build-fast', 'build (fast)', ['setup']),
+ mockJob(3, 'build-slow', 'build (slow)', ['setup']),
+ ]);
+ expect(lookalikes.nodes.map((n) => n.type)).toEqual(['job', 'group']);
+
+ const noJobId = createWorkflowGraphModel([mockJob(1, '', 'first'), mockJob(2, '', 'second')]);
+ expect(noJobId.nodes.map((n) => n.id)).toEqual(['job:1', 'job:2']);
});
test('computeJobLevels keeps stable topological levels', () => {
@@ -87,7 +98,7 @@ test('graph model collapses matrix and groups jobs that share parents and childr
test('expanded matrix height includes summary and toggle rows', () => {
const collapsed = createWorkflowGraphModel(mockJobs);
- const expanded = createWorkflowGraphModel(mockJobs, new Set(['matrix-e2e']));
+ const expanded = createWorkflowGraphModel(mockJobs, new Set(['matrix:matrix-e2e']));
const collapsedMatrix = collapsed.nodes.find((n) => n.id === 'matrix:matrix-e2e');
const expandedMatrix = expanded.nodes.find((n) => n.id === 'matrix:matrix-e2e');
@@ -125,7 +136,7 @@ test('different-row edge uses cubic bezier curve', () => {
test('multi-level pipeline with two matrices and a converging leaf renders without errors', () => {
const graph = createWorkflowGraphModel(wfTest1Jobs);
const matrices = graph.nodes.filter((n) => n.type === 'matrix');
- expect(matrices.map((n) => n.matrixKey).sort()).toEqual(['E2E Tests', 'Unit Tests']);
+ expect(matrices.map((n) => n.name).sort()).toEqual(['E2E Tests', 'Unit Tests']);
const deployProd = graph.nodes.find((n) => n.id === 'job:18');
const verifyDev = graph.nodes.find((n) => n.id === 'job:16');
@@ -160,14 +171,14 @@ test('reusable callers with identical dependency signature are kept as separate
test('matrix legs that call a reusable workflow are folded into a single matrix node', () => {
const jobs: ActionsJob[] = [
{id: 1, link: '', jobId: 'prepare', name: 'prepare', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '30s'},
- {id: 2, link: '', jobId: 'build_call_linux', name: 'build-call (linux)', status: 'success', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '1m', needs: ['prepare'], callUses: './.gitea/workflows/build.yml'},
- {id: 3, link: '', jobId: 'build_call_windows', name: 'build-call (windows)', status: 'success', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '2m', needs: ['prepare'], callUses: './.gitea/workflows/build.yml'},
- {id: 4, link: '', jobId: 'build_call_macos', name: 'build-call (macos)', status: 'success', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '90s', needs: ['prepare'], callUses: './.gitea/workflows/build.yml'},
+ {id: 2, link: '', jobId: 'build-call', name: 'build-call (linux)', status: 'success', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '1m', needs: ['prepare'], callUses: './.gitea/workflows/build.yml'},
+ {id: 3, link: '', jobId: 'build-call', name: 'build-call (windows)', status: 'success', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '2m', needs: ['prepare'], callUses: './.gitea/workflows/build.yml'},
+ {id: 4, link: '', jobId: 'build-call', name: 'build-call (macos)', status: 'success', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '90s', needs: ['prepare'], callUses: './.gitea/workflows/build.yml'},
];
const graph = createWorkflowGraphModel(jobs);
const matrixNodes = graph.nodes.filter((n) => n.type === 'matrix');
expect(matrixNodes).toHaveLength(1);
- expect(matrixNodes[0].matrixKey).toBe('build-call');
+ expect(matrixNodes[0].name).toBe('build-call');
expect(matrixNodes[0].jobs.map((j) => j.id).sort()).toEqual([2, 3, 4]);
});
diff --git a/web_src/js/components/WorkflowGraph.utils.ts b/web_src/js/components/WorkflowGraph.utils.ts
index f2a8d095a50..883e32ba259 100644
--- a/web_src/js/components/WorkflowGraph.utils.ts
+++ b/web_src/js/components/WorkflowGraph.utils.ts
@@ -13,7 +13,6 @@ export type GraphNode = {
level: number;
displayHeight: number;
jobs: ActionsJob[];
- matrixKey?: string;
};
export type Edge = {
@@ -88,10 +87,14 @@ function graphIdForJob(job: ActionsJob): string {
return `job:${job.id}`;
}
-export function matrixKeyFromJobName(name: string): string | null {
- const idx = name.indexOf(' (');
- if (idx === -1) return null;
- return name.slice(0, idx).trim() || null;
+// matrix legs are named ` ()`; a workflow-provided `name:` may not be
+function matrixLabel(matrixJobs: ActionsJob[], jobId: string): string {
+ const prefixes = new Set(matrixJobs.map((job) => {
+ const idx = job.name.indexOf(' (');
+ return idx === -1 ? '' : job.name.slice(0, idx).trim();
+ }));
+ const [prefix] = prefixes;
+ return prefixes.size === 1 && prefix ? prefix : jobId;
}
export function boxBottom(node: GraphNode): number {
@@ -251,7 +254,7 @@ type VisualGraphBuild = {
function buildVisualGraph(
jobs: ActionsJob[],
- expandedMatrixKeys: ReadonlySet,
+ expandedMatrixNodeIds: ReadonlySet,
options: WorkflowGraphLayoutOptions,
): VisualGraphBuild {
const jobsByJobId = new Map();
@@ -262,18 +265,8 @@ function buildVisualGraph(
jobsByJobId.get(job.jobId)!.push(job);
}
- const matrixJobsByKey = new Map();
- for (const job of jobs) {
- // Matrix legs that call a reusable workflow are still one logical job (a single `uses:`
- // expanded over the matrix), so fold them into a matrix node like any other matrix job.
- const matrixKey = matrixKeyFromJobName(job.name);
- if (!matrixKey) continue;
- if (!matrixJobsByKey.has(matrixKey)) matrixJobsByKey.set(matrixKey, []);
- matrixJobsByKey.get(matrixKey)!.push(job);
- }
- for (const list of matrixJobsByKey.values()) {
- list.sort((a, b) => (jobIndexById.get(a.id) ?? 0) - (jobIndexById.get(b.id) ?? 0));
- }
+ // legs of one matrix job share its `jobId`; their display names are free-form so cannot key them
+ const isMatrixLeg = (job: ActionsJob): boolean => Boolean(job.jobId) && jobsByJobId.get(job.jobId)!.length > 1;
const directNeedsByJobId = buildDirectNeedsMap(jobs);
const rawLevels = computeJobLevels(jobs);
@@ -298,7 +291,7 @@ function buildVisualGraph(
const groupsById = new Map();
const groupCandidateBuckets = new Map();
for (const job of jobs) {
- if (matrixKeyFromJobName(job.name)) continue;
+ if (isMatrixLeg(job)) continue;
// Reusable callers represent distinct workflow files — keep each as its own node so the
// graph mirrors GitHub Actions, where every caller shows up as its own box even when
// siblings share an identical (parents, children) dependency signature.
@@ -319,36 +312,25 @@ function buildVisualGraph(
}
const visualIdByJobId = new Map();
- for (const job of jobs) {
- const matrixKey = matrixKeyFromJobName(job.name);
- // Symmetric with the matrix-bucket loop above (callers included).
- if (matrixKey && (matrixJobsByKey.get(matrixKey)?.length ?? 0) > 1) {
- visualIdByJobId.set(job.id, `matrix:${matrixKey}`);
- continue;
- }
- visualIdByJobId.set(job.id, groupedJobIds.get(job.id) || graphIdForJob(job));
- }
-
const emittedNodeIds = new Set();
const nodes: GraphNode[] = [];
for (const job of jobs) {
- const visualId = visualIdByJobId.get(job.id);
- if (!visualId || emittedNodeIds.has(visualId)) continue;
+ const matrixJobs = isMatrixLeg(job) ? jobsByJobId.get(job.jobId)! : null;
+ const visualId = matrixJobs ? `matrix:${job.jobId}` : (groupedJobIds.get(job.id) || graphIdForJob(job));
+ visualIdByJobId.set(job.id, visualId);
+ if (emittedNodeIds.has(visualId)) continue;
emittedNodeIds.add(visualId);
- const matrixKey = matrixKeyFromJobName(job.name);
- if (matrixKey && visualId.startsWith('matrix:')) {
- const matrixJobs = matrixJobsByKey.get(matrixKey) || [];
+ if (matrixJobs) {
nodes.push({
id: visualId,
type: 'matrix',
- name: matrixKey,
+ name: matrixLabel(matrixJobs, job.jobId),
status: aggregateStatus(matrixJobs),
duration: '',
x: 0, y: 0, level: 0,
- displayHeight: matrixPanelHeight(matrixJobs.length, expandedMatrixKeys.has(matrixKey), options),
+ displayHeight: matrixPanelHeight(matrixJobs.length, expandedMatrixNodeIds.has(visualId), options),
jobs: matrixJobs,
- matrixKey,
});
continue;
}
@@ -539,11 +521,11 @@ function buildRoutedEdges(
export function createWorkflowGraphModel(
jobs: ActionsJob[],
- expandedMatrixKeys: ReadonlySet = new Set(),
+ expandedMatrixNodeIds: ReadonlySet = new Set(),
partialOptions: Partial = {},
): WorkflowGraphModel {
const options = {...defaultLayoutOptions, ...partialOptions};
- const {nodes, edges} = buildVisualGraph(jobs, expandedMatrixKeys, options);
+ const {nodes, edges} = buildVisualGraph(jobs, expandedMatrixNodeIds, options);
const nodesById = new Map(nodes.map((n) => [n.id, n]));
const adjacency = buildNodeAdjacency(edges);
assignNodeLevels(nodes, adjacency);
diff --git a/web_src/js/components/WorkflowGraph.vue b/web_src/js/components/WorkflowGraph.vue
index b95db93473f..f4e733fb484 100644
--- a/web_src/js/components/WorkflowGraph.vue
+++ b/web_src/js/components/WorkflowGraph.vue
@@ -1,5 +1,5 @@