From 641c98eb3d8a495a22e8bfe8d8cf9635dc4eb976 Mon Sep 17 00:00:00 2001 From: Zettat123 Date: Sat, 21 Mar 2026 19:19:37 -0600 Subject: [PATCH 1/8] support reusable workflow --- go.mod | 26 +- go.sum | 63 +++- models/actions/run_job.go | 45 +++ models/actions/task.go | 6 +- models/migrations/migrations.go | 1 + models/migrations/v1_26/v330.go | 22 ++ models/perm/access/repo_permission.go | 70 ++++ models/secret/secret.go | 27 ++ modules/actions/jobparser/model.go | 239 +++++++++++- modules/structs/hook.go | 19 + services/actions/concurrency.go | 10 +- services/actions/context.go | 157 +++++++- services/actions/job_emitter.go | 107 +++++- services/actions/rerun_plan.go | 313 ++++++++++++++++ services/actions/reusable_workflow.go | 354 ++++++++++++++++++ services/actions/run.go | 36 +- services/actions/task.go | 9 +- .../actions_reusable_workflow_test.go | 327 ++++++++++++++++ 18 files changed, 1805 insertions(+), 26 deletions(-) create mode 100644 models/migrations/v1_26/v330.go create mode 100644 services/actions/rerun_plan.go create mode 100644 services/actions/reusable_workflow.go create mode 100644 tests/integration/actions_reusable_workflow_test.go diff --git a/go.mod b/go.mod index 24c18d5703cd9..81c6d04aa3733 100644 --- a/go.mod +++ b/go.mod @@ -135,7 +135,9 @@ require ( dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.1.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/DataDog/zstd v1.5.7 // indirect + github.com/Masterminds/semver v1.5.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/RoaringBitmap/roaring/v2 v2.10.0 // indirect github.com/STARRY-S/zip v0.2.3 // indirect @@ -176,14 +178,23 @@ require ( github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect + github.com/containerd/containerd v1.7.2 // indirect github.com/couchbase/go-couchbase v0.1.1 // indirect github.com/couchbase/gomemcached v0.3.3 // indirect github.com/couchbase/goutils v0.1.2 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect + github.com/creack/pty v1.1.21 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davidmz/go-pageant v1.0.2 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/distribution/reference v0.5.0 // indirect + github.com/docker/cli v24.0.7+incompatible // indirect + github.com/docker/distribution v2.8.3+incompatible // indirect + github.com/docker/docker v24.0.9+incompatible // indirect + github.com/docker/docker-credential-helpers v0.7.0 // indirect + github.com/docker/go-connections v0.4.0 // indirect + github.com/docker/go-units v0.5.0 // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/fatih/color v1.18.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect @@ -194,6 +205,8 @@ require ( github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/go-webauthn/x v0.1.24 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect @@ -202,6 +215,7 @@ require ( github.com/google/flatbuffers v25.2.10+incompatible // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/go-tpm v0.9.5 // indirect + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/gorilla/securecookie v1.1.2 // indirect @@ -209,7 +223,9 @@ require ( github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/imdario/mergo v0.3.16 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/joho/godotenv v1.5.1 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/kevinburke/ssh_config v1.4.0 // indirect @@ -228,6 +244,9 @@ require ( github.com/minio/md5-simd v1.1.2 // indirect github.com/minio/minlz v1.0.1 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/moby/buildkit v0.12.5 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450 // indirect @@ -239,6 +258,8 @@ require ( github.com/olekukonko/ll v0.1.0 // indirect github.com/olekukonko/tablewriter v1.0.9 // indirect github.com/onsi/ginkgo v1.16.5 // indirect + github.com/opencontainers/runc v1.1.12 // indirect + github.com/opencontainers/selinux v1.11.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pjbgf/sha1cd v0.4.0 // indirect @@ -256,6 +277,7 @@ require ( github.com/skeema/knownhosts v1.3.1 // indirect github.com/sorairolake/lzip-go v0.3.8 // indirect github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/pflag v1.0.6 // indirect github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect github.com/tinylib/msgp v1.6.1 // indirect github.com/unknwon/com v1.0.1 // indirect @@ -276,6 +298,7 @@ require ( go4.org v0.0.0-20230225012048-214862532bf5 // indirect golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect golang.org/x/mod v0.31.0 // indirect + golang.org/x/term v0.39.0 // indirect golang.org/x/time v0.12.0 // indirect golang.org/x/tools v0.40.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda // indirect @@ -290,7 +313,8 @@ ignore ( replace github.com/jaytaylor/html2text => github.com/Necoro/html2text v0.0.0-20250804200300-7bf1ce1c7347 -replace github.com/nektos/act => gitea.com/gitea/act v0.261.8 +// replace github.com/nektos/act => gitea.com/gitea/act v0.261.8 +replace github.com/nektos/act => gitea.com/Zettat123/act v0.0.0-20260304032015-0d8f8e797a53 // For development only, will be removed before merge exclude github.com/gofrs/uuid v3.2.0+incompatible diff --git a/go.sum b/go.sum index 02e6532542934..670fabad81d83 100644 --- a/go.sum +++ b/go.sum @@ -31,8 +31,8 @@ dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -gitea.com/gitea/act v0.261.8 h1:rUWB5GOZOubfe2VteKb7XP3HRIbcW3UUmfh7bVAgQcA= -gitea.com/gitea/act v0.261.8/go.mod h1:lTp4136rwbZiZS3ZVQeHCvd4qRAZ7LYeiRBqOSdMY/4= +gitea.com/Zettat123/act v0.0.0-20260304032015-0d8f8e797a53 h1:sBLA6IpYMNXCpV8qA1PiiRwJpu2AjICvTLhU8t+2/iQ= +gitea.com/Zettat123/act v0.0.0-20260304032015-0d8f8e797a53/go.mod h1:QlaFLGkBIjcez4cRL0UlmbShPPpVVhrsTfGcxH2hXxw= gitea.com/go-chi/binding v0.0.0-20240430071103-39a851e106ed h1:EZZBtilMLSZNWtHHcgq2mt6NSGhJSZBuduAlinMEmso= gitea.com/go-chi/binding v0.0.0-20240430071103-39a851e106ed/go.mod h1:E3i3cgB04dDx0v3CytCgRTTn9Z/9x891aet3r456RVw= gitea.com/go-chi/cache v0.2.1 h1:bfAPkvXlbcZxPCpcmDVCWoHgiBSBmZN/QosnZvEC0+g= @@ -51,6 +51,8 @@ github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs= github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM= github.com/42wim/sshsig v0.0.0-20250502153856-5100632e8920 h1:mWAVGlovzUfREJBhm0GwJnDNu21yRrL9QH9NIzAU3rg= github.com/42wim/sshsig v0.0.0-20250502153856-5100632e8920/go.mod h1:zWxcT7BIWOe05xVJL0VMvO/PJ6RpoCux10heb77H6Q8= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230106234847-43070de90fa1 h1:EKPd1INOIyr5hWOWhvpmQpY6tKjeG0hT1s3AMC/9fic= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230106234847-43070de90fa1/go.mod h1:VzwV+t+dZ9j/H867F1M2ziD+yLHtB46oM35FxxMJ4d0= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.0 h1:ci6Yd6nysBRLEodoziB6ah1+YOzZbZk+NYneoA6q+6E= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.0/go.mod h1:QyVsSSN64v5TGltphKLQ2sQxe4OBQg0J1eKRcVBnfgE= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= @@ -65,6 +67,8 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuo github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww= github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.2 h1:FwladfywkNirM+FZYLBR2kBz5C8Tg0fw5w5Y7meRXWI= github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.2/go.mod h1:vv5Ad0RrIoT1lJFdWBZwt4mB1+j+V8DUroixmKDTCdk= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= @@ -74,9 +78,13 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym github.com/DataDog/zstd v1.5.7 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE= github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= github.com/Julusian/godocdown v0.0.0-20170816220326-6d19f8ff2df8/go.mod h1:INZr5t32rG59/5xeltqoCJoNY7e5x/3xoY9WSWVWg74= +github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= +github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/Microsoft/hcsshim v0.10.0-rc.8 h1:YSZVvlIIDD1UxQpJp0h+dnpLUw+TrY0cx8obKsp3bek= +github.com/Microsoft/hcsshim v0.10.0-rc.8/go.mod h1:OEthFdQv/AD2RAdzR6Mm1N1KPCztGKDurW1Z8b8VGMM= github.com/Necoro/html2text v0.0.0-20250804200300-7bf1ce1c7347 h1:3JhDl+JysaO8nhNU1XMaw35VSGjV4IEQAefaG4Lyok4= github.com/Necoro/html2text v0.0.0-20250804200300-7bf1ce1c7347/go.mod h1:2ErI0aycD43Ufr6CFK5lT/NrHGmoZuVbn1nlPThw69o= github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= @@ -229,6 +237,8 @@ github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/containerd/containerd v1.7.2 h1:UF2gdONnxO8I6byZXDi5sXWiWvlW3D/sci7dTQimEJo= +github.com/containerd/containerd v1.7.2/go.mod h1:afcz74+K10M/+cjGHIVQrCt3RAQhUSCAjJ9iMYhhkuI= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= @@ -244,6 +254,8 @@ github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwc github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= +github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -256,11 +268,25 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dimiro1/reply v0.0.0-20200315094148-d0136a4c9e21 h1:PdsjTl0Cg+ZJgOx/CFV5NNgO1ThTreqdgKYiDCMHJwA= github.com/dimiro1/reply v0.0.0-20200315094148-d0136a4c9e21/go.mod h1:xJvkyD6Y2rZapGvPJLYo9dyx1s5dxBEDPa8T3YTuOk0= +github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0= +github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/cli v24.0.7+incompatible h1:wa/nIwYFW7BVTGa7SWPVyyXU9lgORqUb1xfI36MSkFg= +github.com/docker/cli v24.0.7+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= +github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker v24.0.9+incompatible h1:HPGzNmwfLZWdxHqK9/II92pyi1EpYKsAqcl4G0Of9v0= +github.com/docker/docker v24.0.9+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker-credential-helpers v0.7.0 h1:xtCHsjxogADNZcdv1pKUHXryefjlVRqWqIhk/uXJp0A= +github.com/docker/docker-credential-helpers v0.7.0/go.mod h1:rETQfLdHNT3foU5kuNkFR1R1V12OJRRO5lzt2D1b5X0= +github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= +github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4= github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s= github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= @@ -349,11 +375,15 @@ github.com/go-webauthn/webauthn v0.13.4 h1:q68qusWPcqHbg9STSxBLBHnsKaLxNO0RnVKaA github.com/go-webauthn/webauthn v0.13.4/go.mod h1:MglN6OH9ECxvhDqoq1wMoF6P6JRYDiQpC9nc5OomQmI= github.com/go-webauthn/x v0.1.24 h1:6LaWf2zzWqbyKT8IyQkhje1/1KCGhlEkMz4V1tDnt/A= github.com/go-webauthn/x v0.1.24/go.mod h1:2o5XKJ+X1AKqYKGgHdKflGnoQFQZ6flJ2IFCBKSbSOw= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs= github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14= github.com/gogs/go-gogs-client v0.0.0-20210131175652-1d7215cd8d85 h1:UjoPNDAQ5JPCjlxoJd6K8ALZqSDDhk2ymieAZOVaDg0= @@ -427,6 +457,8 @@ github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7/go.mod h1:czg5+yv1E0Z github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef h1:xpF9fUHpoIrrjX24DURVKiwHcFpw19ndIs+FwTSMbno= github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -478,6 +510,8 @@ github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= +github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= +github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= @@ -495,6 +529,8 @@ github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZ github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/jhillyerd/enmime v1.3.0 h1:LV5kzfLidiOr8qRGIpYYmUZCnhrPbcFAnAFUnWn99rw= github.com/jhillyerd/enmime v1.3.0/go.mod h1:6c6jg5HdRRV2FtvVL69LjiX1M8oE0xDX9VEhV3oy4gs= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -508,6 +544,7 @@ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNU github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ= github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= @@ -581,11 +618,21 @@ github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrk github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/buildkit v0.12.5 h1:RNHH1l3HDhYyZafr5EgstEu8aGNCwyfvMtrQDtjH9T0= +github.com/moby/buildkit v0.12.5/go.mod h1:YGwjA2loqyiYfZeEo8FtI7z4x5XponAaIWsWcSjWwso= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= +github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= +github.com/moby/term v0.0.0-20200312100748-672ec06f55cd h1:aY7OQNf2XqY/JQ6qREWamhI/81os/agb2BAGpcx5yWI= +github.com/moby/term v0.0.0-20200312100748-672ec06f55cd/go.mod h1:DdlQx2hp0Ss5/fLikoLlEeIYiATotOjgB//nb973jeo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450 h1:j2kD3MT1z4PXCiUllUJF9mWUESr9TWKS7iEKsQ/IipM= github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450/go.mod h1:skjdDftzkFALcuGzYSklqYd8gvat6F1gZJ4YPVbkZpM= github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg= @@ -628,6 +675,10 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/opencontainers/runc v1.1.12 h1:BOIssBaW1La0/qbNZHXOOa71dZfZEQOzW7dqQf3phss= +github.com/opencontainers/runc v1.1.12/go.mod h1:S+lQwSfncpBha7XTy/5lBwWgm5+y5Ma/O44Ekby9FK8= +github.com/opencontainers/selinux v1.11.0 h1:+5Zbo97w3Lbmb3PeqQtpmTkMwsW5nRI3YaLpt7tQ7oU= +github.com/opencontainers/selinux v1.11.0/go.mod h1:E5dMC3VPuVvVHDYmi78qvhJp8+M586T4DlDRYpFkyec= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= @@ -716,6 +767,8 @@ github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkU github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= @@ -780,6 +833,7 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i github.com/yohcop/openid-go v1.0.1 h1:DPRd3iPO5F6O5zX2e62XpVAbPT6wV51cuucH0z9g3js= github.com/yohcop/openid-go v1.0.1/go.mod h1:b/AvD03P0KHj4yuihb+VtLD6bYYgsy0zqBzPCRjkCNs= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -960,6 +1014,7 @@ golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1030,8 +1085,10 @@ golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapK golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200325010219-a49f79bcc224/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200928182047-19e03678916f/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= @@ -1111,6 +1168,8 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/models/actions/run_job.go b/models/actions/run_job.go index 616e298dc9727..bcf8cb5c2cf3f 100644 --- a/models/actions/run_job.go +++ b/models/actions/run_job.go @@ -56,6 +56,33 @@ type ActionRunJob struct { // It is JSON-encoded repo_model.ActionsTokenPermissions and may be empty if not specified. TokenPermissions *repo_model.ActionsTokenPermissions `xorm:"JSON TEXT"` + // IsReusableCall indicates this job is a reusable workflow caller job ("uses: ./.gitea/workflows/*.yml@..."). + // It doesn't run on a runner, but groups the reusable workflow's expanded jobs. + IsReusableCall bool `xorm:"index NOT NULL DEFAULT FALSE"` + // ReusableWorkflowUses stores the raw "uses" value of a reusable workflow caller job. + // It should only be set for reusable workflow caller jobs (IsReusableCall == true). + ReusableWorkflowUses string `xorm:"VARCHAR(255)"` + // ParentCallJobID indicates this job belongs to a reusable workflow caller job. + // It's the ID of the parent ActionRunJob (the caller job). 0 means this job is not a child job of a reusable call. + ParentCallJobID int64 `xorm:"index NOT NULL DEFAULT 0"` + // RootCallJobID indicates the outermost reusable workflow caller job this job belongs to. + // It's the ID of the root caller ActionRunJob. 0 means this job is not inside a reusable call. + RootCallJobID int64 `xorm:"index NOT NULL DEFAULT 0"` + // CallDepth is the nested depth of reusable workflow calls. + // 0 means this job is in the root workflow (including caller jobs). Child jobs have depth >= 1. + CallDepth int `xorm:"index NOT NULL DEFAULT 0"` + // CallEventPayload is the event payload for reusable workflow call. + // It should only be set for reusable workflow caller jobs (IsReusableCall == true). + CallEventPayload string `xorm:"LONGTEXT"` + // CallSecretsInherit indicates the caller job uses "secrets: inherit" when calling a reusable workflow. + // It should only be set for reusable workflow caller jobs (IsReusableCall == true). + CallSecretsInherit bool `xorm:"NOT NULL DEFAULT FALSE"` + // CallSecretNames stores the reusable workflow call secrets mapping, encoded as JSON. + // Key is the secret name expected by the called workflow (declared in "on.workflow_call.secrets"), + // value is the secret name referenced from the caller workflow ("${{ secrets.NAME }}"), e.g. {"parent_token":"mysecret"}. + // It should only be set for reusable workflow caller jobs (IsReusableCall == true). + CallSecretNames string `xorm:"LONGTEXT"` + Started timeutil.TimeStamp Stopped timeutil.TimeStamp Created timeutil.TimeStamp `xorm:"created"` @@ -155,6 +182,24 @@ func GetRunJobsByRunID(ctx context.Context, runID int64) (ActionJobList, error) return jobs, nil } +func GetReusableCallerChildJobs(ctx context.Context, callerJob *ActionRunJob) (ActionJobList, error) { + if callerJob == nil || callerJob.ID <= 0 || callerJob.RunID <= 0 || callerJob.RepoID <= 0 { + return nil, util.NewInvalidArgumentErrorf("invalid caller job") + } + var jobs []*ActionRunJob + if err := db.GetEngine(ctx). + Where(builder.Eq{ + "run_id": callerJob.RunID, + "repo_id": callerJob.RepoID, + "parent_call_job_id": callerJob.ID, + }). + OrderBy("id"). + Find(&jobs); err != nil { + return nil, err + } + return jobs, nil +} + func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, cols ...string) (int64, error) { e := db.GetEngine(ctx) diff --git a/models/actions/task.go b/models/actions/task.go index e092d6fbbd948..a2453946f0495 100644 --- a/models/actions/task.go +++ b/models/actions/task.go @@ -252,7 +252,11 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask } var jobs []*ActionRunJob - if err := e.Where("task_id=? AND status=?", 0, StatusWaiting).And(jobCond).Asc("updated", "id").Find(&jobs); err != nil { + if err := e.Where("task_id=? AND status=?", 0, StatusWaiting). + And(builder.Eq{"is_reusable_call": false}). + And(jobCond). + Asc("updated", "id"). + Find(&jobs); err != nil { return nil, false, err } diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index bb8dad5ec6d8a..9766177bc22e6 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -404,6 +404,7 @@ func prepareMigrationTasks() []*migration { newMigration(327, "Add disabled state to action runners", v1_26.AddDisabledToActionRunner), newMigration(328, "Add TokenPermissions column to ActionRunJob", v1_26.AddTokenPermissionsToActionRunJob), newMigration(329, "Add unique constraint for user badge", v1_26.AddUniqueIndexForUserBadge), + newMigration(330, "Add reusable workflow call fields to action_run_job", v1_26.AddReusableWorkflowCallFieldsToActionRunJob), } return preparedMigrations } diff --git a/models/migrations/v1_26/v330.go b/models/migrations/v1_26/v330.go new file mode 100644 index 0000000000000..fbf15772db8fe --- /dev/null +++ b/models/migrations/v1_26/v330.go @@ -0,0 +1,22 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import "xorm.io/xorm" + +func AddReusableWorkflowCallFieldsToActionRunJob(x *xorm.Engine) error { + type ActionRunJob struct { + IsReusableCall bool `xorm:"index NOT NULL DEFAULT FALSE"` + ReusableWorkflowUses string `xorm:"VARCHAR(255)"` + ParentCallJobID int64 `xorm:"index NOT NULL DEFAULT 0"` + RootCallJobID int64 `xorm:"index NOT NULL DEFAULT 0"` + CallDepth int `xorm:"index NOT NULL DEFAULT 0"` + CallEventPayload string `xorm:"LONGTEXT"` + CallSecretsInherit bool `xorm:"NOT NULL DEFAULT FALSE"` + CallSecretNames string `xorm:"LONGTEXT"` + } + + _, err := x.SyncWithOptions(xorm.SyncOptions{IgnoreDropIndices: true}, new(ActionRunJob)) + return err +} diff --git a/models/perm/access/repo_permission.go b/models/perm/access/repo_permission.go index 622fa5d99ab64..a5ea28877ffcd 100644 --- a/models/perm/access/repo_permission.go +++ b/models/perm/access/repo_permission.go @@ -379,6 +379,76 @@ func GetActionsUserRepoPermission(ctx context.Context, repo *repo_model.Reposito return perm, nil } +// GetActionsUserRepoPermissionByActionRun returns the actions user permissions to the repository. +// +// Unlike GetActionsUserRepoPermission, this function doesn't have an ActionTask context and therefore can't +// apply workflow/job-level token permissions. It is currently only intended for reusable workflow expansion +// (e.g. checking whether a run can read the called workflow repository). +func GetActionsUserRepoPermissionByActionRun(ctx context.Context, repo *repo_model.Repository, actionsUser *user_model.User, run *actions_model.ActionRun) (perm Permission, err error) { + if actionsUser.ID != user_model.ActionsUserID { + return perm, errors.New("api GetActionsUserRepoPermissionByActionRun can only be called by the actions user") + } + + if err := repo.LoadUnits(ctx); err != nil { + return perm, err + } + + // Default deny. + perm.SetUnitsWithDefaultAccessMode(repo.Units, perm_model.AccessModeNone) + + // Same-repo: allow write unless it's a fork PR (fork PRs are read-only). + if run.RepoID == repo.ID { + accessMode := util.Iif(run.IsForkPullRequest, perm_model.AccessModeRead, perm_model.AccessModeWrite) + perm.SetUnitsWithDefaultAccessMode(repo.Units, accessMode) + return perm, nil + } + + // Cross-repo: load the run repo for policy checks. + if err := run.LoadRepo(ctx); err != nil { + return perm, err + } + + // Never grant cross-repo private access for fork PR runs. + if run.IsForkPullRequest { + // Allow public repository read access if the actions bot can read it. + botPerm, err := GetUserRepoPermission(ctx, repo, user_model.NewActionsUser()) + if err != nil { + return perm, err + } + if botPerm.AccessMode >= perm_model.AccessModeRead { + perm.SetUnitsWithDefaultAccessMode(repo.Units, perm_model.AccessModeRead) + } + return perm, nil + } + + // Owner-config allowlist for same-owner cross-repo access. + if checkSameOwnerCrossRepoAccess(ctx, run.Repo, repo, false) { + perm.SetUnitsWithDefaultAccessMode(repo.Units, perm_model.AccessModeRead) + return perm, nil + } + + // Fall through to allow public repository read access via botPerm check below. + botPerm, err := GetUserRepoPermission(ctx, repo, user_model.NewActionsUser()) + if err != nil { + return perm, err + } + if botPerm.AccessMode >= perm_model.AccessModeRead { + perm.SetUnitsWithDefaultAccessMode(repo.Units, perm_model.AccessModeRead) + return perm, nil + } + + // Collaborative-owner relationship for private repositories. + if repo.IsPrivate && run.Repo.IsPrivate { + actionsUnit := repo.MustGetUnit(ctx, unit.TypeActions) + if actionsUnit.ActionsConfig().IsCollaborativeOwner(run.Repo.OwnerID) { + perm.SetUnitsWithDefaultAccessMode(repo.Units, perm_model.AccessModeRead) + return perm, nil + } + } + + return perm, nil +} + // GetUserRepoPermission returns the user permissions to the repository func GetUserRepoPermission(ctx context.Context, repo *repo_model.Repository, user *user_model.User) (perm Permission, err error) { defer func() { diff --git a/models/secret/secret.go b/models/secret/secret.go index a82a924c39303..c2f3a247181e0 100644 --- a/models/secret/secret.go +++ b/models/secret/secret.go @@ -11,6 +11,7 @@ import ( actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" actions_module "code.gitea.io/gitea/modules/actions" + "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" secret_module "code.gitea.io/gitea/modules/secret" "code.gitea.io/gitea/modules/setting" @@ -184,6 +185,32 @@ func GetSecretsOfTask(ctx context.Context, task *actions_model.ActionTask) (map[ secrets[secret.Name] = v } + if task.Job.ParentCallJobID > 0 { + callerJob, err := actions_model.GetRunJobByRepoAndID(ctx, task.Job.RepoID, task.Job.ParentCallJobID) + if err != nil { + return nil, fmt.Errorf("GetRunJobByRepoAndID: %w", err) + } + if !callerJob.CallSecretsInherit { + // For reusable workflow child jobs, only expose explicitly mapped secrets, plus tokens. + filteredSecrets := map[string]string{} + filteredSecrets["GITHUB_TOKEN"] = secrets["GITHUB_TOKEN"] + filteredSecrets["GITEA_TOKEN"] = secrets["GITEA_TOKEN"] + + if callerJob.CallSecretNames != "" { + var mapping map[string]string + if err := json.Unmarshal([]byte(callerJob.CallSecretNames), &mapping); err != nil { + return nil, fmt.Errorf("unmarshal reusable workflow call secrets mapping for caller job %d: %w", callerJob.ID, err) + } + for alias, sourceName := range mapping { + if v, ok := secrets[strings.ToUpper(sourceName)]; ok { + filteredSecrets[alias] = v + } + } + } + secrets = filteredSecrets + } + } + return secrets, nil } diff --git a/modules/actions/jobparser/model.go b/modules/actions/jobparser/model.go index 7132c278e950b..796523b6539c3 100644 --- a/modules/actions/jobparser/model.go +++ b/modules/actions/jobparser/model.go @@ -7,7 +7,14 @@ import ( "bytes" "errors" "fmt" + "regexp" + "strconv" + "strings" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/util" + + "github.com/nektos/act/pkg/exprparser" "github.com/nektos/act/pkg/model" "go.yaml.in/yaml/v4" ) @@ -264,6 +271,229 @@ func EvaluateConcurrency(rc *model.RawConcurrency, jobID string, job *Job, gitCt return evaluated.Group, evaluated.CancelInProgress == "true", nil } +// EvaluateWorkflowCallInputs evaluates reusable workflow call inputs and returns the final input map. +func EvaluateWorkflowCallInputs(workflow *model.Workflow, jobID string, job *Job, gitCtx map[string]any, results map[string]*JobResult, vars map[string]string, inputs map[string]any) (map[string]any, error) { + cfg := workflow.WorkflowCallConfig() + if len(cfg.Inputs) == 0 { + return map[string]any{}, nil + } + + provided := make(container.Set[string], len(job.With)) + + inputsWithDefaults := make(map[string]any) + for name, input := range cfg.Inputs { + if input.Type == "" { + return nil, fmt.Errorf("workflow_call input %q is missing required field \"type\"", name) + } + + if input.Default == "" { + continue + } + + switch input.Type { + case "string": + inputsWithDefaults[name] = input.Default + case "boolean": + v, err := strconv.ParseBool(input.Default) + if err != nil { + return nil, fmt.Errorf("parse workflow_call input %q default: %w", name, err) + } + inputsWithDefaults[name] = v + case "number": + v, err := util.ToFloat64(input.Default) + if err != nil { + return nil, fmt.Errorf("parse workflow_call input %q default: %w", name, err) + } + inputsWithDefaults[name] = v + default: + return nil, fmt.Errorf("unknown workflow_call input type %q", input.Type) + } + } + + actJob := &model.Job{ + Strategy: &model.Strategy{ + FailFastString: job.Strategy.FailFastString, + MaxParallelString: job.Strategy.MaxParallelString, + RawMatrix: job.Strategy.RawMatrix, + }, + } + actJob.Strategy.FailFast = actJob.Strategy.GetFailFast() + actJob.Strategy.MaxParallel = actJob.Strategy.GetMaxParallel() + matrix := make(map[string]any) + matrixes, err := actJob.GetMatrixes() + if err != nil { + return nil, err + } + if len(matrixes) > 0 { + matrix = matrixes[0] + } + + evaluator := NewExpressionEvaluator(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs)) + for k, v := range job.With { + wcInput, ok := cfg.Inputs[k] + if !ok { + continue + } + provided.Add(k) + + var out any + switch val := v.(type) { + case string: + var node yaml.Node + if err := node.Encode(val); err != nil { + return nil, fmt.Errorf("encode workflow_call input %q: %w", k, err) + } + if err := evaluator.EvaluateYamlNode(&node); err != nil { + return nil, fmt.Errorf("evaluate workflow_call input %q: %w", k, err) + } + if err := node.Decode(&out); err != nil { + return nil, fmt.Errorf("decode workflow_call input %q: %w", k, err) + } + default: + out = v + } + + switch wcInput.Type { + case "string": + inputsWithDefaults[k] = out + case "boolean": + switch out.(type) { + case bool: + inputsWithDefaults[k] = out + default: + return nil, fmt.Errorf("workflow_call input %q expects boolean", k) + } + case "number": + f, err := util.ToFloat64(out) + if err != nil { + return nil, fmt.Errorf("workflow_call input %q expects number: %w", k, err) + } + inputsWithDefaults[k] = f + default: + return nil, fmt.Errorf("unknown workflow_call input type %q for input %q", wcInput.Type, k) + } + } + + for name, input := range cfg.Inputs { + if input.Required { + if provided.Contains(name) { + continue + } + if input.Default == "" { + return nil, fmt.Errorf("workflow_call input %q is required", name) + } + } + } + + return inputsWithDefaults, nil +} + +var workflowCallSecretExpr = regexp.MustCompile(`^\$\{\{\s*secrets\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}$`) + +// EvaluateWorkflowCallSecrets evaluates reusable workflow call secrets mapping. +// It only accepts values in the form "${{ secrets.NAME }}". +func EvaluateWorkflowCallSecrets(workflow *model.Workflow, job *Job) (map[string]string, error) { + cfg := workflow.WorkflowCallConfig() + if len(cfg.Secrets) == 0 { + return map[string]string{}, nil + } + + allowed := cfg.Secrets + secrets := make(map[string]string) + + if job == nil || job.RawSecrets.IsZero() { + for name, secret := range allowed { + if secret.Required { + return nil, fmt.Errorf("workflow_call secret %q is required", name) + } + } + return secrets, nil + } + + var raw map[string]string + if err := job.RawSecrets.Decode(&raw); err != nil { + return nil, fmt.Errorf("decode secrets: %w", err) + } + + for name, val := range raw { + secretName, err := parseWorkflowCallSecretValue(val) + if err != nil { + return nil, fmt.Errorf("workflow_call secret %q: %w", name, err) + } + if _, ok := allowed[name]; !ok { + return nil, fmt.Errorf("workflow_call secret %q is not declared", name) + } + secrets[name] = secretName + } + + for name, secret := range allowed { + if secret.Required { + if _, ok := secrets[name]; !ok { + return nil, fmt.Errorf("workflow_call secret %q is required", name) + } + } + } + + return secrets, nil +} + +func parseWorkflowCallSecretValue(val string) (string, error) { + matches := workflowCallSecretExpr.FindStringSubmatch(val) + if len(matches) != 2 { + return "", errors.New("secret value must be in the form \"${{ secrets.NAME }}\"") + } + return matches[1], nil +} + +// EvaluateWorkflowCallOutputs evaluates reusable workflow call outputs with expression support. +// It accepts any valid expression and evaluates it using the provided contexts. +func EvaluateWorkflowCallOutputs(workflow *model.Workflow, gitCtx map[string]any, vars map[string]string, inputs map[string]any, jobOutputs map[string]map[string]string) (map[string]string, error) { + cfg := workflow.WorkflowCallConfig() + if len(cfg.Outputs) == 0 { + return map[string]string{}, nil + } + + jobsCtx := make(map[string]*model.WorkflowCallResult, len(jobOutputs)) + for jobID, outputs := range jobOutputs { + jobsCtx[jobID] = &model.WorkflowCallResult{Outputs: outputs} + } + + env := &exprparser.EvaluationEnvironment{ + Github: toGitContext(gitCtx), + Vars: vars, + Inputs: inputs, + Jobs: &jobsCtx, + } + interpreter := exprparser.NewInterpeter(env, exprparser.Config{}) + + result := make(map[string]string, len(cfg.Outputs)) + for name, outputItem := range cfg.Outputs { + value, err := evaluateWorkflowCallOutputValue(interpreter, outputItem.Value) + if err != nil { + return nil, fmt.Errorf("workflow_call output %q: %w", name, err) + } + result[name] = value + } + + return result, nil +} + +func evaluateWorkflowCallOutputValue(interpreter exprparser.Interpreter, value string) (string, error) { + if !strings.Contains(value, "${{") || !strings.Contains(value, "}}") { + return value, nil + } + expr, _ := rewriteSubExpression(value, true) + evaluated, err := interpreter.Evaluate(expr, exprparser.DefaultStatusCheckNone) + if err != nil { + return "", err + } + str, ok := evaluated.(string) + if !ok { + return "", fmt.Errorf("expression %q did not evaluate to a string", expr) + } + return str, nil +} + func toGitContext(input map[string]any) *model.GithubContext { gitContext := &model.GithubContext{ EventPath: asString(input["event_path"]), @@ -400,8 +630,13 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) { } acts[act] = []string{t} case yaml.MappingNode: - if k != "workflow_dispatch" || act != "inputs" { - return nil, fmt.Errorf("map should only for workflow_dispatch but %s: %#v", act, content) + if k != "workflow_dispatch" && k != "workflow_call" { + return nil, fmt.Errorf("map should only for workflow_dispatch or workflow_call but %s: %#v", act, content) + } + if act != "inputs" { + // workflow_call may also contain "secrets" and "outputs". + // They are parsed elsewhere, here we only need to avoid treating them as invalid workflows. + break } var key string diff --git a/modules/structs/hook.go b/modules/structs/hook.go index 57af38464a2f3..2a5789c40f3c7 100644 --- a/modules/structs/hook.go +++ b/modules/structs/hook.go @@ -565,6 +565,25 @@ func (p *WorkflowDispatchPayload) JSONPayload() ([]byte, error) { return json.MarshalIndent(p, "", " ") } +// WorkflowCallPayload represents a workflow_call payload. +type WorkflowCallPayload struct { + // The name or path of the caller workflow file + Workflow string `json:"workflow"` + // The git reference (branch, tag, or commit SHA) to run the workflow on + Ref string `json:"ref"` + // Input parameters for the reusable workflow call + Inputs map[string]any `json:"inputs"` + // The repository containing the workflow + Repository *Repository `json:"repository"` + // The user who triggered the caller workflow + Sender *User `json:"sender"` +} + +// JSONPayload implements Payload +func (p *WorkflowCallPayload) JSONPayload() ([]byte, error) { + return json.MarshalIndent(p, "", " ") +} + // CommitStatusPayload represents a payload information of commit status event. type CommitStatusPayload struct { // TODO: add Branches per https://docs.github.com/en/webhooks/webhook-events-and-payloads#status diff --git a/services/actions/concurrency.go b/services/actions/concurrency.go index 878e5c483bfee..93b8849c536d2 100644 --- a/services/actions/concurrency.go +++ b/services/actions/concurrency.go @@ -25,7 +25,10 @@ func EvaluateRunConcurrencyFillModel(ctx context.Context, run *actions_model.Act return fmt.Errorf("run LoadAttributes: %w", err) } - actionsRunCtx := GenerateGiteaContext(run, nil) + actionsRunCtx, err := GenerateGiteaContext(ctx, run, nil) + if err != nil { + return fmt.Errorf("GenerateGiteaContext: %w", err) + } jobResults := map[string]*jobparser.JobResult{"": {}} if inputs == nil { var err error @@ -81,7 +84,10 @@ func EvaluateJobConcurrencyFillModel(ctx context.Context, run *actions_model.Act return fmt.Errorf("unmarshal raw concurrency: %w", err) } - actionsJobCtx := GenerateGiteaContext(run, actionRunJob) + actionsJobCtx, err := GenerateGiteaContext(ctx, run, actionRunJob) + if err != nil { + return fmt.Errorf("GenerateGiteaContext: %w", err) + } jobResults, err := findJobNeedsAndFillJobResults(ctx, actionRunJob) if err != nil { diff --git a/services/actions/context.go b/services/actions/context.go index 626ae6ee6bfbb..dd1f303849024 100644 --- a/services/actions/context.go +++ b/services/actions/context.go @@ -11,22 +11,27 @@ import ( actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" actions_module "code.gitea.io/gitea/modules/actions" + "code.gitea.io/gitea/modules/actions/jobparser" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" - "github.com/nektos/act/pkg/model" + act_pkg_model "github.com/nektos/act/pkg/model" + "go.yaml.in/yaml/v4" ) type GiteaContext map[string]any // GenerateGiteaContext generate the gitea context without token and gitea_runtime_token // job can be nil when generating a context for parsing workflow-level expressions -func GenerateGiteaContext(run *actions_model.ActionRun, job *actions_model.ActionRunJob) GiteaContext { +func GenerateGiteaContext(ctx context.Context, run *actions_model.ActionRun, job *actions_model.ActionRunJob) (GiteaContext, error) { event := map[string]any{} - _ = json.Unmarshal([]byte(run.EventPayload), &event) + if err := json.Unmarshal([]byte(run.EventPayload), &event); err != nil { + return nil, err + } baseRef := "" headRef := "" @@ -91,9 +96,23 @@ func GenerateGiteaContext(run *actions_model.ActionRun, job *actions_model.Actio gitContext["job"] = job.JobID gitContext["run_id"] = strconv.FormatInt(job.RunID, 10) gitContext["run_attempt"] = strconv.FormatInt(job.Attempt, 10) + + if job.ParentCallJobID > 0 { + callerJob, err := actions_model.GetRunJobByRepoAndID(ctx, job.RepoID, job.ParentCallJobID) + if err != nil { + return nil, err + } + if callerJob.CallEventPayload != "" { + callEvent := map[string]any{} + if err := json.Unmarshal([]byte(callerJob.CallEventPayload), &callEvent); err != nil { + return nil, fmt.Errorf("unmarshal workflow_call payload for caller job %d: %w", callerJob.ID, err) + } + gitContext["event"] = callEvent + } + } } - return gitContext + return gitContext, nil } type TaskNeed struct { @@ -114,17 +133,40 @@ func FindTaskNeeds(ctx context.Context, job *actions_model.ActionRunJob) (map[st } jobIDJobs := make(map[string][]*actions_model.ActionRunJob) - for _, job := range jobs { - jobIDJobs[job.JobID] = append(jobIDJobs[job.JobID], job) + for _, candidate := range jobs { + // "needs" references must be resolved within the same reusable workflow call scope. + if candidate.ParentCallJobID != job.ParentCallJobID { + continue + } + jobIDJobs[candidate.JobID] = append(jobIDJobs[candidate.JobID], candidate) } ret := make(map[string]*TaskNeed, len(needs)) + reusableCallerOutputsCache := make(map[int64]map[string]string) for jobID, jobsWithSameID := range jobIDJobs { if !needs.Contains(jobID) { continue } var jobOutputs map[string]string for _, job := range jobsWithSameID { + if job.IsReusableCall && job.TaskID == 0 && job.Status.IsDone() { + outputs, ok := reusableCallerOutputsCache[job.ID] + if !ok { + var err error + outputs, err = computeReusableCallerOutputs(ctx, job) + if err != nil { + return nil, err + } + reusableCallerOutputsCache[job.ID] = outputs + } + if len(jobOutputs) == 0 { + jobOutputs = outputs + } else { + jobOutputs = mergeTwoOutputs(outputs, jobOutputs) + } + continue + } + if job.TaskID == 0 || !job.Status.IsDone() { // it shouldn't happen, or the job has been rerun continue @@ -151,6 +193,105 @@ func FindTaskNeeds(ctx context.Context, job *actions_model.ActionRunJob) (map[st return ret, nil } +func computeReusableCallerOutputs(ctx context.Context, callerJob *actions_model.ActionRunJob) (map[string]string, error) { + cache := make(map[int64]map[string]string) + return computeReusableCallerOutputsInternal(ctx, callerJob, cache) +} + +func computeReusableCallerOutputsInternal(ctx context.Context, callerJob *actions_model.ActionRunJob, cache map[int64]map[string]string) (map[string]string, error) { + if callerJob == nil || !callerJob.IsReusableCall || callerJob.ReusableWorkflowUses == "" { + return map[string]string{}, nil + } + + if cached, ok := cache[callerJob.ID]; ok { + return cached, nil + } + + if err := callerJob.LoadAttributes(ctx); err != nil { + return nil, err + } + + childJobs, err := actions_model.GetReusableCallerChildJobs(ctx, callerJob) + if err != nil { + return nil, fmt.Errorf("GetReusableCallerChildJobs: %w", err) + } + if len(childJobs) == 0 { + cache[callerJob.ID] = map[string]string{} + return cache[callerJob.ID], nil + } + for _, child := range childJobs { + if !child.Status.IsDone() { + cache[callerJob.ID] = map[string]string{} + return cache[callerJob.ID], nil + } + } + + singleWorkflow := &jobparser.SingleWorkflow{} + if err := yaml.Unmarshal(childJobs[0].WorkflowPayload, singleWorkflow); err != nil { + return nil, fmt.Errorf("unmarshal reusable workflow: %w", err) + } + workflow := &act_pkg_model.Workflow{RawOn: singleWorkflow.RawOn} + + inputs := map[string]any{} + if callerJob.CallEventPayload != "" { + var payload api.WorkflowCallPayload + if err := json.Unmarshal([]byte(callerJob.CallEventPayload), &payload); err != nil { + return nil, fmt.Errorf("unmarshal workflow_call payload for caller job %d: %w", callerJob.ID, err) + } + if payload.Inputs != nil { + inputs = payload.Inputs + } + } + + gitCtx, err := GenerateGiteaContext(ctx, callerJob.Run, callerJob) + if err != nil { + return nil, err + } + + vars, err := actions_model.GetVariablesOfRun(ctx, callerJob.Run) + if err != nil { + return nil, fmt.Errorf("GetVariablesOfRun: %w", err) + } + + jobOutputs := make(map[string]map[string]string) + for _, child := range childJobs { + var outputs map[string]string + switch { + case child.IsReusableCall && child.ReusableWorkflowUses != "": + childOutputs, err := computeReusableCallerOutputsInternal(ctx, child, cache) + if err != nil { + return nil, err + } + outputs = childOutputs + case child.TaskID > 0: + got, err := actions_model.FindTaskOutputByTaskID(ctx, child.TaskID) + if err != nil { + return nil, fmt.Errorf("FindTaskOutputByTaskID: %w", err) + } + outputs = make(map[string]string, len(got)) + for _, v := range got { + outputs[v.OutputKey] = v.OutputValue + } + default: + outputs = map[string]string{} + } + + if len(jobOutputs[child.JobID]) == 0 { + jobOutputs[child.JobID] = outputs + } else { + jobOutputs[child.JobID] = mergeTwoOutputs(outputs, jobOutputs[child.JobID]) + } + } + + outputs, err := jobparser.EvaluateWorkflowCallOutputs(workflow, gitCtx, vars, inputs, jobOutputs) + if err != nil { + return nil, err + } + + cache[callerJob.ID] = outputs + return outputs, nil +} + // mergeTwoOutputs merges two outputs from two different ActionRunJobs // Values with the same output name may be overridden. The user should ensure the output names are unique. // See https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#using-job-outputs-in-a-matrix-job @@ -166,8 +307,8 @@ func mergeTwoOutputs(o1, o2 map[string]string) map[string]string { return ret } -func (g *GiteaContext) ToGitHubContext() *model.GithubContext { - return &model.GithubContext{ +func (g *GiteaContext) ToGitHubContext() *act_pkg_model.GithubContext { + return &act_pkg_model.GithubContext{ Event: util.GetMapValueOrDefault(*g, "event", map[string]any(nil)), EventPath: util.GetMapValueOrDefault(*g, "event_path", ""), Workflow: util.GetMapValueOrDefault(*g, "workflow", ""), diff --git a/services/actions/job_emitter.go b/services/actions/job_emitter.go index 20a4f81eabb91..0d6b9f094c91d 100644 --- a/services/actions/job_emitter.go +++ b/services/actions/job_emitter.go @@ -4,9 +4,11 @@ package actions import ( + "cmp" "context" "errors" "fmt" + "slices" actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" @@ -15,6 +17,7 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/queue" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" notify_service "code.gitea.io/gitea/services/notify" @@ -89,6 +92,15 @@ func checkJobsByRunID(ctx context.Context, runID int64) error { }); err != nil { return err } + + for _, job := range updatedJobs { + if job.Status == actions_model.StatusWaiting && job.IsReusableCall { + if err := ExpandReusableCallJob(ctx, job); err != nil { + log.Error("expand reusable workflow for run %d job %d: %v", job.RunID, job.ID, err) + } + } + } + CreateCommitStatusForRunJobs(ctx, run, jobs...) for _, job := range updatedJobs { _ = job.LoadAttributes(ctx) @@ -212,8 +224,29 @@ func checkJobsOfRun(ctx context.Context, run *actions_model.ActionRun) (jobs, up job.Run = run } + originalStatuses := make(map[int64]actions_model.Status, len(jobs)) + for _, job := range jobs { + originalStatuses[job.ID] = job.Status + } + + childJobsByCaller := make(map[int64][]*actions_model.ActionRunJob) + for _, job := range jobs { + if job.ParentCallJobID > 0 { + childJobsByCaller[job.ParentCallJobID] = append(childJobsByCaller[job.ParentCallJobID], job) + } + } + + // Reusable workflow caller jobs are group jobs whose statuses are derived from their child jobs. + // Apply derived statuses before resolving blocked jobs so downstream jobs can be unblocked in the same check. + applyDerivedStatusToReusableCallers(jobs, childJobsByCaller) + updates := newJobStatusResolver(jobs, vars).Resolve(ctx) for _, job := range jobs { + // Reusable workflow caller jobs are group jobs. Their statuses are derived from their child jobs + // and should not be updated by the needs-based blocked-job resolver once expanded (child jobs exist). + if job.IsReusableCall && len(childJobsByCaller[job.ID]) > 0 { + continue + } if status, ok := updates[job.ID]; ok { job.Status = status if n, err := actions_model.UpdateRunJob(ctx, job, builder.Eq{"status": actions_model.StatusBlocked}, "status"); err != nil { @@ -224,6 +257,43 @@ func checkJobsOfRun(ctx context.Context, run *actions_model.ActionRun) (jobs, up updatedJobs = append(updatedJobs, job) } } + + // Re-calculate derived statuses after we have updated job statuses, so caller jobs reflect the latest children statuses. + applyDerivedStatusToReusableCallers(jobs, childJobsByCaller) + + now := timeutil.TimeStampNow() + for _, caller := range jobs { + if !caller.IsReusableCall { + continue + } + children := childJobsByCaller[caller.ID] + if len(children) == 0 { + continue + } + + newStatus := caller.Status + updateCols := make([]string, 0, 3) + if originalStatuses[caller.ID] != newStatus { + updateCols = append(updateCols, "status") + } + if caller.Started.IsZero() && newStatus.IsRunning() { + caller.Started = now + updateCols = append(updateCols, "started") + } + if caller.Stopped.IsZero() && newStatus.IsDone() { + caller.Stopped = now + updateCols = append(updateCols, "stopped") + } + if len(updateCols) == 0 { + continue + } + + if _, err := actions_model.UpdateRunJob(ctx, caller, nil, updateCols...); err != nil { + return err + } + updatedJobs = append(updatedJobs, caller) + } + return nil }); err != nil { return nil, nil, err @@ -249,19 +319,24 @@ type jobStatusResolver struct { } func newJobStatusResolver(jobs actions_model.ActionJobList, vars map[string]string) *jobStatusResolver { - idToJobs := make(map[string][]*actions_model.ActionRunJob, len(jobs)) + idToJobs := make(map[int64]map[string][]*actions_model.ActionRunJob) jobMap := make(map[int64]*actions_model.ActionRunJob) for _, job := range jobs { - idToJobs[job.JobID] = append(idToJobs[job.JobID], job) + scopeKey := job.ParentCallJobID + if idToJobs[scopeKey] == nil { + idToJobs[scopeKey] = make(map[string][]*actions_model.ActionRunJob) + } + idToJobs[scopeKey][job.JobID] = append(idToJobs[scopeKey][job.JobID], job) jobMap[job.ID] = job } statuses := make(map[int64]actions_model.Status, len(jobs)) needs := make(map[int64][]int64, len(jobs)) for _, job := range jobs { + scopeKey := job.ParentCallJobID statuses[job.ID] = job.Status for _, need := range job.Needs { - for _, v := range idToJobs[need] { + for _, v := range idToJobs[scopeKey][need] { needs[job.ID] = append(needs[job.ID], v.ID) } } @@ -372,3 +447,29 @@ func updateConcurrencyEvaluationForJobWithNeeds(ctx context.Context, actionRunJo } return nil } + +func applyDerivedStatusToReusableCallers(jobs []*actions_model.ActionRunJob, childJobsByCaller map[int64][]*actions_model.ActionRunJob) { + callers := make([]*actions_model.ActionRunJob, 0, len(childJobsByCaller)) + for _, job := range jobs { + if !job.IsReusableCall { + continue + } + if len(childJobsByCaller[job.ID]) == 0 { + continue + } + callers = append(callers, job) + } + + // Derived status for nested reusable workflows must be computed from inner to outer. + slices.SortFunc(callers, func(a, b *actions_model.ActionRunJob) int { + if a.CallDepth != b.CallDepth { + return b.CallDepth - a.CallDepth // deeper first + } + return cmp.Compare(b.ID, a.ID) + }) + + for _, caller := range callers { + children := childJobsByCaller[caller.ID] + caller.Status = actions_model.AggregateJobStatus(children) + } +} diff --git a/services/actions/rerun_plan.go b/services/actions/rerun_plan.go new file mode 100644 index 0000000000000..95e2f53137b2b --- /dev/null +++ b/services/actions/rerun_plan.go @@ -0,0 +1,313 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/util" +) + +func buildRerunPlan(jobs []*actions_model.ActionRunJob, targetJob *actions_model.ActionRunJob, isRunBlocked bool) *rerunPlan { + builder := newRerunPlanBuilder(jobs, targetJob, isRunBlocked) + return builder.build() +} + +func executeRerunPlan(ctx context.Context, jobs []*actions_model.ActionRunJob, plan *rerunPlan) error { + for _, job := range jobs { + if !plan.rerunJobIDs.Contains(job.ID) { + continue + } + shouldBlock, ok := plan.shouldBlock[job.ID] + if !ok { + shouldBlock = true + } + if err := rerunWorkflowJob(ctx, job, shouldBlock); err != nil { + return err + } + } + return nil +} + +type rerunPlan struct { + // RerunJobIDs contains the IDs of jobs that should be rerun. + rerunJobIDs container.Set[int64] + + // ShouldBlock indicates whether a job should be set to StatusBlocked when rerun. + // If a job ID is not present in this map, it is treated as blocked by default. + shouldBlock map[int64]bool +} + +type rerunPlanBuilder struct { + targetJob *actions_model.ActionRunJob + isRunBlocked bool + + // jobByID maps job database ID to job model for quick lookup. + jobByID map[int64]*actions_model.ActionRunJob + + graph *rerunGraph + + rerunIDs container.Set[int64] + callerSubtreeIDs container.Set[int64] + expandSubtreeCallers container.Set[int64] + ancestorCallerIDs container.Set[int64] + + shouldBlockMemo map[int64]bool +} + +func newRerunPlanBuilder(jobs []*actions_model.ActionRunJob, targetJob *actions_model.ActionRunJob, isRunBlocked bool) *rerunPlanBuilder { + jobByID := make(map[int64]*actions_model.ActionRunJob, len(jobs)) + for _, job := range jobs { + jobByID[job.ID] = job + } + + return &rerunPlanBuilder{ + targetJob: targetJob, + isRunBlocked: isRunBlocked, + graph: newRerunGraph(jobs), + jobByID: jobByID, + rerunIDs: make(container.Set[int64]), + callerSubtreeIDs: make(container.Set[int64]), + expandSubtreeCallers: make(container.Set[int64]), + ancestorCallerIDs: make(container.Set[int64]), + shouldBlockMemo: make(map[int64]bool, len(jobs)), + } +} + +func (b *rerunPlanBuilder) build() *rerunPlan { + if b.targetJob == nil { + return b.buildWholeRun() + } + return b.buildSubsetByTarget() +} + +func (b *rerunPlanBuilder) buildWholeRun() *rerunPlan { + plan := &rerunPlan{rerunJobIDs: make(container.Set[int64]), shouldBlock: make(map[int64]bool)} + for _, job := range b.jobByID { + plan.rerunJobIDs.Add(job.ID) + b.rerunIDs.Add(job.ID) + } + for _, job := range b.jobByID { + plan.shouldBlock[job.ID] = b.shouldBlockByNeedsAndCaller(job.ID) + } + return plan +} + +func (b *rerunPlanBuilder) buildSubsetByTarget() *rerunPlan { + // 1) Always rerun the selected job and all of its downstream jobs within the same scope. + parentCallJobID := b.targetJob.ParentCallJobID + for id := range b.graph.collectDownstreamByParentCallJobID(parentCallJobID, b.targetJob.JobID) { + b.rerunIDs.Add(id) + } + + // 2) If the selected job is a reusable workflow caller job, rerun its whole child job subtree. + if b.targetJob.IsReusableCall { + b.expandSubtreeCallers.Add(b.targetJob.ID) + } + + // 3) If the selected job is inside a reusable call, rerun all ancestor caller jobs (up to root) + // and their downstream jobs. Ancestor caller jobs are not expanded to their sibling subtrees. + if b.targetJob.ParentCallJobID > 0 { + parentID := b.targetJob.ParentCallJobID + for parentID > 0 { + parentCaller := b.jobByID[parentID] + if parentCaller == nil { + break + } + + b.ancestorCallerIDs.Add(parentCaller.ID) + b.rerunIDs.Add(parentCaller.ID) + + parentCallJobID := parentCaller.ParentCallJobID + for id := range b.graph.collectDownstreamByParentCallJobID(parentCallJobID, parentCaller.JobID) { + b.rerunIDs.Add(id) + } + + parentID = parentCaller.ParentCallJobID + } + } + + // 4) Expand reusable call subtrees for caller jobs that are part of this rerun selection, + // except for ancestor callers (their siblings should not be rerun). + for id := range b.rerunIDs { + job := b.jobByID[id] + if job == nil { + continue + } + if job.IsReusableCall && !b.ancestorCallerIDs.Contains(job.ID) { + b.expandSubtreeCallers.Add(job.ID) + } + } + + for callerID := range b.expandSubtreeCallers { + b.rerunIDs.Add(callerID) + subtree := b.graph.collectCallerSubtreeJobs(callerID) + for id := range subtree { + b.rerunIDs.Add(id) + b.callerSubtreeIDs.Add(id) + } + } + + // 5) Compute initial statuses (Blocked vs Waiting) for all selected jobs and build the plan. + plan := &rerunPlan{rerunJobIDs: make(container.Set[int64]), shouldBlock: make(map[int64]bool)} + unblockedTargetJobID := util.Iif(b.targetJob.IsReusableCall, 0, b.targetJob.ID) + + for id := range b.rerunIDs { + job := b.jobByID[id] + if job == nil { + continue + } + + shouldBlock := true + if job.IsReusableCall && b.ancestorCallerIDs.Contains(job.ID) { + shouldBlock = b.isRunBlocked + } else if job.ID == unblockedTargetJobID { + shouldBlock = b.isRunBlocked + } else if b.callerSubtreeIDs.Contains(job.ID) { + shouldBlock = b.shouldBlockByNeedsAndCaller(job.ID) + } + + plan.rerunJobIDs.Add(job.ID) + plan.shouldBlock[job.ID] = shouldBlock + } + return plan +} + +func (b *rerunPlanBuilder) shouldBlockByNeedsAndCaller(jobID int64) bool { + if b.isRunBlocked { + return true + } + if v, ok := b.shouldBlockMemo[jobID]; ok { + return v + } + job := b.jobByID[jobID] + if job == nil { + // Shouldn't happen. Be conservative to avoid running child jobs while their caller can't be resolved. + b.shouldBlockMemo[jobID] = true + return true + } + + // Block if any needed job is not ready. + // "Ready" means the needed job is not being rerun (so it remains done), and it has succeeded or been skipped. + for _, need := range job.Needs { + needJobs := b.graph.jobsByJobIDByParentCallJobID[job.ParentCallJobID][need] + if len(needJobs) == 0 { + b.shouldBlockMemo[jobID] = true + return true + } + + needJob := needJobs[0] + if b.rerunIDs.Contains(needJob.ID) { + b.shouldBlockMemo[jobID] = true + return true + } + if needJob.Status != actions_model.StatusSuccess && needJob.Status != actions_model.StatusSkipped { + b.shouldBlockMemo[jobID] = true + return true + } + } + if job.ParentCallJobID > 0 { + b.shouldBlockMemo[jobID] = b.shouldBlockByNeedsAndCaller(job.ParentCallJobID) + return b.shouldBlockMemo[jobID] + } + b.shouldBlockMemo[jobID] = false + return false +} + +type rerunGraph struct { + // jobsByJobIDByParentCallJobID groups jobs by ParentCallJobID and workflow JobID. + // It allows scope-aware selection of jobs by JobID, to avoid conflicts across reusable workflow scopes. + jobsByJobIDByParentCallJobID map[int64]map[string][]*actions_model.ActionRunJob + // dependentsByParentCallJobID is the reverse dependency graph within a ParentCallJobID scope: + // dependentsByParentCallJobID[parentCallJobID][needJobID] lists jobs that declare "needs: [needJobID]". + dependentsByParentCallJobID map[int64]map[string][]*actions_model.ActionRunJob + // childrenByCaller groups jobs by their direct reusable workflow caller job (job.ParentCallJobID). + // It is used to expand a reusable caller job to its child job subtree (including nested reusable calls). + childrenByCaller map[int64][]*actions_model.ActionRunJob +} + +func newRerunGraph(jobs []*actions_model.ActionRunJob) *rerunGraph { + g := &rerunGraph{ + jobsByJobIDByParentCallJobID: make(map[int64]map[string][]*actions_model.ActionRunJob), + dependentsByParentCallJobID: make(map[int64]map[string][]*actions_model.ActionRunJob), + childrenByCaller: make(map[int64][]*actions_model.ActionRunJob), + } + + for _, job := range jobs { + parentCallJobID := job.ParentCallJobID + if g.jobsByJobIDByParentCallJobID[parentCallJobID] == nil { + g.jobsByJobIDByParentCallJobID[parentCallJobID] = make(map[string][]*actions_model.ActionRunJob) + } + g.jobsByJobIDByParentCallJobID[parentCallJobID][job.JobID] = append(g.jobsByJobIDByParentCallJobID[parentCallJobID][job.JobID], job) + + if g.dependentsByParentCallJobID[parentCallJobID] == nil { + g.dependentsByParentCallJobID[parentCallJobID] = make(map[string][]*actions_model.ActionRunJob) + } + for _, need := range job.Needs { + g.dependentsByParentCallJobID[parentCallJobID][need] = append(g.dependentsByParentCallJobID[parentCallJobID][need], job) + } + + if job.ParentCallJobID > 0 { + g.childrenByCaller[job.ParentCallJobID] = append(g.childrenByCaller[job.ParentCallJobID], job) + } + } + + return g +} + +func (g *rerunGraph) collectDownstreamByParentCallJobID(parentCallJobID int64, seedJobID string) container.Set[int64] { + ret := make(container.Set[int64]) + if seedJobID == "" { + return ret + } + + queue := make([]string, 0, 4) + enqueued := make(container.Set[string]) + if enqueued.Add(seedJobID) { + queue = append(queue, seedJobID) + } + + for len(queue) > 0 { + jobID := queue[0] + queue = queue[1:] + + for _, v := range g.jobsByJobIDByParentCallJobID[parentCallJobID][jobID] { + ret.Add(v.ID) + } + + for _, dependent := range g.dependentsByParentCallJobID[parentCallJobID][jobID] { + if ret.Add(dependent.ID) && enqueued.Add(dependent.JobID) { + queue = append(queue, dependent.JobID) + } + } + } + + return ret +} + +func (g *rerunGraph) collectCallerSubtreeJobs(callerID int64) container.Set[int64] { + ret := make(container.Set[int64]) + if callerID <= 0 { + return ret + } + + stack := []int64{callerID} + for len(stack) > 0 { + id := stack[len(stack)-1] + stack = stack[:len(stack)-1] + + for _, child := range g.childrenByCaller[id] { + if !ret.Add(child.ID) { + continue + } + if child.IsReusableCall { + stack = append(stack, child.ID) + } + } + } + + return ret +} diff --git a/services/actions/reusable_workflow.go b/services/actions/reusable_workflow.go new file mode 100644 index 0000000000000..c0aac73aa5e73 --- /dev/null +++ b/services/actions/reusable_workflow.go @@ -0,0 +1,354 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + "errors" + "fmt" + "strings" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + perm_model "code.gitea.io/gitea/models/perm" + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/actions/jobparser" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/json" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/convert" + + act_pkg_model "github.com/nektos/act/pkg/model" + act_pkg_runner "github.com/nektos/act/pkg/runner" + "go.yaml.in/yaml/v4" + "xorm.io/builder" +) + +func buildReusableWorkflowCallSecrets(workflow *act_pkg_model.Workflow, workflowJob *jobparser.Job) (inherit bool, secretNamesJSON string, _ error) { + if workflowJob != nil && workflowJob.RawSecrets.Kind == yaml.ScalarNode && workflowJob.RawSecrets.Value == "inherit" { + return true, "", nil + } + + mapping, err := jobparser.EvaluateWorkflowCallSecrets(workflow, workflowJob) + if err != nil { + return false, "", err + } + if len(mapping) == 0 { + return false, "", nil + } + b, err := json.Marshal(mapping) + if err != nil { + return false, "", fmt.Errorf("marshal workflow_call secrets mapping: %w", err) + } + return false, string(b), nil +} + +func ExpandReusableCallJob(ctx context.Context, callerJob *actions_model.ActionRunJob) error { + if err := callerJob.LoadAttributes(ctx); err != nil { + return err + } + workflowJob, err := callerJob.ParseJob() + if err != nil { + return err + } + if strings.TrimSpace(workflowJob.Uses) == "" { + return nil + } + + if err := ensureNoReusableWorkflowCallCycle(ctx, callerJob, workflowJob.Uses); err != nil { + return err + } + + ref, err := act_pkg_runner.ParseReusableWorkflowRef(workflowJob.Uses) + if err != nil { + return err + } + + content, err := loadReusableWorkflowContent(ctx, callerJob.Run, ref) + if err != nil { + return err + } + + vars, err := actions_model.GetVariablesOfRun(ctx, callerJob.Run) + if err != nil { + return err + } + runInputs, err := getInputsFromRun(callerJob.Run) + if err != nil { + return fmt.Errorf("get inputs: %w", err) + } + + singleWorkflow := &jobparser.SingleWorkflow{} + if err := yaml.Unmarshal(content, singleWorkflow); err != nil { + return fmt.Errorf("unmarshal workflow: %w", err) + } + workflow := &act_pkg_model.Workflow{ + RawOn: singleWorkflow.RawOn, + } + actionsJobCtx, err := GenerateGiteaContext(ctx, callerJob.Run, callerJob) + if err != nil { + return err + } + jobResults, err := findJobNeedsAndFillJobResults(ctx, callerJob) + if err != nil { + return err + } + + inputsWithDefaults, err := jobparser.EvaluateWorkflowCallInputs(workflow, callerJob.JobID, workflowJob, actionsJobCtx, jobResults, vars, runInputs) + if err != nil { + return err + } + + eventPayload, err := buildWorkflowCallEventPayload(ctx, callerJob.Run, inputsWithDefaults) + if err != nil { + return err + } + + // Check if the caller job has already been expanded. + exist, err := db.GetEngine(ctx).Exist(&actions_model.ActionRunJob{ + RunID: callerJob.RunID, + ParentCallJobID: callerJob.ID, + }) + if err != nil { + return err + } + if exist { + callerJob.CallEventPayload = string(eventPayload) + _, err := actions_model.UpdateRunJob(ctx, callerJob, nil, "call_event_payload") + return err + } + + callSecretsInherit, callSecretNamesJSON, err := buildReusableWorkflowCallSecrets(workflow, workflowJob) + if err != nil { + return err + } + + giteaCtx, err := GenerateGiteaContext(ctx, callerJob.Run, callerJob) + if err != nil { + return err + } + + calledJobs, err := jobparser.Parse(content, + jobparser.WithVars(vars), + jobparser.WithGitContext(giteaCtx.ToGitHubContext()), + jobparser.WithInputs(inputsWithDefaults), + ) + if err != nil { + return err + } + + rootCallJobID := util.Iif(callerJob.RootCallJobID == 0, callerJob.ID, callerJob.RootCallJobID) + + now := timeutil.TimeStampNow() + var readyCallJobs []*actions_model.ActionRunJob + if err := db.WithTx(ctx, func(ctx context.Context) error { + // Update the caller job itself. + callerJob.IsReusableCall = true + callerJob.ReusableWorkflowUses = workflowJob.Uses + callerJob.CallEventPayload = string(eventPayload) + callerJob.CallSecretsInherit = callSecretsInherit + callerJob.CallSecretNames = callSecretNamesJSON + if callerJob.Started.IsZero() { + callerJob.Started = now + } + callerJob.Status = actions_model.StatusRunning + if _, err := actions_model.UpdateRunJob(ctx, callerJob, builder.In("status", []actions_model.Status{actions_model.StatusWaiting, actions_model.StatusBlocked}), "is_reusable_call", "reusable_workflow_uses", "call_event_payload", "call_secrets_inherit", "call_secret_names", "status", "started"); err != nil { + return err + } + + var hasWaitingJobs bool + for _, v := range calledJobs { + id, job := v.Job() + needs := job.Needs() + if err := v.SetJob(id, job.EraseNeeds()); err != nil { + return err + } + payload, _ := v.Marshal() + + shouldBlockJob := len(needs) > 0 + + job.Name = util.EllipsisDisplayString(job.Name, 255) + runJob := &actions_model.ActionRunJob{ + RunID: callerJob.RunID, + RepoID: callerJob.RepoID, + OwnerID: callerJob.OwnerID, + CommitSHA: callerJob.CommitSHA, + IsForkPullRequest: callerJob.IsForkPullRequest, + Name: job.Name, + WorkflowPayload: payload, + JobID: id, + Needs: needs, + RunsOn: job.RunsOn(), + Status: util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting), + + ParentCallJobID: callerJob.ID, + RootCallJobID: rootCallJobID, + CallDepth: callerJob.CallDepth + 1, + } + if job.Uses != "" { + runJob.IsReusableCall = true + runJob.ReusableWorkflowUses = job.Uses + } + + if job.RawConcurrency != nil { + rawConcurrency, err := yaml.Marshal(job.RawConcurrency) + if err != nil { + return fmt.Errorf("marshal raw concurrency: %w", err) + } + runJob.RawConcurrency = string(rawConcurrency) + + if len(needs) == 0 { + if err := EvaluateJobConcurrencyFillModel(ctx, callerJob.Run, runJob, vars, inputsWithDefaults); err != nil { + return fmt.Errorf("evaluate job concurrency: %w", err) + } + } + + if runJob.Status == actions_model.StatusWaiting { + runJob.Status, err = PrepareToStartJobWithConcurrency(ctx, runJob) + if err != nil { + return fmt.Errorf("prepare to start job with concurrency: %w", err) + } + } + } + + if err := db.Insert(ctx, runJob); err != nil { + return err + } + if runJob.Status == actions_model.StatusWaiting { + if runJob.IsReusableCall { + readyCallJobs = append(readyCallJobs, runJob) + } else { + hasWaitingJobs = true + } + } + } + + if hasWaitingJobs { + if err := actions_model.IncreaseTaskVersion(ctx, callerJob.OwnerID, callerJob.RepoID); err != nil { + return err + } + } + + allJobs, err := actions_model.GetRunJobsByRunID(ctx, callerJob.RunID) + if err != nil { + return err + } + + // UpdateRun requires an up-to-date Version, and the run may have already been updated by UpdateRunJob above. + // Reload it to avoid optimistic locking failures like "run has changed". + run, err := actions_model.GetRunByRepoAndID(ctx, callerJob.RepoID, callerJob.RunID) + if err != nil { + return err + } + run.Status = actions_model.AggregateJobStatus(allJobs) + return actions_model.UpdateRun(ctx, run, "status") + }); err != nil { + return err + } + + for _, callJob := range readyCallJobs { + if err := ExpandReusableCallJob(ctx, callJob); err != nil { + return err + } + } + + return nil +} + +func loadReusableWorkflowContent(ctx context.Context, parentRun *actions_model.ActionRun, ref *act_pkg_runner.ReusableWorkflowRef) ([]byte, error) { + if ref.Kind == act_pkg_runner.ReusableWorkflowKindLocalSameRepo { + if err := parentRun.LoadRepo(ctx); err != nil { + return nil, err + } + return readWorkflowContentFromLocalRepo(ctx, parentRun.Repo, parentRun.Ref, ref.WorkflowPath) + } + + if ref.Kind == act_pkg_runner.ReusableWorkflowKindLocalOtherRepo { + repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, ref.Owner, ref.Repo) + if err != nil { + return nil, err + } + if repo.IsPrivate { + perm, err := access_model.GetActionsUserRepoPermissionByActionRun(ctx, repo, user_model.NewActionsUser(), parentRun) + if err != nil { + return nil, err + } + if !perm.CanRead(unit.TypeCode) { + return nil, errors.New("actions user has no access to reusable workflow repo") + } + } + return readWorkflowContentFromLocalRepo(ctx, repo, ref.Ref, ref.WorkflowPath) + } + + content, err := ref.FetchReusableWorkflowContent(ctx) + if err != nil { + return nil, err + } + return content, nil +} + +func readWorkflowContentFromLocalRepo(ctx context.Context, repo *repo_model.Repository, ref, workflowPath string) ([]byte, error) { + gitRepo, err := gitrepo.OpenRepository(ctx, repo) + if err != nil { + return nil, err + } + defer gitRepo.Close() + + commit, err := gitRepo.GetCommit(ref) + if err != nil { + return nil, err + } + content, err := commit.GetFileContent(workflowPath, 1024*1024) + if err != nil { + return nil, err + } + return []byte(content), nil +} + +func buildWorkflowCallEventPayload(ctx context.Context, run *actions_model.ActionRun, inputs map[string]any) ([]byte, error) { + if err := run.LoadAttributes(ctx); err != nil { + return nil, err + } + + payload := &api.WorkflowCallPayload{ + Workflow: run.WorkflowID, + Ref: run.Ref, + Repository: convert.ToRepo(ctx, run.Repo, access_model.Permission{AccessMode: perm_model.AccessModeNone}), + Sender: convert.ToUserWithAccessMode(ctx, run.TriggerUser, perm_model.AccessModeNone), + Inputs: inputs, + } + eventPayload, err := payload.JSONPayload() + if err != nil { + return nil, fmt.Errorf("JSONPayload: %w", err) + } + return eventPayload, nil +} + +func ensureNoReusableWorkflowCallCycle(ctx context.Context, callerJob *actions_model.ActionRunJob, uses string) error { + visited := make(container.Set[string]) + visited.Add(uses) + + parentID := callerJob.ParentCallJobID + for parentID > 0 { + parentJob, err := actions_model.GetRunJobByRepoAndID(ctx, callerJob.RepoID, parentID) + if err != nil { + return err + } + if parentJob.ReusableWorkflowUses != "" { + if visited.Contains(parentJob.ReusableWorkflowUses) { + return fmt.Errorf("reusable workflow call cycle detected: %q", parentJob.ReusableWorkflowUses) + } + visited.Add(parentJob.ReusableWorkflowUses) + } + parentID = parentJob.ParentCallJobID + } + + return nil +} diff --git a/services/actions/run.go b/services/actions/run.go index e9fcdcaf43d60..5b4b0294a6455 100644 --- a/services/actions/run.go +++ b/services/actions/run.go @@ -10,6 +10,7 @@ import ( actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/actions/jobparser" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/util" notify_service "code.gitea.io/gitea/services/notify" @@ -41,7 +42,10 @@ func PrepareRunAndInsert(ctx context.Context, content []byte, run *actions_model } } - giteaCtx := GenerateGiteaContext(run, nil) + giteaCtx, err := GenerateGiteaContext(ctx, run, nil) + if err != nil { + return fmt.Errorf("GenerateGiteaContext: %w", err) + } jobs, err := jobparser.Parse(content, jobparser.WithVars(vars), jobparser.WithGitContext(giteaCtx.ToGitHubContext()), jobparser.WithInputs(inputsWithDefaults)) if err != nil { @@ -75,7 +79,9 @@ func PrepareRunAndInsert(ctx context.Context, content []byte, run *actions_model // InsertRun inserts a run // The title will be cut off at 255 characters if it's longer than 255 characters. func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobparser.SingleWorkflow, vars map[string]string, inputs map[string]any) error { - return db.WithTx(ctx, func(ctx context.Context) error { + var readyCallJobs []*actions_model.ActionRunJob + + if err := db.WithTx(ctx, func(ctx context.Context) error { index, err := db.GetNextResourceIndex(ctx, "action_run_index", run.RepoID) if err != nil { return err @@ -128,11 +134,16 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobpar RunsOn: job.RunsOn(), Status: util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting), } + // Parse workflow/job permissions (no clamping here) if perms := ExtractJobPermissionsFromWorkflow(v, job); perms != nil { runJob.TokenPermissions = perms } + if job.Uses != "" { + runJob.IsReusableCall = true + runJob.ReusableWorkflowUses = job.Uses + } // check job concurrency if job.RawConcurrency != nil { rawConcurrency, err := yaml.Marshal(job.RawConcurrency) @@ -159,12 +170,19 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobpar } } - hasWaitingJobs = hasWaitingJobs || runJob.Status == actions_model.StatusWaiting if err := db.Insert(ctx, runJob); err != nil { return err } runJobs = append(runJobs, runJob) + + if runJob.IsReusableCall { + if runJob.Status == actions_model.StatusWaiting { + readyCallJobs = append(readyCallJobs, runJob) + } + continue + } + hasWaitingJobs = hasWaitingJobs || runJob.Status == actions_model.StatusWaiting } run.Status = actions_model.AggregateJobStatus(runJobs) @@ -180,5 +198,15 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobpar } return nil - }) + }); err != nil { + return err + } + + for _, callJob := range readyCallJobs { + if err := ExpandReusableCallJob(ctx, callJob); err != nil { + log.Error("expand reusable workflow for run %d job %d: %v", callJob.RunID, callJob.ID, err) + } + } + + return nil } diff --git a/services/actions/task.go b/services/actions/task.go index a21b600998727..8031bb502c21e 100644 --- a/services/actions/task.go +++ b/services/actions/task.go @@ -78,7 +78,7 @@ func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv return fmt.Errorf("findTaskNeeds: %w", err) } - taskContext, err := generateTaskContext(t) + taskContext, err := generateTaskContext(ctx, t) if err != nil { return fmt.Errorf("generateTaskContext: %w", err) } @@ -107,13 +107,16 @@ func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv return task, true, nil } -func generateTaskContext(t *actions_model.ActionTask) (*structpb.Struct, error) { +func generateTaskContext(ctx context.Context, t *actions_model.ActionTask) (*structpb.Struct, error) { giteaRuntimeToken, err := CreateAuthorizationToken(t.ID, t.Job.RunID, t.JobID) if err != nil { return nil, err } - gitCtx := GenerateGiteaContext(t.Job.Run, t.Job) + gitCtx, err := GenerateGiteaContext(ctx, t.Job.Run, t.Job) + if err != nil { + return nil, err + } gitCtx["token"] = t.Token gitCtx["gitea_runtime_token"] = giteaRuntimeToken diff --git a/tests/integration/actions_reusable_workflow_test.go b/tests/integration/actions_reusable_workflow_test.go new file mode 100644 index 0000000000000..9136d9a6cb95e --- /dev/null +++ b/tests/integration/actions_reusable_workflow_test.go @@ -0,0 +1,327 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "testing" + + actions_model "code.gitea.io/gitea/models/actions" + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/json" + api "code.gitea.io/gitea/modules/structs" + + runnerv1 "code.gitea.io/actions-proto-go/runner/v1" + "github.com/stretchr/testify/assert" +) + +func TestJobUsesReusableWorkflow(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + user2Session := loginUser(t, user2.Name) + user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + + apiRepo := createActionsTestRepo(t, user2Token, "workflow-call-test", false) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID}) + + defaultRunner := newMockRunner() + defaultRunner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-default-runner", []string{"ubuntu-latest"}, false) + customRunner := newMockRunner() + customRunner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-custom-runner", []string{"custom-os"}, false) + + // add a variable for test + req := NewRequestWithJSON(t, "POST", + fmt.Sprintf("/api/v1/repos/%s/%s/actions/variables/myvar", repo.OwnerName, repo.Name), &api.CreateVariableOption{ + Value: "abc123", + }). + AddTokenAuth(user2Token) + MakeRequest(t, req, http.StatusCreated) + // add a secret for test + req = NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets/mysecret", repo.OwnerName, repo.Name), api.CreateOrUpdateSecretOption{ + Data: "secRET-t0Ken", + }).AddTokenAuth(user2Token) + MakeRequest(t, req, http.StatusCreated) + + createRepoWorkflowFile(t, user2, repo, ".gitea/workflows/reusable1.yaml", + `name: Reusable1 +on: + workflow_call: + inputs: + str_input: + type: string + num_input: + type: number + bool_input: + type: boolean + parent_var: + type: string + needs_out: + type: string + secrets: + parent_token: + outputs: + r1_out: + value: ${{ jobs.reusable1_job2.outputs.r1j2_out }} + +jobs: + reusable1_job1: + runs-on: ubuntu-latest + steps: + - run: echo 'reusable1_job1' + + reusable1_job2: + needs: [reusable1_job1] + outputs: + r1j2_out: ${{ steps.gen_r1j2_output.outputs.out }} + runs-on: custom-os + steps: + - id: gen_r1j2_output + run: | + echo "out=r1j2_out_data" >> "$GITHUB_OUTPUT" +`) + + createRepoWorkflowFile(t, user2, repo, ".gitea/workflows/caller.yaml", + `name: Caller +on: + push: + paths: + - '.gitea/workflows/caller.yaml' +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + prepare_out: ${{ steps.gen_output.outputs.po }} + steps: + - id: gen_output + run: | + echo "po=prepared_data" >> "$GITHUB_OUTPUT" + + caller_job1: + needs: [prepare] + uses: './.gitea/workflows/reusable1.yaml' + with: + str_input: 'from caller job1' + num_input: ${{ 2.3e2 }} + bool_input: ${{ gitea.event_name == 'push' }} + parent_var: ${{ vars.myvar }} + needs_out: ${{ needs.prepare.outputs.prepare_out }} + secrets: + parent_token: ${{ secrets.mysecret }} + + caller_job2: + needs: [caller_job1] + runs-on: ubuntu-latest + steps: + - run: | + echo ${{ needs.caller_job1.outputs.r1_out }} +`) + + var ( + callerRunID int64 + callerJob1ID int64 + reusable1Job2ID int64 + callerJob2ID int64 + ) + + t.Run("Check initialized jobs", func(t *testing.T) { + assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID})) + callerRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID}) + callerRunID = callerRun.ID + assert.Equal(t, 3, unittest.GetCount(t, &actions_model.ActionRunJob{RunID: callerRun.ID})) + prepareJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: callerRun.ID, JobID: "prepare"}) + assert.Equal(t, actions_model.StatusWaiting, prepareJob.Status) + assert.False(t, prepareJob.IsReusableCall) + callerJob1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: callerRun.ID, JobID: "caller_job1"}) + assert.Equal(t, actions_model.StatusBlocked, callerJob1.Status) + assert.True(t, callerJob1.IsReusableCall) + callerJob1ID = callerJob1.ID + callerJob2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: callerRun.ID, JobID: "caller_job2"}) + callerJob2ID = callerJob2.ID + assert.Equal(t, actions_model.StatusBlocked, callerJob2.Status) + assert.False(t, callerJob2.IsReusableCall) + }) + + t.Run("First run", func(t *testing.T) { + prepareTask := defaultRunner.fetchTask(t) // for "prepare" job + _, prepareJob, _ := getTaskAndJobAndRunByTaskID(t, prepareTask.Id) + assert.Equal(t, "prepare", prepareJob.JobID) + defaultRunner.fetchNoTask(t) + defaultRunner.execTask(t, prepareTask, &mockTaskOutcome{ + result: runnerv1.Result_RESULT_SUCCESS, + outputs: map[string]string{ + "prepare_out": "prepared_data", + }, + }) + + reusable1Job1Task := defaultRunner.fetchTask(t) // for "reusable1_job1" job + _, reusable1Job1, _ := getTaskAndJobAndRunByTaskID(t, reusable1Job1Task.Id) + assert.Equal(t, "reusable1_job1", reusable1Job1.JobID) + assert.Equal(t, callerJob1ID, reusable1Job1.ParentCallJobID) + assert.Equal(t, callerJob1ID, reusable1Job1.RootCallJobID) + payload := getWorkflowCallPayloadFromTask(t, reusable1Job1Task) + if assert.Len(t, payload.Inputs, 5) { + assert.Equal(t, "from caller job1", payload.Inputs["str_input"]) + assert.EqualValues(t, 230, payload.Inputs["num_input"]) + assert.Equal(t, true, payload.Inputs["bool_input"]) + assert.Equal(t, "abc123", payload.Inputs["parent_var"]) + assert.Equal(t, "prepared_data", payload.Inputs["needs_out"]) + } + if assert.Len(t, reusable1Job1Task.Secrets, 3) { + assert.Contains(t, reusable1Job1Task.Secrets, "GITEA_TOKEN") + assert.Contains(t, reusable1Job1Task.Secrets, "GITHUB_TOKEN") + assert.Equal(t, "secRET-t0Ken", reusable1Job1Task.Secrets["parent_token"]) + } + customRunner.fetchNoTask(t) + defaultRunner.execTask(t, reusable1Job1Task, &mockTaskOutcome{ + result: runnerv1.Result_RESULT_SUCCESS, + }) + + reusable1Job2Task := customRunner.fetchTask(t) // for "reusable1_job2" job + _, reusable1Job2, _ := getTaskAndJobAndRunByTaskID(t, reusable1Job2Task.Id) + assert.Equal(t, "reusable1_job2", reusable1Job2.JobID) + reusable1Job2ID = reusable1Job2.ID + if assert.Len(t, reusable1Job2Task.Needs, 1) { + assert.Contains(t, reusable1Job2Task.Needs, "reusable1_job1") + assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, reusable1Job2Task.Needs["reusable1_job1"].Result) + } + customRunner.execTask(t, reusable1Job2Task, &mockTaskOutcome{ + result: runnerv1.Result_RESULT_SUCCESS, + outputs: map[string]string{ + "r1j2_out": "r1j2_out_data", + }, + }) + callerJob1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: callerJob1ID}) + assert.Equal(t, actions_model.StatusSuccess, callerJob1.Status) + + callerJob2Task := defaultRunner.fetchTask(t) // for "caller_job2" job + _, callerJob2, _ := getTaskAndJobAndRunByTaskID(t, callerJob2Task.Id) + assert.Equal(t, "caller_job2", callerJob2.JobID) + if assert.Len(t, callerJob2Task.Needs, 1) { + assert.Contains(t, callerJob2Task.Needs, "caller_job1") + assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, callerJob2Task.Needs["caller_job1"].Result) + if assert.Len(t, callerJob2Task.Needs["caller_job1"].Outputs, 1) { + assert.Equal(t, "r1j2_out_data", callerJob2Task.Needs["caller_job1"].Outputs["r1_out"]) + } + } + defaultRunner.execTask(t, callerJob2Task, &mockTaskOutcome{ + result: runnerv1.Result_RESULT_SUCCESS, + }) + callerRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: callerRunID}) + assert.Equal(t, actions_model.StatusSuccess, callerRun.Status) + }) + + t.Run("Rerun 'reusable1_job2'", func(t *testing.T) { + req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", repo.OwnerName, repo.Name, callerRunID, reusable1Job2ID)) + user2Session.MakeRequest(t, req, http.StatusOK) + + callerRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: callerRunID}) + assert.Equal(t, actions_model.StatusWaiting, callerRun.Status) + callerJob1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: callerJob1ID}) + assert.Equal(t, actions_model.StatusWaiting, callerJob1.Status) + callerJob2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: callerJob2ID}) + assert.Equal(t, actions_model.StatusBlocked, callerJob2.Status) + + defaultRunner.fetchNoTask(t) + reusable1Job2Task := customRunner.fetchTask(t) + _, reusable1Job2, _ := getTaskAndJobAndRunByTaskID(t, reusable1Job2Task.Id) + assert.Equal(t, "reusable1_job2", reusable1Job2.JobID) + assert.Equal(t, reusable1Job2ID, reusable1Job2.ID) + assert.Equal(t, actions_model.StatusRunning, reusable1Job2.Status) + callerRun = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: callerRunID}) + assert.Equal(t, actions_model.StatusRunning, callerRun.Status) + customRunner.execTask(t, reusable1Job2Task, &mockTaskOutcome{ + result: runnerv1.Result_RESULT_SUCCESS, + outputs: map[string]string{ + "r1j2_out": "r1j2_out_data_updated", + }, + }) + callerJob1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: callerJob1ID}) + assert.Equal(t, actions_model.StatusSuccess, callerJob1.Status) + + callerJob2Task := defaultRunner.fetchTask(t) + _, callerJob2, _ = getTaskAndJobAndRunByTaskID(t, callerJob2Task.Id) + assert.Equal(t, "caller_job2", callerJob2.JobID) + if assert.Len(t, callerJob2Task.Needs, 1) { + assert.Contains(t, callerJob2Task.Needs, "caller_job1") + assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, callerJob2Task.Needs["caller_job1"].Result) + if assert.Len(t, callerJob2Task.Needs["caller_job1"].Outputs, 1) { + assert.Equal(t, "r1j2_out_data_updated", callerJob2Task.Needs["caller_job1"].Outputs["r1_out"]) + } + } + defaultRunner.execTask(t, callerJob2Task, &mockTaskOutcome{ + result: runnerv1.Result_RESULT_SUCCESS, + }) + callerRun = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: callerRunID}) + assert.Equal(t, actions_model.StatusSuccess, callerRun.Status) + }) + + t.Run("Rerun 'caller_job1'", func(t *testing.T) { + req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", repo.OwnerName, repo.Name, callerRunID, callerJob1ID)) + user2Session.MakeRequest(t, req, http.StatusOK) + + callerRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: callerRunID}) + assert.Equal(t, actions_model.StatusWaiting, callerRun.Status) + callerJob2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: callerJob2ID}) + assert.Equal(t, actions_model.StatusBlocked, callerJob2.Status) + + reusable1Job1Task := defaultRunner.fetchTask(t) + _, reusable1Job1, _ := getTaskAndJobAndRunByTaskID(t, reusable1Job1Task.Id) + assert.Equal(t, "reusable1_job1", reusable1Job1.JobID) + assert.Equal(t, callerJob1ID, reusable1Job1.ParentCallJobID) + defaultRunner.execTask(t, reusable1Job1Task, &mockTaskOutcome{ + result: runnerv1.Result_RESULT_SUCCESS, + }) + + reusable1Job2Task := customRunner.fetchTask(t) + _, reusable1Job2, _ := getTaskAndJobAndRunByTaskID(t, reusable1Job2Task.Id) + assert.Equal(t, "reusable1_job2", reusable1Job2.JobID) + if assert.Len(t, reusable1Job2Task.Needs, 1) { + assert.Contains(t, reusable1Job2Task.Needs, "reusable1_job1") + assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, reusable1Job2Task.Needs["reusable1_job1"].Result) + } + customRunner.execTask(t, reusable1Job2Task, &mockTaskOutcome{ + result: runnerv1.Result_RESULT_SUCCESS, + outputs: map[string]string{ + "r1j2_out": "r1j2_out_data_rerun_caller_job1", + }, + }) + + callerJob2Task := defaultRunner.fetchTask(t) + _, callerJob2, _ = getTaskAndJobAndRunByTaskID(t, callerJob2Task.Id) + assert.Equal(t, "caller_job2", callerJob2.JobID) + if assert.Len(t, callerJob2Task.Needs, 1) { + assert.Contains(t, callerJob2Task.Needs, "caller_job1") + assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, callerJob2Task.Needs["caller_job1"].Result) + if assert.Len(t, callerJob2Task.Needs["caller_job1"].Outputs, 1) { + assert.Equal(t, "r1j2_out_data_rerun_caller_job1", callerJob2Task.Needs["caller_job1"].Outputs["r1_out"]) + } + } + defaultRunner.execTask(t, callerJob2Task, &mockTaskOutcome{ + result: runnerv1.Result_RESULT_SUCCESS, + }) + + callerRun = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: callerRunID}) + assert.Equal(t, actions_model.StatusSuccess, callerRun.Status) + }) + }) +} + +func createRepoWorkflowFile(t *testing.T, u *user_model.User, repo *repo_model.Repository, treePath, content string) { + token := getTokenForLoggedInUser(t, loginUser(t, u.Name), auth_model.AccessTokenScopeWriteRepository) + opts := getWorkflowCreateFileOptions(u, repo.DefaultBranch, "create "+treePath, content) + createWorkflowFile(t, token, repo.OwnerName, repo.Name, treePath, opts) +} + +func getWorkflowCallPayloadFromTask(t *testing.T, runnerTask *runnerv1.Task) *api.WorkflowCallPayload { + eventJSON, err := runnerTask.GetContext().Fields["event"].GetStructValue().MarshalJSON() + assert.NoError(t, err) + var payload api.WorkflowCallPayload + assert.NoError(t, json.Unmarshal(eventJSON, &payload)) + return &payload +} From b106b30929c0986c66559455c696a8a3d02fe5d4 Mon Sep 17 00:00:00 2001 From: Zettat123 Date: Sat, 21 Mar 2026 20:33:40 -0600 Subject: [PATCH 2/8] fix rerun --- routers/api/v1/repo/action.go | 6 +- routers/web/repo/actions/view.go | 13 +--- services/actions/rerun.go | 75 +++++++++++++------ services/actions/rerun_plan.go | 120 +++++++++++++++---------------- services/actions/rerun_test.go | 6 +- 5 files changed, 122 insertions(+), 98 deletions(-) diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 0c48f732abfb4..23781751d3565 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -1255,7 +1255,7 @@ func RerunWorkflowRun(ctx *context.APIContext) { return } - if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobs); err != nil { + if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobs, nil); err != nil { handleWorkflowRerunError(ctx, err) return } @@ -1306,7 +1306,7 @@ func RerunFailedWorkflowRun(ctx *context.APIContext) { return } - if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, actions_service.GetFailedRerunJobs(jobs)); err != nil { + if err := actions_service.RerunFailedWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobs); err != nil { handleWorkflowRerunError(ctx, err) return } @@ -1367,7 +1367,7 @@ func RerunWorkflowJob(ctx *context.APIContext) { } targetJob := jobs[jobIdx] - if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, actions_service.GetAllRerunJobs(targetJob, jobs)); err != nil { + if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobs, targetJob); err != nil { handleWorkflowRerunError(ctx, err) return } diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 90810a6d2513a..9b03d021f171c 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -473,14 +473,7 @@ func Rerun(ctx *context_module.Context) { return } - var jobsToRerun []*actions_model.ActionRunJob - if currentJob != nil { - jobsToRerun = actions_service.GetAllRerunJobs(currentJob, jobs) - } else { - jobsToRerun = jobs - } - - if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobsToRerun); err != nil { + if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobs, currentJob); err != nil { ctx.ServerError("RerunWorkflowRunJobs", err) return } @@ -498,8 +491,8 @@ func RerunFailed(ctx *context_module.Context) { return } - if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, actions_service.GetFailedRerunJobs(jobs)); err != nil { - ctx.ServerError("RerunWorkflowRunJobs", err) + if err := actions_service.RerunFailedWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobs); err != nil { + ctx.ServerError("RerunFailedWorkflowRunJobs", err) return } diff --git a/services/actions/rerun.go b/services/actions/rerun.go index 1596d9bfc5a7f..f52daba4f8ba7 100644 --- a/services/actions/rerun.go +++ b/services/actions/rerun.go @@ -129,41 +129,72 @@ func prepareRunRerun(ctx context.Context, repo *repo_model.Repository, run *acti return run.Status == actions_model.StatusBlocked, nil } +func validateRunRerunAllowed(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun) error { + if !run.Status.IsDone() { + return util.NewInvalidArgumentErrorf("this workflow run is not done") + } + + cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions) + cfg := cfgUnit.ActionsConfig() + if cfg.IsWorkflowDisabled(run.WorkflowID) { + return util.NewInvalidArgumentErrorf("workflow %s is disabled", run.WorkflowID) + } + + return nil +} + // RerunWorkflowRunJobs reruns the given jobs of a workflow run. -// jobsToRerun must include all jobs to be rerun (the target job and its transitively dependent jobs). -// A job is blocked (waiting for dependencies) if the run itself is blocked or if any of its -// needs are also being rerun. -func RerunWorkflowRunJobs(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, jobsToRerun []*actions_model.ActionRunJob) error { - if len(jobsToRerun) == 0 { +// If targetJob is nil, it reruns the whole workflow run. +// Otherwise, it reruns the selected job and its related jobs based on a scope-aware rerun plan. +func RerunWorkflowRunJobs(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob, targetJob *actions_model.ActionRunJob) error { + if len(jobs) == 0 { return nil } - isRunBlocked, err := prepareRunRerun(ctx, repo, run, jobsToRerun) + isRunBlocked, err := prepareRunRerun(ctx, repo, run, jobs) if err != nil { return err } - rerunJobIDs := make(container.Set[string]) - for _, j := range jobsToRerun { - rerunJobIDs.Add(j.JobID) + var targetJobs []*actions_model.ActionRunJob + var explicitTargetJobID int64 + if targetJob != nil { + targetJobs = []*actions_model.ActionRunJob{targetJob} + explicitTargetJobID = targetJob.ID } + plan := buildRerunPlan(jobs, targetJobs, explicitTargetJobID, isRunBlocked) + return executeRerunPlan(ctx, jobs, plan) +} - for _, job := range jobsToRerun { - shouldBlockJob := isRunBlocked - if !shouldBlockJob { - for _, need := range job.Needs { - if rerunJobIDs.Contains(need) { - shouldBlockJob = true - break - } - } - } - if err := rerunWorkflowJob(ctx, job, shouldBlockJob); err != nil { - return err +// RerunFailedWorkflowRunJobs reruns failed/cancelled jobs and their related jobs based on a scope-aware rerun plan. +func RerunFailedWorkflowRunJobs(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) error { + if len(jobs) == 0 { + return nil + } + + // If there are no failed/cancelled jobs, treat it as a no-op and do not reset the run. + // Still validate rerun is allowed to avoid surprising behavior differences between Web/API callers. + if err := validateRunRerunAllowed(ctx, repo, run); err != nil { + return err + } + + targetJobs := make([]*actions_model.ActionRunJob, 0) + for _, job := range jobs { + if job.Status == actions_model.StatusFailure || job.Status == actions_model.StatusCancelled { + targetJobs = append(targetJobs, job) } } + if len(targetJobs) == 0 { + return nil + } - return nil + isRunBlocked, err := prepareRunRerun(ctx, repo, run, jobs) + if err != nil { + return err + } + + plan := buildRerunPlan(jobs, targetJobs, 0, isRunBlocked) + return executeRerunPlan(ctx, jobs, plan) } func rerunWorkflowJob(ctx context.Context, job *actions_model.ActionRunJob, shouldBlock bool) error { diff --git a/services/actions/rerun_plan.go b/services/actions/rerun_plan.go index 95e2f53137b2b..b9ae0061b0f57 100644 --- a/services/actions/rerun_plan.go +++ b/services/actions/rerun_plan.go @@ -11,8 +11,8 @@ import ( "code.gitea.io/gitea/modules/util" ) -func buildRerunPlan(jobs []*actions_model.ActionRunJob, targetJob *actions_model.ActionRunJob, isRunBlocked bool) *rerunPlan { - builder := newRerunPlanBuilder(jobs, targetJob, isRunBlocked) +func buildRerunPlan(jobs, targetJobs []*actions_model.ActionRunJob, explicitTargetJobID int64, isRunBlocked bool) *rerunPlan { + builder := newRerunPlanBuilder(jobs, targetJobs, explicitTargetJobID, isRunBlocked) return builder.build() } @@ -42,76 +42,52 @@ type rerunPlan struct { } type rerunPlanBuilder struct { - targetJob *actions_model.ActionRunJob - isRunBlocked bool + targetJobs []*actions_model.ActionRunJob + explicitTargetJobID int64 + isRunBlocked bool // jobByID maps job database ID to job model for quick lookup. jobByID map[int64]*actions_model.ActionRunJob graph *rerunGraph - rerunIDs container.Set[int64] - callerSubtreeIDs container.Set[int64] - expandSubtreeCallers container.Set[int64] - ancestorCallerIDs container.Set[int64] + rerunIDs container.Set[int64] + callerSubtreeIDs container.Set[int64] + ancestorCallerIDs container.Set[int64] shouldBlockMemo map[int64]bool } -func newRerunPlanBuilder(jobs []*actions_model.ActionRunJob, targetJob *actions_model.ActionRunJob, isRunBlocked bool) *rerunPlanBuilder { +func newRerunPlanBuilder(jobs, targetJobs []*actions_model.ActionRunJob, explicitTargetJobID int64, isRunBlocked bool) *rerunPlanBuilder { jobByID := make(map[int64]*actions_model.ActionRunJob, len(jobs)) for _, job := range jobs { jobByID[job.ID] = job } return &rerunPlanBuilder{ - targetJob: targetJob, - isRunBlocked: isRunBlocked, - graph: newRerunGraph(jobs), - jobByID: jobByID, - rerunIDs: make(container.Set[int64]), - callerSubtreeIDs: make(container.Set[int64]), - expandSubtreeCallers: make(container.Set[int64]), - ancestorCallerIDs: make(container.Set[int64]), - shouldBlockMemo: make(map[int64]bool, len(jobs)), + targetJobs: targetJobs, + explicitTargetJobID: explicitTargetJobID, + isRunBlocked: isRunBlocked, + graph: newRerunGraph(jobs), + jobByID: jobByID, + rerunIDs: make(container.Set[int64]), + callerSubtreeIDs: make(container.Set[int64]), + ancestorCallerIDs: make(container.Set[int64]), + shouldBlockMemo: make(map[int64]bool, len(jobs)), } } -func (b *rerunPlanBuilder) build() *rerunPlan { - if b.targetJob == nil { - return b.buildWholeRun() - } - return b.buildSubsetByTarget() -} - -func (b *rerunPlanBuilder) buildWholeRun() *rerunPlan { - plan := &rerunPlan{rerunJobIDs: make(container.Set[int64]), shouldBlock: make(map[int64]bool)} - for _, job := range b.jobByID { - plan.rerunJobIDs.Add(job.ID) - b.rerunIDs.Add(job.ID) - } - for _, job := range b.jobByID { - plan.shouldBlock[job.ID] = b.shouldBlockByNeedsAndCaller(job.ID) - } - return plan -} - -func (b *rerunPlanBuilder) buildSubsetByTarget() *rerunPlan { - // 1) Always rerun the selected job and all of its downstream jobs within the same scope. - parentCallJobID := b.targetJob.ParentCallJobID - for id := range b.graph.collectDownstreamByParentCallJobID(parentCallJobID, b.targetJob.JobID) { +func (b *rerunPlanBuilder) addSelectionForSeed(targetJob *actions_model.ActionRunJob) { + // Always rerun the selected job and all of its downstream jobs within the same scope. + parentCallJobID := targetJob.ParentCallJobID + for id := range b.graph.collectDownstreamByParentCallJobID(parentCallJobID, targetJob.JobID) { b.rerunIDs.Add(id) } - // 2) If the selected job is a reusable workflow caller job, rerun its whole child job subtree. - if b.targetJob.IsReusableCall { - b.expandSubtreeCallers.Add(b.targetJob.ID) - } - - // 3) If the selected job is inside a reusable call, rerun all ancestor caller jobs (up to root) + // If the selected job is inside a reusable call, rerun all ancestor caller jobs (up to root) // and their downstream jobs. Ancestor caller jobs are not expanded to their sibling subtrees. - if b.targetJob.ParentCallJobID > 0 { - parentID := b.targetJob.ParentCallJobID + if targetJob.ParentCallJobID > 0 { + parentID := targetJob.ParentCallJobID for parentID > 0 { parentCaller := b.jobByID[parentID] if parentCaller == nil { @@ -129,20 +105,35 @@ func (b *rerunPlanBuilder) buildSubsetByTarget() *rerunPlan { parentID = parentCaller.ParentCallJobID } } +} + +func (b *rerunPlanBuilder) build() *rerunPlan { + // 1) Seed selection union. + if len(b.targetJobs) == 0 { + for id := range b.jobByID { + b.rerunIDs.Add(id) + } + } else { + for _, targetJob := range b.targetJobs { + b.addSelectionForSeed(targetJob) + } + } - // 4) Expand reusable call subtrees for caller jobs that are part of this rerun selection, + // 2) Expand reusable call subtrees for caller jobs that are part of this rerun selection, // except for ancestor callers (their siblings should not be rerun). + expandSubtreeCallers := make(container.Set[int64]) for id := range b.rerunIDs { job := b.jobByID[id] if job == nil { continue } if job.IsReusableCall && !b.ancestorCallerIDs.Contains(job.ID) { - b.expandSubtreeCallers.Add(job.ID) + expandSubtreeCallers.Add(job.ID) } } - for callerID := range b.expandSubtreeCallers { + // 3) Expand caller subtrees. + for callerID := range expandSubtreeCallers { b.rerunIDs.Add(callerID) subtree := b.graph.collectCallerSubtreeJobs(callerID) for id := range subtree { @@ -151,9 +142,8 @@ func (b *rerunPlanBuilder) buildSubsetByTarget() *rerunPlan { } } - // 5) Compute initial statuses (Blocked vs Waiting) for all selected jobs and build the plan. + // 4) Compute initial statuses (Blocked vs Waiting) for all selected jobs and build the plan. plan := &rerunPlan{rerunJobIDs: make(container.Set[int64]), shouldBlock: make(map[int64]bool)} - unblockedTargetJobID := util.Iif(b.targetJob.IsReusableCall, 0, b.targetJob.ID) for id := range b.rerunIDs { job := b.jobByID[id] @@ -161,18 +151,28 @@ func (b *rerunPlanBuilder) buildSubsetByTarget() *rerunPlan { continue } - shouldBlock := true - if job.IsReusableCall && b.ancestorCallerIDs.Contains(job.ID) { - shouldBlock = b.isRunBlocked - } else if job.ID == unblockedTargetJobID { + shouldBlock := b.shouldBlockByNeedsAndCaller(job.ID) + + if b.explicitTargetJobID > 0 { + // "Explicit rerun" semantics: rerun the target job and its related jobs, but block everything else by default. + shouldBlock = true + if job.IsReusableCall && b.ancestorCallerIDs.Contains(job.ID) { + shouldBlock = b.isRunBlocked + } else if job.ID == b.explicitTargetJobID { + shouldBlock = util.Iif(job.IsReusableCall, true, b.isRunBlocked) + } else if b.callerSubtreeIDs.Contains(job.ID) { + shouldBlock = b.shouldBlockByNeedsAndCaller(job.ID) + } + } else if job.IsReusableCall && b.ancestorCallerIDs.Contains(job.ID) { + // Ancestor caller jobs are rerun for status propagation/downstream selection, but their sibling subtrees + // should not be rerun. Unblock them unless the run is blocked by concurrency. shouldBlock = b.isRunBlocked - } else if b.callerSubtreeIDs.Contains(job.ID) { - shouldBlock = b.shouldBlockByNeedsAndCaller(job.ID) } plan.rerunJobIDs.Add(job.ID) plan.shouldBlock[job.ID] = shouldBlock } + return plan } diff --git a/services/actions/rerun_test.go b/services/actions/rerun_test.go index 3b4dc5483f424..017cbe6b93942 100644 --- a/services/actions/rerun_test.go +++ b/services/actions/rerun_test.go @@ -129,16 +129,16 @@ func TestRerunValidation(t *testing.T) { jobs := []*actions_model.ActionRunJob{ {ID: 1, JobID: "job1"}, } - err := RerunWorkflowRunJobs(context.Background(), nil, runningRun, jobs) + err := RerunWorkflowRunJobs(context.Background(), nil, runningRun, jobs, nil) require.Error(t, err) assert.ErrorIs(t, err, util.ErrInvalidArgument) }) - t.Run("RerunWorkflowRunJobs rejects a non-done run when failed jobs exist", func(t *testing.T) { + t.Run("RerunFailedWorkflowRunJobs rejects a non-done run", func(t *testing.T) { jobs := []*actions_model.ActionRunJob{ {ID: 1, JobID: "job1", Status: actions_model.StatusFailure}, } - err := RerunWorkflowRunJobs(context.Background(), nil, runningRun, GetFailedRerunJobs(jobs)) + err := RerunFailedWorkflowRunJobs(context.Background(), nil, runningRun, jobs) require.Error(t, err) assert.ErrorIs(t, err, util.ErrInvalidArgument) }) From 21f69d098ecae3016102f336db15002a2f0e66d5 Mon Sep 17 00:00:00 2001 From: Zettat123 Date: Sat, 21 Mar 2026 22:51:29 -0600 Subject: [PATCH 3/8] add test --- .../actions_reusable_workflow_test.go | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/tests/integration/actions_reusable_workflow_test.go b/tests/integration/actions_reusable_workflow_test.go index 9136d9a6cb95e..5ff66c2064d3c 100644 --- a/tests/integration/actions_reusable_workflow_test.go +++ b/tests/integration/actions_reusable_workflow_test.go @@ -124,6 +124,7 @@ jobs: var ( callerRunID int64 + prepareJobID int64 callerJob1ID int64 reusable1Job2ID int64 callerJob2ID int64 @@ -137,6 +138,7 @@ jobs: prepareJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: callerRun.ID, JobID: "prepare"}) assert.Equal(t, actions_model.StatusWaiting, prepareJob.Status) assert.False(t, prepareJob.IsReusableCall) + prepareJobID = prepareJob.ID callerJob1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: callerRun.ID, JobID: "caller_job1"}) assert.Equal(t, actions_model.StatusBlocked, callerJob1.Status) assert.True(t, callerJob1.IsReusableCall) @@ -309,6 +311,80 @@ jobs: callerRun = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: callerRunID}) assert.Equal(t, actions_model.StatusSuccess, callerRun.Status) }) + + t.Run("Rerun 'prepare' job and Rerun 'Caller' run", func(t *testing.T) { + testCases := []struct { + name string + rerunURL string + }{ + {"prepare_job", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", repo.OwnerName, repo.Name, callerRunID, prepareJobID)}, + {"caller_run", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", repo.OwnerName, repo.Name, callerRunID)}, + } + + for _, tc := range testCases { + t.Run("Rerun "+tc.name, func(t *testing.T) { + req = NewRequest(t, "POST", tc.rerunURL) + user2Session.MakeRequest(t, req, http.StatusOK) + + callerRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: callerRunID}) + assert.Equal(t, actions_model.StatusWaiting, callerRun.Status) + prepareJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: prepareJobID}) + assert.Equal(t, actions_model.StatusWaiting, prepareJob.Status) + callerJob1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: callerJob1ID}) + assert.Equal(t, actions_model.StatusBlocked, callerJob1.Status) + callerJob2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: callerJob2ID}) + assert.Equal(t, actions_model.StatusBlocked, callerJob2.Status) + + prepareTask := defaultRunner.fetchTask(t) + _, prepareJob, _ = getTaskAndJobAndRunByTaskID(t, prepareTask.Id) + assert.Equal(t, "prepare", prepareJob.JobID) + defaultRunner.execTask(t, prepareTask, &mockTaskOutcome{ + result: runnerv1.Result_RESULT_SUCCESS, + outputs: map[string]string{ + "prepare_out": "prepared_data_updated", + }, + }) + + reusable1Job1Task := defaultRunner.fetchTask(t) + _, reusable1Job1, _ := getTaskAndJobAndRunByTaskID(t, reusable1Job1Task.Id) + assert.Equal(t, "reusable1_job1", reusable1Job1.JobID) + payload := getWorkflowCallPayloadFromTask(t, reusable1Job1Task) + if assert.Len(t, payload.Inputs, 5) { + assert.Equal(t, "prepared_data_updated", payload.Inputs["needs_out"]) + } + defaultRunner.execTask(t, reusable1Job1Task, &mockTaskOutcome{ + result: runnerv1.Result_RESULT_SUCCESS, + }) + + reusable1Job2Task := customRunner.fetchTask(t) + _, reusable1Job2, _ := getTaskAndJobAndRunByTaskID(t, reusable1Job2Task.Id) + assert.Equal(t, "reusable1_job2", reusable1Job2.JobID) + customRunner.execTask(t, reusable1Job2Task, &mockTaskOutcome{ + result: runnerv1.Result_RESULT_SUCCESS, + outputs: map[string]string{ + "r1j2_out": "r1j2_out_data_rerun_prepare", + }, + }) + + callerJob2Task := defaultRunner.fetchTask(t) + _, callerJob2, _ = getTaskAndJobAndRunByTaskID(t, callerJob2Task.Id) + assert.Equal(t, "caller_job2", callerJob2.JobID) + if assert.Len(t, callerJob2Task.Needs, 1) { + assert.Contains(t, callerJob2Task.Needs, "caller_job1") + assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, callerJob2Task.Needs["caller_job1"].Result) + if assert.Len(t, callerJob2Task.Needs["caller_job1"].Outputs, 1) { + assert.Equal(t, "r1j2_out_data_rerun_prepare", callerJob2Task.Needs["caller_job1"].Outputs["r1_out"]) + } + } + defaultRunner.execTask(t, callerJob2Task, &mockTaskOutcome{ + result: runnerv1.Result_RESULT_SUCCESS, + }) + + callerRun = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: callerRunID}) + assert.Equal(t, actions_model.StatusSuccess, callerRun.Status) + }) + } + }) }) } From 5f709d053b96f58a3927120acd90ecceb73a0570 Mon Sep 17 00:00:00 2001 From: Zettat123 Date: Wed, 25 Mar 2026 14:34:04 -0600 Subject: [PATCH 4/8] support nested secrets --- models/secret/secret.go | 83 +++++++++++++++++++++++++---------------- 1 file changed, 51 insertions(+), 32 deletions(-) diff --git a/models/secret/secret.go b/models/secret/secret.go index c2f3a247181e0..19fc661d5dc48 100644 --- a/models/secret/secret.go +++ b/models/secret/secret.go @@ -153,16 +153,16 @@ func UpdateSecret(ctx context.Context, secretID int64, data, description string) } func GetSecretsOfTask(ctx context.Context, task *actions_model.ActionTask) (map[string]string, error) { - secrets := map[string]string{} + baseSecrets := map[string]string{} - secrets["GITHUB_TOKEN"] = task.Token - secrets["GITEA_TOKEN"] = task.Token + baseSecrets["GITHUB_TOKEN"] = task.Token + baseSecrets["GITEA_TOKEN"] = task.Token if task.Job.Run.IsForkPullRequest && task.Job.Run.TriggerEvent != actions_module.GithubEventPullRequestTarget { // ignore secrets for fork pull request, except GITHUB_TOKEN and GITEA_TOKEN which are automatically generated. // for the tasks triggered by pull_request_target event, they could access the secrets because they will run in the context of the base branch // see the documentation: https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target - return secrets, nil + return baseSecrets, nil } ownerSecrets, err := db.Find[Secret](ctx, FindSecretsOptions{OwnerID: task.Job.Run.Repo.OwnerID}) @@ -182,36 +182,10 @@ func GetSecretsOfTask(ctx context.Context, task *actions_model.ActionTask) (map[ log.Error("Unable to decrypt Actions secret %v %q, maybe SECRET_KEY is wrong: %v", secret.ID, secret.Name, err) continue } - secrets[secret.Name] = v + baseSecrets[secret.Name] = v } - if task.Job.ParentCallJobID > 0 { - callerJob, err := actions_model.GetRunJobByRepoAndID(ctx, task.Job.RepoID, task.Job.ParentCallJobID) - if err != nil { - return nil, fmt.Errorf("GetRunJobByRepoAndID: %w", err) - } - if !callerJob.CallSecretsInherit { - // For reusable workflow child jobs, only expose explicitly mapped secrets, plus tokens. - filteredSecrets := map[string]string{} - filteredSecrets["GITHUB_TOKEN"] = secrets["GITHUB_TOKEN"] - filteredSecrets["GITEA_TOKEN"] = secrets["GITEA_TOKEN"] - - if callerJob.CallSecretNames != "" { - var mapping map[string]string - if err := json.Unmarshal([]byte(callerJob.CallSecretNames), &mapping); err != nil { - return nil, fmt.Errorf("unmarshal reusable workflow call secrets mapping for caller job %d: %w", callerJob.ID, err) - } - for alias, sourceName := range mapping { - if v, ok := secrets[strings.ToUpper(sourceName)]; ok { - filteredSecrets[alias] = v - } - } - } - secrets = filteredSecrets - } - } - - return secrets, nil + return getScopedSecretsForJob(ctx, task.Job, baseSecrets) } func CountWrongRepoLevelSecrets(ctx context.Context) (int64, error) { @@ -220,6 +194,51 @@ func CountWrongRepoLevelSecrets(ctx context.Context) (int64, error) { return result, err } +func getScopedSecretsForJob(ctx context.Context, job *actions_model.ActionRunJob, baseSecrets map[string]string) (map[string]string, error) { + if job.ParentCallJobID == 0 { + return baseSecrets, nil + } + + callerJob, err := actions_model.GetRunJobByRepoAndID(ctx, job.RepoID, job.ParentCallJobID) + if err != nil { + return nil, fmt.Errorf("GetRunJobByRepoAndID: %w", err) + } + + callerSecrets, err := getScopedSecretsForJob(ctx, callerJob, baseSecrets) + if err != nil { + return nil, err + } + if callerJob.CallSecretsInherit { + return callerSecrets, nil + } + + // For reusable workflow child jobs, only expose explicitly mapped secrets, plus tokens. + filteredSecrets := map[string]string{ + "GITHUB_TOKEN": baseSecrets["GITHUB_TOKEN"], + "GITEA_TOKEN": baseSecrets["GITEA_TOKEN"], + } + + if callerJob.CallSecretNames == "" { + return filteredSecrets, nil + } + + var mapping map[string]string + if err := json.Unmarshal([]byte(callerJob.CallSecretNames), &mapping); err != nil { + return nil, fmt.Errorf("unmarshal reusable workflow call secrets mapping for caller job %d: %w", callerJob.ID, err) + } + for alias, sourceName := range mapping { + if v, ok := callerSecrets[sourceName]; ok { + filteredSecrets[alias] = v + continue + } + if v, ok := callerSecrets[strings.ToUpper(sourceName)]; ok { + filteredSecrets[alias] = v + } + } + + return filteredSecrets, nil +} + func UpdateWrongRepoLevelSecrets(ctx context.Context) (int64, error) { result, err := db.GetEngine(ctx).Exec("UPDATE `secret` SET `owner_id` = 0 WHERE `repo_id` > 0 AND `owner_id` > 0") if err != nil { From 7b731422f64f6860744edbba01c1fe70adbf8bda Mon Sep 17 00:00:00 2001 From: Zettat123 Date: Wed, 25 Mar 2026 17:13:25 -0600 Subject: [PATCH 5/8] fix inputs --- modules/actions/jobparser/model.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/modules/actions/jobparser/model.go b/modules/actions/jobparser/model.go index 796523b6539c3..b8fdfe0afc532 100644 --- a/modules/actions/jobparser/model.go +++ b/modules/actions/jobparser/model.go @@ -357,9 +357,18 @@ func EvaluateWorkflowCallInputs(workflow *model.Workflow, jobID string, job *Job case "string": inputsWithDefaults[k] = out case "boolean": - switch out.(type) { + switch v := out.(type) { case bool: - inputsWithDefaults[k] = out + inputsWithDefaults[k] = v + case string: + switch v { + case "true": + inputsWithDefaults[k] = true + case "false": + inputsWithDefaults[k] = false + default: + return nil, fmt.Errorf("workflow_call input %q expects boolean (%v), got %q", k, err, v) + } default: return nil, fmt.Errorf("workflow_call input %q expects boolean", k) } From f7958722b51e354a0258c48331a6214824d73f31 Mon Sep 17 00:00:00 2001 From: Zettat123 Date: Wed, 25 Mar 2026 18:15:04 -0600 Subject: [PATCH 6/8] update ui --- routers/web/repo/actions/view.go | 12 + web_src/js/components/ActionRunJobView.vue | 269 ++++++++++++++------- web_src/js/components/ActionRunView.ts | 41 ++++ web_src/js/components/RepoActionView.vue | 111 ++++++++- web_src/js/components/WorkflowGraph.vue | 88 ++++--- web_src/js/modules/gitea-actions.ts | 5 + 6 files changed, 386 insertions(+), 140 deletions(-) diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 9b03d021f171c..b794d93a99d5d 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -168,6 +168,12 @@ type ViewJob struct { CanRerun bool `json:"canRerun"` Duration string `json:"duration"` Needs []string `json:"needs,omitempty"` + + IsReusableCall bool `json:"isReusableCall"` + ReusableWorkflowUses string `json:"reusableWorkflowUses,omitempty"` + ParentCallJobID int64 `json:"parentCallJobID"` + RootCallJobID int64 `json:"rootCallJobID"` + CallDepth int `json:"callDepth"` } type ViewCommit struct { @@ -283,6 +289,12 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, CanRerun: resp.State.Run.CanRerun, Duration: v.Duration().String(), Needs: v.Needs, + + IsReusableCall: v.IsReusableCall, + ReusableWorkflowUses: v.ReusableWorkflowUses, + ParentCallJobID: v.ParentCallJobID, + RootCallJobID: v.RootCallJobID, + CallDepth: v.CallDepth, }) } diff --git a/web_src/js/components/ActionRunJobView.vue b/web_src/js/components/ActionRunJobView.vue index 9d8ee0dbde5a1..fd04a1c7d8722 100644 --- a/web_src/js/components/ActionRunJobView.vue +++ b/web_src/js/components/ActionRunJobView.vue @@ -1,16 +1,19 @@