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..19fc661d5dc48 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"
@@ -152,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})
@@ -181,10 +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
}
- return secrets, nil
+ return getScopedSecretsForJob(ctx, task.Job, baseSecrets)
}
func CountWrongRepoLevelSecrets(ctx context.Context) (int64, error) {
@@ -193,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 {
diff --git a/modules/actions/jobparser/model.go b/modules/actions/jobparser/model.go
index 7132c278e950b..b8fdfe0afc532 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,238 @@ 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 v := out.(type) {
+ case bool:
+ 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)
+ }
+ 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 +639,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/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..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,
})
}
@@ -473,14 +485,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 +503,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/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.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
new file mode 100644
index 0000000000000..b9ae0061b0f57
--- /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, targetJobs []*actions_model.ActionRunJob, explicitTargetJobID int64, isRunBlocked bool) *rerunPlan {
+ builder := newRerunPlanBuilder(jobs, targetJobs, explicitTargetJobID, 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 {
+ 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]
+ ancestorCallerIDs container.Set[int64]
+
+ shouldBlockMemo map[int64]bool
+}
+
+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{
+ 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) 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)
+ }
+
+ // 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 targetJob.ParentCallJobID > 0 {
+ parentID := 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
+ }
+ }
+}
+
+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)
+ }
+ }
+
+ // 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) {
+ expandSubtreeCallers.Add(job.ID)
+ }
+ }
+
+ // 3) Expand caller subtrees.
+ for callerID := range expandSubtreeCallers {
+ b.rerunIDs.Add(callerID)
+ subtree := b.graph.collectCallerSubtreeJobs(callerID)
+ for id := range subtree {
+ b.rerunIDs.Add(id)
+ b.callerSubtreeIDs.Add(id)
+ }
+ }
+
+ // 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)}
+
+ for id := range b.rerunIDs {
+ job := b.jobByID[id]
+ if job == nil {
+ continue
+ }
+
+ 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
+ }
+
+ 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/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)
})
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..5ff66c2064d3c
--- /dev/null
+++ b/tests/integration/actions_reusable_workflow_test.go
@@ -0,0 +1,403 @@
+// 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
+ prepareJobID 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)
+ 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)
+ 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)
+ })
+
+ 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)
+ })
+ }
+ })
+ })
+}
+
+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
+}
diff --git a/web_src/js/components/ActionRunJobView.vue b/web_src/js/components/ActionRunJobView.vue
index 9d8ee0dbde5a1..60d1cda82346a 100644
--- a/web_src/js/components/ActionRunJobView.vue
+++ b/web_src/js/components/ActionRunJobView.vue
@@ -1,16 +1,19 @@
-