From 20f115fdac5fee56f5abf091f55382bbdac17f3b Mon Sep 17 00:00:00 2001 From: Earl Warren Date: Thu, 31 Jul 2025 05:37:12 +0000 Subject: [PATCH] feat: add the runner validate subcommand (#757) - features - [PR](https://code.forgejo.org/forgejo/runner/pulls/757): feat: the new `forgejo-runner validate` command can be used to verify if an action or a workflow is conformant with the expected schema. `forgejo-runner validate --repository https://example.com/my/repository` will validate all the workflows and actions a Git repository contains. Alternatively `forgejo-runner validate --path myaction/action.yml --action` or `forgejo-runner validate --path .forgejo/workflows/test.yml --workflow` can be used to validate a single file. It is recommended to use these commands to verify existing actions and workflows pass before upgrading to [Forgejo runner v8.0.0](https://code.forgejo.org/forgejo/runner/src/branch/main/RELEASE-NOTES.md#8-0-0) or above to not disrupt existing workflows. Reviewed-on: https://code.forgejo.org/forgejo/runner/pulls/757 Reviewed-by: Michael Kriese Co-authored-by: Earl Warren Co-committed-by: Earl Warren --- Makefile | 2 +- internal/app/cmd/cmd.go | 9 +- internal/app/cmd/testdata/validate/README.txt | 1 + .../app/cmd/testdata/validate/bad-action.yml | 67 ++++++ .../cmd/testdata/validate/bad-repository/HEAD | 1 + .../testdata/validate/bad-repository/config | 6 + .../validate/bad-repository/description | 1 + .../validate/bad-repository/info/exclude | 6 + .../4a/b480aa3a6da70e379c50fb30509cf8acc1bd8c | Bin 0 -> 51 bytes .../53/ce48939297c1445f1a7e2c4afb24d9e679c933 | Bin 0 -> 58 bytes .../6f/70f2754f4de737955b68705db6ccec1090e12d | Bin 0 -> 845 bytes .../71/48e33ec2da861625486f4ac5c72e2197445bbd | Bin 0 -> 89 bytes .../80/2c1a243fbadbedf725ae695d7a37be1748eb2d | Bin 0 -> 86 bytes .../99/a66b16c83472b94e5e275ae6bf85ba89a8e201 | Bin 0 -> 778 bytes .../validate/bad-repository/packed-refs | 2 + .../validate/bad-repository/refs/placeholder | 0 .../cmd/testdata/validate/bad-workflow.yml | 6 + .../app/cmd/testdata/validate/good-action.yml | 67 ++++++ .../testdata/validate/good-repository/HEAD | 1 + .../testdata/validate/good-repository/config | 6 + .../validate/good-repository/description | 1 + .../validate/good-repository/info/exclude | 6 + .../62/6b6b892539d8c81545a5af6bac1b9996335e4d | Bin 0 -> 76 bytes .../74/22baa3a822e909afe3c87eaa7646b12f43fdcb | Bin 0 -> 156 bytes .../80/2c1a243fbadbedf725ae695d7a37be1748eb2d | Bin 0 -> 86 bytes .../ba/e251227a079a7601f20554eb82b3cefcce51c9 | Bin 0 -> 51 bytes .../cb/66d230ae7d2c6ca2132b0996cffd8c74de48b1 | Bin 0 -> 853 bytes .../ce/2b2747739d1553c23a48e695732c358faccfaf | Bin 0 -> 52 bytes .../ef/dc13fcbbc43a196903b12847fca66d3c6b8d9d | Bin 0 -> 85 bytes .../f0/9a73e2ddef7b7834661e4c7b388a22f654f164 | Bin 0 -> 781 bytes .../f1/8ebc1e1151d0b8de9c296f1d8baf9c90fe3fa6 | Bin 0 -> 56 bytes .../f4/2c3901e0beb480edfd5d41670c1a1958d5b33c | Bin 0 -> 51 bytes .../validate/good-repository/packed-refs | 2 + .../validate/good-repository/refs/placeholder | 0 .../cmd/testdata/validate/good-workflow.yml | 6 + .../testdata/validate/make-repositories.sh | 56 +++++ internal/app/cmd/validate.go | 193 ++++++++++++++++++ internal/app/cmd/validate_test.go | 93 +++++++++ release-notes/757.md | 1 + testutils/file.go | 20 ++ 40 files changed, 545 insertions(+), 8 deletions(-) create mode 100644 internal/app/cmd/testdata/validate/README.txt create mode 100644 internal/app/cmd/testdata/validate/bad-action.yml create mode 100644 internal/app/cmd/testdata/validate/bad-repository/HEAD create mode 100644 internal/app/cmd/testdata/validate/bad-repository/config create mode 100644 internal/app/cmd/testdata/validate/bad-repository/description create mode 100644 internal/app/cmd/testdata/validate/bad-repository/info/exclude create mode 100644 internal/app/cmd/testdata/validate/bad-repository/objects/4a/b480aa3a6da70e379c50fb30509cf8acc1bd8c create mode 100644 internal/app/cmd/testdata/validate/bad-repository/objects/53/ce48939297c1445f1a7e2c4afb24d9e679c933 create mode 100644 internal/app/cmd/testdata/validate/bad-repository/objects/6f/70f2754f4de737955b68705db6ccec1090e12d create mode 100644 internal/app/cmd/testdata/validate/bad-repository/objects/71/48e33ec2da861625486f4ac5c72e2197445bbd create mode 100644 internal/app/cmd/testdata/validate/bad-repository/objects/80/2c1a243fbadbedf725ae695d7a37be1748eb2d create mode 100644 internal/app/cmd/testdata/validate/bad-repository/objects/99/a66b16c83472b94e5e275ae6bf85ba89a8e201 create mode 100644 internal/app/cmd/testdata/validate/bad-repository/packed-refs create mode 100644 internal/app/cmd/testdata/validate/bad-repository/refs/placeholder create mode 100644 internal/app/cmd/testdata/validate/bad-workflow.yml create mode 100644 internal/app/cmd/testdata/validate/good-action.yml create mode 100644 internal/app/cmd/testdata/validate/good-repository/HEAD create mode 100644 internal/app/cmd/testdata/validate/good-repository/config create mode 100644 internal/app/cmd/testdata/validate/good-repository/description create mode 100644 internal/app/cmd/testdata/validate/good-repository/info/exclude create mode 100644 internal/app/cmd/testdata/validate/good-repository/objects/62/6b6b892539d8c81545a5af6bac1b9996335e4d create mode 100644 internal/app/cmd/testdata/validate/good-repository/objects/74/22baa3a822e909afe3c87eaa7646b12f43fdcb create mode 100644 internal/app/cmd/testdata/validate/good-repository/objects/80/2c1a243fbadbedf725ae695d7a37be1748eb2d create mode 100644 internal/app/cmd/testdata/validate/good-repository/objects/ba/e251227a079a7601f20554eb82b3cefcce51c9 create mode 100644 internal/app/cmd/testdata/validate/good-repository/objects/cb/66d230ae7d2c6ca2132b0996cffd8c74de48b1 create mode 100644 internal/app/cmd/testdata/validate/good-repository/objects/ce/2b2747739d1553c23a48e695732c358faccfaf create mode 100644 internal/app/cmd/testdata/validate/good-repository/objects/ef/dc13fcbbc43a196903b12847fca66d3c6b8d9d create mode 100644 internal/app/cmd/testdata/validate/good-repository/objects/f0/9a73e2ddef7b7834661e4c7b388a22f654f164 create mode 100644 internal/app/cmd/testdata/validate/good-repository/objects/f1/8ebc1e1151d0b8de9c296f1d8baf9c90fe3fa6 create mode 100644 internal/app/cmd/testdata/validate/good-repository/objects/f4/2c3901e0beb480edfd5d41670c1a1958d5b33c create mode 100644 internal/app/cmd/testdata/validate/good-repository/packed-refs create mode 100644 internal/app/cmd/testdata/validate/good-repository/refs/placeholder create mode 100644 internal/app/cmd/testdata/validate/good-workflow.yml create mode 100755 internal/app/cmd/testdata/validate/make-repositories.sh create mode 100644 internal/app/cmd/validate.go create mode 100644 internal/app/cmd/validate_test.go create mode 100644 release-notes/757.md create mode 100644 testutils/file.go diff --git a/Makefile b/Makefile index f4cc57ff..50e12958 100644 --- a/Makefile +++ b/Makefile @@ -122,7 +122,7 @@ install: $(GOFILES) build: go-check $(EXECUTABLE) -$(EXECUTABLE): $(GOFILES) +$(EXECUTABLE): $(GOFILES) act/schema/action_schema.json act/schema/workflow_schema.json $(GO) build -v -tags 'netgo osusergo $(TAGS)' -ldflags '$(EXTLDFLAGS)-s -w $(LDFLAGS)' -o $@ .PHONY: deps-tools diff --git a/internal/app/cmd/cmd.go b/internal/app/cmd/cmd.go index 09efd70e..00b84301 100644 --- a/internal/app/cmd/cmd.go +++ b/internal/app/cmd/cmd.go @@ -15,7 +15,6 @@ import ( ) func Execute(ctx context.Context) { - // ./act_runner rootCmd := &cobra.Command{ Use: "forgejo-runner [event name to run]\nIf no event name passed, will default to \"on: push\"", Short: "Run Forgejo Actions locally by specifying the event name (e.g. `push`) or an action name directly.", @@ -26,7 +25,6 @@ func Execute(ctx context.Context) { configFile := "" rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "Config file path") - // ./act_runner register var regArgs registerArgs registerCmd := &cobra.Command{ Use: "register", @@ -43,7 +41,6 @@ func Execute(ctx context.Context) { rootCmd.AddCommand(createRunnerFileCmd(ctx, &configFile)) - // ./act_runner daemon daemonCmd := &cobra.Command{ Use: "daemon", Short: "Run as a runner daemon", @@ -52,7 +49,6 @@ func Execute(ctx context.Context) { } rootCmd.AddCommand(daemonCmd) - // ./act_runner job jobCmd := &cobra.Command{ Use: "one-job", Short: "Run only one job", @@ -61,10 +57,10 @@ func Execute(ctx context.Context) { } rootCmd.AddCommand(jobCmd) - // ./act_runner exec rootCmd.AddCommand(loadExecCmd(ctx)) - // ./act_runner config + rootCmd.AddCommand(loadValidateCmd(ctx)) + rootCmd.AddCommand(&cobra.Command{ Use: "generate-config", Short: "Generate an example config file", @@ -74,7 +70,6 @@ func Execute(ctx context.Context) { }, }) - // ./act_runner cache-server var cacheArgs cacheServerArgs cacheCmd := &cobra.Command{ Use: "cache-server", diff --git a/internal/app/cmd/testdata/validate/README.txt b/internal/app/cmd/testdata/validate/README.txt new file mode 100644 index 00000000..2e4ffc2f --- /dev/null +++ b/internal/app/cmd/testdata/validate/README.txt @@ -0,0 +1 @@ +Use make-repositories.sh to change good-repository and bad-repository diff --git a/internal/app/cmd/testdata/validate/bad-action.yml b/internal/app/cmd/testdata/validate/bad-action.yml new file mode 100644 index 00000000..6f70f275 --- /dev/null +++ b/internal/app/cmd/testdata/validate/bad-action.yml @@ -0,0 +1,67 @@ +name: 'Forgejo release download and upload' +author: 'Forgejo authors' +description: | + Upload or download the assets of a release to a Forgejo instance. +inputs: + badinput: scalarinsteadofmap + url: + description: 'URL of the Forgejo instance' + default: '${{ env.FORGEJO_SERVER_URL }}' + repo: + description: 'owner/project relative to the URL' + default: '${{ forge.repository }}' + tag: + description: 'Tag of the release' + default: '${{ forge.ref_name }}' + title: + description: 'Title of the release (defaults to tag)' + sha: + description: 'SHA of the release' + default: '${{ forge.sha }}' + token: + description: 'Forgejo application token' + default: '${{ forge.token }}' + release-dir: + description: 'Directory in whichs release assets are uploaded or downloaded' + required: true + release-notes: + description: 'Release notes' + direction: + description: 'Can either be `download` or `upload`' + required: true + gpg-private-key: + description: 'GPG Private Key to sign the release artifacts' + gpg-passphrase: + description: 'Passphrase of the GPG Private Key' + download-retry: + description: 'Number of times to retry if the release is not ready (default 1)' + download-latest: + description: 'Download the latest release' + default: false + verbose: + description: 'Increase the verbosity level' + default: false + override: + description: 'Override an existing release by the same `{tag}`' + default: false + prerelease: + description: 'Mark Release as Pre-Release' + default: false + release-notes-assistant: + description: 'Generate release notes with Release Notes Assistant' + default: false + hide-archive-link: + description: 'Hide the archive links' + default: false + +runs: + using: "composite" + steps: + - if: ${{ inputs.release-notes-assistant }} + uses: https://data.forgejo.org/actions/cache@v4 + with: + key: rna-${{ inputs.repo }} + path: ${{ forge.action_path }}/rna + + - run: echo "${{ forge.action_path }}" >> $FORGEJO_PATH + shell: bash diff --git a/internal/app/cmd/testdata/validate/bad-repository/HEAD b/internal/app/cmd/testdata/validate/bad-repository/HEAD new file mode 100644 index 00000000..cb089cd8 --- /dev/null +++ b/internal/app/cmd/testdata/validate/bad-repository/HEAD @@ -0,0 +1 @@ +ref: refs/heads/master diff --git a/internal/app/cmd/testdata/validate/bad-repository/config b/internal/app/cmd/testdata/validate/bad-repository/config new file mode 100644 index 00000000..ee11fe6a --- /dev/null +++ b/internal/app/cmd/testdata/validate/bad-repository/config @@ -0,0 +1,6 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = true +[remote "origin"] + url = /tmp/tmp.jyjE6tqWGS/bad diff --git a/internal/app/cmd/testdata/validate/bad-repository/description b/internal/app/cmd/testdata/validate/bad-repository/description new file mode 100644 index 00000000..498b267a --- /dev/null +++ b/internal/app/cmd/testdata/validate/bad-repository/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/internal/app/cmd/testdata/validate/bad-repository/info/exclude b/internal/app/cmd/testdata/validate/bad-repository/info/exclude new file mode 100644 index 00000000..a5196d1b --- /dev/null +++ b/internal/app/cmd/testdata/validate/bad-repository/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/internal/app/cmd/testdata/validate/bad-repository/objects/4a/b480aa3a6da70e379c50fb30509cf8acc1bd8c b/internal/app/cmd/testdata/validate/bad-repository/objects/4a/b480aa3a6da70e379c50fb30509cf8acc1bd8c new file mode 100644 index 0000000000000000000000000000000000000000..e51ad7cf741ffb3025c9d600b97f7bb6119a8e7d GIT binary patch literal 51 zcmV-30L=e*0V^p=O;s>9V=yrQ0)_JYqU^Ms{PJRk;By|6Crv--5-(M!4WH2-^Ff%bxD93L}k}3V~ z?8Csw#ueo-{Alcwt_)bJ2^~RcZgg$11k@$8jr<%A zu=SO(52Cw2+;C8mE39g~GCGGp1AvQg1&qB%@)ZF(N8W*10B%!x!ykQqrJYAz&}g7^ z(|VUP)*Q?5k%KF+Mk_IdrCF@7;p5iU5(V&x#PDMJRq{yIUGQNHUtn8%F6Zg(Es$Q1 zKAulcjz6DYem|c6Jf2<(!rh$&TWU-y2RDiAxUuGn3NHnrS8D>UNYe8CRHlWrdL)oe zd1E&*hsR|q#SdKeWyIP14|8!T^Xw6ouPKQs2Ro?ntfbHn7wF^ix#V#brU1U59_?J; zPl_RWKIWQq3a+0IHchPx?xE34f`j*?hU4vYf@ZT)*%bN*WqCT%Xrtkw6ZffQ@T z!kl2%n{OsuW}W+HdzdVnW!6}=Mo-x_ZBpnblM|S9A@GGZGGC{b++x;f?{~CbEwJ$H zWq^Rmt!XODyC4NQ*`D?~*hvyfjyoi?3Gs%}O=k8`^8= zC5evJKw13ULpg$FLBAPKo0*|bcK3vM33BhZtiv6+VLfkio{3N3sE65;wBjCRXp4%M zXjUtIovP)OgF;tgAV^@4t#~l5^lpv=+v?!X)^S&JI4I02Y+X9w8S^93MDJ!SxE$me z=w2P(&JX;3Up_CK;9h=o9Qz(B?-^n|F45yC?DZ=%;`KgeC$ZJtxWJ;Kck9>jmh_~n zNw$jE6|BbWQR2qjX8~xCZvAW-u`T0tLOa{G#;Ktb7KqEe)%za+mX&&k6W#5HRP*nuB|L v3=Is-OiUCKlS?x5^Ykina~SdqK9&0WJ~y8loly|G?aUj22@iDvvLGO1i7X|o literal 0 HcmV?d00001 diff --git a/internal/app/cmd/testdata/validate/bad-repository/objects/80/2c1a243fbadbedf725ae695d7a37be1748eb2d b/internal/app/cmd/testdata/validate/bad-repository/objects/80/2c1a243fbadbedf725ae695d7a37be1748eb2d new file mode 100644 index 0000000000000000000000000000000000000000..84cde071b2cb8756a9f1a314edde7c25fad884b2 GIT binary patch literal 86 zcmV-c0IC0Y0ZYosPg1ZjWysI7Qiv`nEzXGL%F0hFw&GGyC`m0Y0nrKyMWvZ}#kwFR sDf!9SsYPJH;*!(?usVLp*wPoFz@)+`mYQG*5#T*hn>Gob+cXg@R z8mhfZmup7Z9RKB?hEhKrHHL}9bTFO_soF3Z%9~z9T3R!g`JLa9)}he~1jT~!#@wRk z`<`9r!js&m>QewZx%Y@NG6w45X87Y2zQn+~rk*9)vK`H%5LreB`xcuq8JvMOQn8nG zAMXRWvOT6i%7;dLpiNlQnZ%Ztf@kb<+IRdA>n~4gkIa0o)AvP8>ML%t>C(bTg(Qh@ z*LV9;8wzlIxZK$+HEGy4aM$~Wp3&C2V14y^cRA3E`G~FiTaD)hrzfupZ@3lCzCrrU==*{Z=j2fI`Bkkt z_0zDt{Fn}1`M!1Ay~<8eju3a&P6vEJF=kZxs)g6;$Gl|lnSOs);6Y+s!^u`a!JRWL6z_>T-UQ3AQanMvIAnmI)UWV4NqO zC%tJ>%vETKkeEd~Wk{q7>qkxRYdo$~7AUAGL%uS?*uNMpQ=JX^^ZPg&eM@g-$9hy-6nbN$dn%y-O<0nx%_3Qh zN~G8%?N_xSeyiE_iO$li9n3uu;#3Tqx)_bXS+74S@N?Q>miosT%Qyea80upJsneGF I4GRlNX+6S@XaE2J literal 0 HcmV?d00001 diff --git a/internal/app/cmd/testdata/validate/bad-repository/packed-refs b/internal/app/cmd/testdata/validate/bad-repository/packed-refs new file mode 100644 index 00000000..85e37809 --- /dev/null +++ b/internal/app/cmd/testdata/validate/bad-repository/packed-refs @@ -0,0 +1,2 @@ +# pack-refs with: peeled fully-peeled sorted +99a66b16c83472b94e5e275ae6bf85ba89a8e201 refs/heads/master diff --git a/internal/app/cmd/testdata/validate/bad-repository/refs/placeholder b/internal/app/cmd/testdata/validate/bad-repository/refs/placeholder new file mode 100644 index 00000000..e69de29b diff --git a/internal/app/cmd/testdata/validate/bad-workflow.yml b/internal/app/cmd/testdata/validate/bad-workflow.yml new file mode 100644 index 00000000..802c1a24 --- /dev/null +++ b/internal/app/cmd/testdata/validate/bad-workflow.yml @@ -0,0 +1,6 @@ +on: [push] +jobs: + test: + ruins-on: docker + steps: + - run: echo All good! diff --git a/internal/app/cmd/testdata/validate/good-action.yml b/internal/app/cmd/testdata/validate/good-action.yml new file mode 100644 index 00000000..cb66d230 --- /dev/null +++ b/internal/app/cmd/testdata/validate/good-action.yml @@ -0,0 +1,67 @@ +# SPDX-License-Identifier: MIT +name: 'Forgejo release download and upload' +author: 'Forgejo authors' +description: | + Upload or download the assets of a release to a Forgejo instance. +inputs: + url: + description: 'URL of the Forgejo instance' + default: '${{ env.FORGEJO_SERVER_URL }}' + repo: + description: 'owner/project relative to the URL' + default: '${{ forge.repository }}' + tag: + description: 'Tag of the release' + default: '${{ forge.ref_name }}' + title: + description: 'Title of the release (defaults to tag)' + sha: + description: 'SHA of the release' + default: '${{ forge.sha }}' + token: + description: 'Forgejo application token' + default: '${{ forge.token }}' + release-dir: + description: 'Directory in whichs release assets are uploaded or downloaded' + required: true + release-notes: + description: 'Release notes' + direction: + description: 'Can either be `download` or `upload`' + required: true + gpg-private-key: + description: 'GPG Private Key to sign the release artifacts' + gpg-passphrase: + description: 'Passphrase of the GPG Private Key' + download-retry: + description: 'Number of times to retry if the release is not ready (default 1)' + download-latest: + description: 'Download the latest release' + default: false + verbose: + description: 'Increase the verbosity level' + default: false + override: + description: 'Override an existing release by the same `{tag}`' + default: false + prerelease: + description: 'Mark Release as Pre-Release' + default: false + release-notes-assistant: + description: 'Generate release notes with Release Notes Assistant' + default: false + hide-archive-link: + description: 'Hide the archive links' + default: false + +runs: + using: "composite" + steps: + - if: ${{ inputs.release-notes-assistant }} + uses: https://data.forgejo.org/actions/cache@v4 + with: + key: rna-${{ inputs.repo }} + path: ${{ forge.action_path }}/rna + + - run: echo "${{ forge.action_path }}" >> $FORGEJO_PATH + shell: bash diff --git a/internal/app/cmd/testdata/validate/good-repository/HEAD b/internal/app/cmd/testdata/validate/good-repository/HEAD new file mode 100644 index 00000000..cb089cd8 --- /dev/null +++ b/internal/app/cmd/testdata/validate/good-repository/HEAD @@ -0,0 +1 @@ +ref: refs/heads/master diff --git a/internal/app/cmd/testdata/validate/good-repository/config b/internal/app/cmd/testdata/validate/good-repository/config new file mode 100644 index 00000000..029b9911 --- /dev/null +++ b/internal/app/cmd/testdata/validate/good-repository/config @@ -0,0 +1,6 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = true +[remote "origin"] + url = /tmp/tmp.jyjE6tqWGS/good diff --git a/internal/app/cmd/testdata/validate/good-repository/description b/internal/app/cmd/testdata/validate/good-repository/description new file mode 100644 index 00000000..498b267a --- /dev/null +++ b/internal/app/cmd/testdata/validate/good-repository/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/internal/app/cmd/testdata/validate/good-repository/info/exclude b/internal/app/cmd/testdata/validate/good-repository/info/exclude new file mode 100644 index 00000000..a5196d1b --- /dev/null +++ b/internal/app/cmd/testdata/validate/good-repository/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/internal/app/cmd/testdata/validate/good-repository/objects/62/6b6b892539d8c81545a5af6bac1b9996335e4d b/internal/app/cmd/testdata/validate/good-repository/objects/62/6b6b892539d8c81545a5af6bac1b9996335e4d new file mode 100644 index 0000000000000000000000000000000000000000..caa1d2e6d68d93bc0bbbccdb7b247f576b16d8b0 GIT binary patch literal 76 zcmV-S0JHyi0V^p=O;s>7GGs6`FfcPQQAkWK$;{8wtIW+|cz;Lu&+a2ul9|jKHQfI! i%eBewoeNb}o?n!mmXlv@NUTC5y~;!!Mgjn7H#1Vp%OKwX literal 0 HcmV?d00001 diff --git a/internal/app/cmd/testdata/validate/good-repository/objects/74/22baa3a822e909afe3c87eaa7646b12f43fdcb b/internal/app/cmd/testdata/validate/good-repository/objects/74/22baa3a822e909afe3c87eaa7646b12f43fdcb new file mode 100644 index 0000000000000000000000000000000000000000..6c08df4f7471431d6c6f9389199250e6a0bdfad0 GIT binary patch literal 156 zcmV;N0Av4n0V^p=O;s>7H)Aj{00ITQwEUv<)U14lFFKZt5B66cjF`Y+BGUDoH z8>o`>%#zeZhFy;Wm8#fhl`(!|4SC(P`P`p#fhQ3PGD?%MsxUM#Ff%bxNK7ut%+J%S z%*|mqop#A!U9C>eB4KULY3Kj;l-%>!2r;L)Gzp@P;bY$(Il;gSJMPWV%$Mz6KWD-} K`(*&&^Glo)Y)t9V=yrQ0)_JYqU^Ms{PJRkbK2_e#dAf24_SFUn_8@6+P~)f JdH_DR5NA)f7_mOwMvtPR=Bn6fVwgI!Y`t zh3<(qbG*}FutZ@Ia(%B#EpiYlhq~gw-HxbTq3PTvx_M!{og8guWaXq*Dg5aG-1;M+ z%@dL<5QMepEa(}CO)979q0KLqbwXv>>qu4A&ZdM_o05M3US#Rsj?Ni~VYg3rL-5mi;njC!aQk)Yt+sQ&Y2I$o4XE=>&mNE(_BH(DyV zFJxBOjkto~gu$R<%%6)AN!6?@gTHD*$y0EqMlXmarP2<)2)ETY3P$k(3_|3H1slL4 zzSQbhp#UXm#=sO|(zB$3ah{qsB6E4`Ux$JDC1A}n<^g}qEK{no`jnz40Q z1@9 @yrXs#97&d>b)R6Z>n?_RoeEcqS^=Ln+T&xI2`zt`_{kJfvioJ3Z){Y+#9 zep!7Cr?@9gO}tguu3(f%UL>ycQx;$OR6fi3^>@i*} fd=Cx|V86ZPhKDz&ftM|?EK`^YTXg;ca}BJUJ&Kw& literal 0 HcmV?d00001 diff --git a/internal/app/cmd/testdata/validate/good-repository/objects/ce/2b2747739d1553c23a48e695732c358faccfaf b/internal/app/cmd/testdata/validate/good-repository/objects/ce/2b2747739d1553c23a48e695732c358faccfaf new file mode 100644 index 0000000000000000000000000000000000000000..da65a7ccc5a030a0ef0cb3604a2bd0ada471eae2 GIT binary patch literal 52 zcmV-40L%Y)0V^p=O;s>9WiT`_Ff%bxNJ>o6tIW+|XwZ>TvEOz3?RV96nXy&o`@}t7 K>jD5I_YY7Xb{Am) literal 0 HcmV?d00001 diff --git a/internal/app/cmd/testdata/validate/good-repository/objects/ef/dc13fcbbc43a196903b12847fca66d3c6b8d9d b/internal/app/cmd/testdata/validate/good-repository/objects/ef/dc13fcbbc43a196903b12847fca66d3c6b8d9d new file mode 100644 index 0000000000000000000000000000000000000000..0fd740ac5b719a98525429bc6d09ba19f0ff0fa5 GIT binary patch literal 85 zcmV-b0IL6Z0ZYosPg1ZjVaU(3Qiv`nEzXGL%F0hFw&GGyC`m0Y0nrKyMWuPgx*!!P r`N`R-MPRYwlGFmQGLQmYkP<6})Z~nO1;?Bmh4lRV6h$ro_Kq6Fo%$#G literal 0 HcmV?d00001 diff --git a/internal/app/cmd/testdata/validate/good-repository/objects/f0/9a73e2ddef7b7834661e4c7b388a22f654f164 b/internal/app/cmd/testdata/validate/good-repository/objects/f0/9a73e2ddef7b7834661e4c7b388a22f654f164 new file mode 100644 index 0000000000000000000000000000000000000000..d67d9bb5e1d1b878e7068721da062796dac27580 GIT binary patch literal 781 zcmV+o1M>WM0hN=<4y#5GMYHBBdXJK3GJ}*C1u$T+8EggvJ7!~O44BD${c{p!mrZJU zq`Frn)yZB6)e|5IlKW*Eivn;AMWsnXCp<+J0wE-2L1#QyBnihdY)VokLzj7$V#z!` zUL%;_#n>gN0Kbo~*YszxC8%$U-?A6_1CZQ-<^}E`kN_trg22A?+cp*dbFJ#DcU^&B z{~AfrbPG6|16*CRM9;q|e{&3ApuXz6B#M$;iMd)UN(rm0Lz$1uZif7^52diXoZY&s zD^V3ueZF^@C|Aw(U;a&~j=3yCSb85Fe@1xc!mK&Bdi5A3NH>M!Rs0YW`MW$*tlWLt zi#vRF@!d7+V;D$?0fb91Svy}7iJ*@YIoxp1BEq6c8~M(`C^`sva+#x`eQ0`twg{!T z7ZLuJ-=^K=S`0kw=YD0XaYlBqp4aB=LUV(%k--D;JXNtSIiqn*xgGU{-sbpfi|jV0 zTxyZ~N52mkX!B(n478>3fz;XQif2q!@I~*(Wa>#9F&8&>i~~93=}U*`dAEEMPwlNb zt$pUjG6t;gtxA!;g-4W}@^1Ano&0X2^`LGn6Ni2pIV9Blmyu-5gG2CG=H`+f+XYWT z8Bq)dvc+;5!%(@`5;q$of5UCG;gu#a0J*fI z5yrL3au{%tZT9$} z&f3h~6rOSI>ib=ja`v;PJ?FXINj+oQRnIuNVIn$Ssvj8mvF|Sv`^Oo@I{ue2*w=)q LPFn0III%|tMR0!7 literal 0 HcmV?d00001 diff --git a/internal/app/cmd/testdata/validate/good-repository/objects/f1/8ebc1e1151d0b8de9c296f1d8baf9c90fe3fa6 b/internal/app/cmd/testdata/validate/good-repository/objects/f1/8ebc1e1151d0b8de9c296f1d8baf9c90fe3fa6 new file mode 100644 index 0000000000000000000000000000000000000000..d38500cd0177c3bdde2b988cf481007d91e2324a GIT binary patch literal 56 zcmV-80LTA$0V^p=O;s?qWH2-^Ff%bxNK7ut%+J%SOw7$;IGuLMU|p?F&LUxL&S~fW O_LSW7*a!e`)Di=Swi%xQ literal 0 HcmV?d00001 diff --git a/internal/app/cmd/testdata/validate/good-repository/objects/f4/2c3901e0beb480edfd5d41670c1a1958d5b33c b/internal/app/cmd/testdata/validate/good-repository/objects/f4/2c3901e0beb480edfd5d41670c1a1958d5b33c new file mode 100644 index 0000000000000000000000000000000000000000..3064fac8cd5b3cfd3544e9d769900709ce1e6b16 GIT binary patch literal 51 zcmV-30L=e*0V^p=O;s>9V=yrQ0)_JYqU^Ms{PJRkr0ncYRm&SEL|vDz&t4-vbDD9S JF91VY59l>$7Ks1= literal 0 HcmV?d00001 diff --git a/internal/app/cmd/testdata/validate/good-repository/packed-refs b/internal/app/cmd/testdata/validate/good-repository/packed-refs new file mode 100644 index 00000000..b36775ff --- /dev/null +++ b/internal/app/cmd/testdata/validate/good-repository/packed-refs @@ -0,0 +1,2 @@ +# pack-refs with: peeled fully-peeled sorted +f09a73e2ddef7b7834661e4c7b388a22f654f164 refs/heads/master diff --git a/internal/app/cmd/testdata/validate/good-repository/refs/placeholder b/internal/app/cmd/testdata/validate/good-repository/refs/placeholder new file mode 100644 index 00000000..e69de29b diff --git a/internal/app/cmd/testdata/validate/good-workflow.yml b/internal/app/cmd/testdata/validate/good-workflow.yml new file mode 100644 index 00000000..efdc13fc --- /dev/null +++ b/internal/app/cmd/testdata/validate/good-workflow.yml @@ -0,0 +1,6 @@ +on: [push] +jobs: + test: + runs-on: docker + steps: + - run: echo All good! diff --git a/internal/app/cmd/testdata/validate/make-repositories.sh b/internal/app/cmd/testdata/validate/make-repositories.sh new file mode 100755 index 00000000..6f3918ee --- /dev/null +++ b/internal/app/cmd/testdata/validate/make-repositories.sh @@ -0,0 +1,56 @@ +#!/bin/bash -ex + +tmpdir=$(mktemp -d) + +trap "rm -fr $tmpdir" EXIT + +# good + +mkdir $tmpdir/good +git -C $tmpdir/good init --quiet + +cp good-action.yml $tmpdir/good/action.yml +mkdir -p $tmpdir/good/subaction +cp good-action.yml $tmpdir/good/subaction/action.yaml + +mkdir -p $tmpdir/good/.forgejo/workflows +cp good-workflow.yml $tmpdir/good/.forgejo/workflows/action.yml +cp good-workflow.yml $tmpdir/good/.forgejo/workflows/workflow1.yml +cp good-workflow.yml $tmpdir/good/.forgejo/workflows/workflow2.yaml + +# add workflows / actions that won't be good but it does not matter +# because they must be ignored +for i in .github .gitea; do + mkdir -p $tmpdir/good/$i/workflows + cp bad-workflow.yml $tmpdir/good/$i/workflows/bad.yml +done + +git -C $tmpdir/good config user.email root@example.com +git -C $tmpdir/good config user.name username +git -C $tmpdir/good add . +git -C $tmpdir/good commit -m 'initial' + +rm -fr good-repository +git clone --bare $tmpdir/good good-repository +rm -fr good-repository/hooks +touch good-repository/refs/placeholder + +# bad + +mkdir $tmpdir/bad +git -C $tmpdir/bad init --quiet + +cp bad-action.yml $tmpdir/bad/action.yml + +mkdir -p $tmpdir/bad/.forgejo/workflows +cp bad-workflow.yml $tmpdir/bad/.forgejo/workflows/workflow1.yml + +git -C $tmpdir/bad config user.email root@example.com +git -C $tmpdir/bad config user.name username +git -C $tmpdir/bad add . +git -C $tmpdir/bad commit -m 'initial' + +rm -fr bad-repository +git clone --bare $tmpdir/bad bad-repository +rm -fr bad-repository/hooks +touch bad-repository/refs/placeholder diff --git a/internal/app/cmd/validate.go b/internal/app/cmd/validate.go new file mode 100644 index 00000000..b2a73ebf --- /dev/null +++ b/internal/app/cmd/validate.go @@ -0,0 +1,193 @@ +// Copyright 2025 The Forgejo Authors +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + + "code.forgejo.org/forgejo/runner/act/model" + "code.forgejo.org/forgejo/runner/testutils" + + "github.com/spf13/cobra" +) + +type validateArgs struct { + path string + repository string + clonedir string + workflow bool + action bool +} + +func validate(dir, path string, isWorkflow, isAction bool) error { + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("%s: %v", path, err) + } + defer func() { f.Close() }() + + if isWorkflow { + _, err = model.ReadWorkflow(f, true) + } else if isAction { + _, err = model.ReadAction(f) + } + + if len(dir) > 0 { + dir += "/" + } + shortPath := strings.TrimPrefix(path, dir) + kind := "workflow" + if isAction { + kind = "action" + } + if err != nil { + fmt.Printf("%s %s schema validation failed:\n%s\n", shortPath, kind, err.Error()) + } else { + fmt.Printf("%s %s schema validation OK\n", shortPath, kind) + } + + return nil +} + +func validatePath(validateArgs *validateArgs) error { + if !validateArgs.workflow && !validateArgs.action { + return errors.New("one of --workflow or --action must be set") + } + return validate("", validateArgs.path, validateArgs.workflow, validateArgs.action) +} + +func validateHasYamlSuffix(s, suffix string) bool { + return strings.HasSuffix(s, suffix+".yml") || strings.HasSuffix(s, suffix+".yaml") +} + +func validateRepository(validateArgs *validateArgs) error { + clonedir := validateArgs.clonedir + if len(clonedir) == 0 { + tmpdir, err := os.MkdirTemp("", "runner-validate") + if err != nil { + return fmt.Errorf("MkdirTemp: %v", err) + } + clonedir = filepath.Join(tmpdir, "clonedir") + defer os.RemoveAll(tmpdir) + } + + exists, err := testutils.FileExists(clonedir) + if err != nil { + return err + } + + if !exists { + git := "git" + args := []string{"clone", "--depth=1", validateArgs.repository, clonedir} + cmd := exec.Command(git, args...) + if output, err := cmd.CombinedOutput(); err != nil { + fmt.Fprintf(os.Stderr, "%s %s: %s", git, args, output) + return err + } + for _, dir := range []string{".git", ".github", ".gitea"} { + exists, err := testutils.FileExists(clonedir) + if err != nil { + return err + } + if exists { + if err := os.RemoveAll(filepath.Join(clonedir, dir)); err != nil { + return err + } + } + } + } + + if err := filepath.Walk(clonedir, func(path string, fi fs.FileInfo, err error) error { + if validateHasYamlSuffix(path, "/.forgejo/workflows/action") { + return nil + } + isWorkflow := false + isAction := true + if validateHasYamlSuffix(path, "/action") { + if err := validate(clonedir, path, isWorkflow, isAction); err != nil { + return err + } + } + return nil + }); err != nil { + return err + } + + workflowdir := clonedir + "/.forgejo/workflows" + exists, err = testutils.FileExists(workflowdir) + if err != nil { + return err + } + + if exists { + if err := filepath.Walk(workflowdir, func(path string, fi fs.FileInfo, err error) error { + isWorkflow := true + isAction := false + if validateHasYamlSuffix(path, "") { + if err := validate(clonedir, path, isWorkflow, isAction); err != nil { + return err + } + } + return nil + }); err != nil { + return err + } + } + + return nil +} + +func runValidate(_ context.Context, validateArgs *validateArgs) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + if len(validateArgs.path) > 0 { + return validatePath(validateArgs) + } else if len(validateArgs.repository) > 0 { + return validateRepository(validateArgs) + } + return nil + } +} + +func loadValidateCmd(ctx context.Context) *cobra.Command { + validateArgs := validateArgs{} + + validateCmd := &cobra.Command{ + Use: "validate", + Short: "Validate workflows or actions with a schema", + Long: ` +Validate workflows or actions with a schema verifying they are conformant. + +The --path argument is a filename that will be validated as a workflow +(if the --workflow flag is set) or as an action (if the --action flag is set). + +The --repository argument is a URL to a Git repository. It will be +cloned (in the --clonedir directory or a temporary location removed +when the validation completes). The following files will be validated: + +- All .forgejo/workflows/*.{yml,yaml} files as workflows +- All **/action.{yml,yaml} files as actions +`, + Args: cobra.MaximumNArgs(20), + RunE: runValidate(ctx, &validateArgs), + } + + validateCmd.Flags().BoolVar(&validateArgs.workflow, "workflow", false, "use the workflow schema") + validateCmd.Flags().BoolVar(&validateArgs.action, "action", false, "use the action schema") + validateCmd.MarkFlagsMutuallyExclusive("workflow", "action") + + validateCmd.Flags().StringVar(&validateArgs.clonedir, "clonedir", "", "directory in which the repository will be cloned") + validateCmd.Flags().StringVar(&validateArgs.repository, "repository", "", "URL to a repository to validate") + validateCmd.Flags().StringVar(&validateArgs.path, "path", "", "path to the file") + validateCmd.MarkFlagsOneRequired("repository", "path") + validateCmd.MarkFlagsMutuallyExclusive("repository", "path") + + return validateCmd +} diff --git a/internal/app/cmd/validate_test.go b/internal/app/cmd/validate_test.go new file mode 100644 index 00000000..7442d1d2 --- /dev/null +++ b/internal/app/cmd/validate_test.go @@ -0,0 +1,93 @@ +// Copyright 2025 The Forgejo Authors +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_validateCmd(t *testing.T) { + ctx := context.Background() + for _, testCase := range []struct { + name string + args []string + message string + cmdOut string + stdOut string + stdErr string + }{ + { + name: "MissingFlag", + args: []string{"--path", "testdata/validate/good-action.yml"}, + cmdOut: "Usage:", + message: "one of --workflow or --action must be set", + }, + { + name: "MutuallyExclusive", + args: []string{"--action", "--workflow", "--path", "/tmp"}, + message: "[action workflow] were all set", + }, + { + name: "PathActionOK", + args: []string{"--action", "--path", "testdata/validate/good-action.yml"}, + stdOut: "schema validation OK", + }, + { + name: "PathActionNOK", + args: []string{"--action", "--path", "testdata/validate/bad-action.yml"}, + stdOut: "Expected a mapping got scalar", + }, + { + name: "PathWorkflowOK", + args: []string{"--workflow", "--path", "testdata/validate/good-workflow.yml"}, + stdOut: "schema validation OK", + }, + { + name: "PathWorkflowNOK", + args: []string{"--workflow", "--path", "testdata/validate/bad-workflow.yml"}, + stdOut: "Unknown Property ruins-on", + }, + { + name: "RepositoryOK", + args: []string{"--repository", "testdata/validate/good-repository"}, + stdOut: "action.yml action schema validation OK\nsubaction/action.yaml action schema validation OK\n.forgejo/workflows/action.yml workflow schema validation OK\n.forgejo/workflows/workflow1.yml workflow schema validation OK\n.forgejo/workflows/workflow2.yaml workflow schema validation OK", + }, + { + name: "RepositoryActionNOK", + args: []string{"--repository", "testdata/validate/bad-repository"}, + stdOut: "action.yml action schema validation failed", + }, + { + name: "RepositoryWorkflowNOK", + args: []string{"--repository", "testdata/validate/bad-repository"}, + stdOut: ".forgejo/workflows/workflow1.yml workflow schema validation failed", + }, + } { + t.Run(testCase.name, func(t *testing.T) { + cmd := loadValidateCmd(ctx) + cmdOut, stdOut, stdErr, err := executeCommand(ctx, t, cmd, testCase.args...) + if testCase.message != "" { + assert.ErrorContains(t, err, testCase.message) + } else { + assert.NoError(t, err) + } + if testCase.stdOut != "" { + assert.Contains(t, stdOut, testCase.stdOut) + } else { + assert.Empty(t, stdOut) + } + if testCase.stdErr != "" { + assert.Contains(t, stdErr, testCase.stdErr) + } else { + assert.Empty(t, stdErr) + } + if testCase.cmdOut != "" { + assert.Contains(t, cmdOut, testCase.cmdOut) + } + }) + } +} diff --git a/release-notes/757.md b/release-notes/757.md new file mode 100644 index 00000000..7e2053be --- /dev/null +++ b/release-notes/757.md @@ -0,0 +1 @@ +feat: the new `forgejo-runner validate` command can be used to verify if an action or a workflow is conformant with the expected schema. `forgejo-runner validate --repository https://example.com/my/repository` will validate all the workflows and actions a Git repository contains. Alternatively `forgejo-runner validate --path myaction/action.yml --action` or `forgejo-runner validate --path .forgejo/workflows/test.yml --workflow` can be used to validate a single file. It is recommended to use these commands to verify existing actions and workflows pass before upgrading to [Forgejo runner v8.0.0](https://code.forgejo.org/forgejo/runner/src/branch/main/RELEASE-NOTES.md#8-0-0) or above to not disrupt existing workflows. diff --git a/testutils/file.go b/testutils/file.go new file mode 100644 index 00000000..159b597d --- /dev/null +++ b/testutils/file.go @@ -0,0 +1,20 @@ +// Copyright 2025 The Forgejo Authors +// SPDX-License-Identifier: MIT + +package testutils + +import ( + "errors" + "os" +) + +func FileExists(pathname string) (bool, error) { + _, err := os.Stat(pathname) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +}