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