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 00000000..e51ad7cf Binary files /dev/null and b/internal/app/cmd/testdata/validate/bad-repository/objects/4a/b480aa3a6da70e379c50fb30509cf8acc1bd8c differ diff --git a/internal/app/cmd/testdata/validate/bad-repository/objects/53/ce48939297c1445f1a7e2c4afb24d9e679c933 b/internal/app/cmd/testdata/validate/bad-repository/objects/53/ce48939297c1445f1a7e2c4afb24d9e679c933 new file mode 100644 index 00000000..116a1b8c Binary files /dev/null and b/internal/app/cmd/testdata/validate/bad-repository/objects/53/ce48939297c1445f1a7e2c4afb24d9e679c933 differ diff --git a/internal/app/cmd/testdata/validate/bad-repository/objects/6f/70f2754f4de737955b68705db6ccec1090e12d b/internal/app/cmd/testdata/validate/bad-repository/objects/6f/70f2754f4de737955b68705db6ccec1090e12d new file mode 100644 index 00000000..94302828 Binary files /dev/null and b/internal/app/cmd/testdata/validate/bad-repository/objects/6f/70f2754f4de737955b68705db6ccec1090e12d differ diff --git a/internal/app/cmd/testdata/validate/bad-repository/objects/71/48e33ec2da861625486f4ac5c72e2197445bbd b/internal/app/cmd/testdata/validate/bad-repository/objects/71/48e33ec2da861625486f4ac5c72e2197445bbd new file mode 100644 index 00000000..7040da0a Binary files /dev/null and b/internal/app/cmd/testdata/validate/bad-repository/objects/71/48e33ec2da861625486f4ac5c72e2197445bbd differ 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 00000000..84cde071 Binary files /dev/null and b/internal/app/cmd/testdata/validate/bad-repository/objects/80/2c1a243fbadbedf725ae695d7a37be1748eb2d differ diff --git a/internal/app/cmd/testdata/validate/bad-repository/objects/99/a66b16c83472b94e5e275ae6bf85ba89a8e201 b/internal/app/cmd/testdata/validate/bad-repository/objects/99/a66b16c83472b94e5e275ae6bf85ba89a8e201 new file mode 100644 index 00000000..a5b95e8e Binary files /dev/null and b/internal/app/cmd/testdata/validate/bad-repository/objects/99/a66b16c83472b94e5e275ae6bf85ba89a8e201 differ 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 00000000..caa1d2e6 Binary files /dev/null and b/internal/app/cmd/testdata/validate/good-repository/objects/62/6b6b892539d8c81545a5af6bac1b9996335e4d differ 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 00000000..6c08df4f Binary files /dev/null and b/internal/app/cmd/testdata/validate/good-repository/objects/74/22baa3a822e909afe3c87eaa7646b12f43fdcb differ diff --git a/internal/app/cmd/testdata/validate/good-repository/objects/80/2c1a243fbadbedf725ae695d7a37be1748eb2d b/internal/app/cmd/testdata/validate/good-repository/objects/80/2c1a243fbadbedf725ae695d7a37be1748eb2d new file mode 100644 index 00000000..84cde071 Binary files /dev/null and b/internal/app/cmd/testdata/validate/good-repository/objects/80/2c1a243fbadbedf725ae695d7a37be1748eb2d differ diff --git a/internal/app/cmd/testdata/validate/good-repository/objects/ba/e251227a079a7601f20554eb82b3cefcce51c9 b/internal/app/cmd/testdata/validate/good-repository/objects/ba/e251227a079a7601f20554eb82b3cefcce51c9 new file mode 100644 index 00000000..c00df1a8 Binary files /dev/null and b/internal/app/cmd/testdata/validate/good-repository/objects/ba/e251227a079a7601f20554eb82b3cefcce51c9 differ diff --git a/internal/app/cmd/testdata/validate/good-repository/objects/cb/66d230ae7d2c6ca2132b0996cffd8c74de48b1 b/internal/app/cmd/testdata/validate/good-repository/objects/cb/66d230ae7d2c6ca2132b0996cffd8c74de48b1 new file mode 100644 index 00000000..2d1cb968 Binary files /dev/null and b/internal/app/cmd/testdata/validate/good-repository/objects/cb/66d230ae7d2c6ca2132b0996cffd8c74de48b1 differ 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 00000000..da65a7cc Binary files /dev/null and b/internal/app/cmd/testdata/validate/good-repository/objects/ce/2b2747739d1553c23a48e695732c358faccfaf differ 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 00000000..0fd740ac Binary files /dev/null and b/internal/app/cmd/testdata/validate/good-repository/objects/ef/dc13fcbbc43a196903b12847fca66d3c6b8d9d differ 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 00000000..d67d9bb5 Binary files /dev/null and b/internal/app/cmd/testdata/validate/good-repository/objects/f0/9a73e2ddef7b7834661e4c7b388a22f654f164 differ 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 00000000..d38500cd Binary files /dev/null and b/internal/app/cmd/testdata/validate/good-repository/objects/f1/8ebc1e1151d0b8de9c296f1d8baf9c90fe3fa6 differ 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 00000000..3064fac8 Binary files /dev/null and b/internal/app/cmd/testdata/validate/good-repository/objects/f4/2c3901e0beb480edfd5d41670c1a1958d5b33c differ 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 +}