1
0
Fork 0
mirror of https://code.forgejo.org/forgejo/runner.git synced 2025-08-06 17:40:58 +00:00

feat: add the runner validate subcommand (#757)

<!--start release-notes-assistant-->
<!--URL:https://code.forgejo.org/forgejo/runner-->
- features
  - [PR](https://code.forgejo.org/forgejo/runner/pulls/757): <!--number 757 --><!--line 0 --><!--description ZmVhdDogdGhlIG5ldyBgZm9yZ2Vqby1ydW5uZXIgdmFsaWRhdGVgIGNvbW1hbmQgY2FuIGJlIHVzZWQgdG8gdmVyaWZ5IGlmIGFuIGFjdGlvbiBvciBhIHdvcmtmbG93IGlzIGNvbmZvcm1hbnQgd2l0aCB0aGUgZXhwZWN0ZWQgc2NoZW1hLiBgZm9yZ2Vqby1ydW5uZXIgdmFsaWRhdGUgLS1yZXBvc2l0b3J5IGh0dHBzOi8vZXhhbXBsZS5jb20vbXkvcmVwb3NpdG9yeWAgd2lsbCB2YWxpZGF0ZSBhbGwgdGhlIHdvcmtmbG93cyBhbmQgYWN0aW9ucyBhIEdpdCByZXBvc2l0b3J5IGNvbnRhaW5zLiBBbHRlcm5hdGl2ZWx5ICBgZm9yZ2Vqby1ydW5uZXIgdmFsaWRhdGUgLS1wYXRoIG15YWN0aW9uL2FjdGlvbi55bWwgLS1hY3Rpb25gIG9yIGBmb3JnZWpvLXJ1bm5lciB2YWxpZGF0ZSAtLXBhdGggLmZvcmdlam8vd29ya2Zsb3dzL3Rlc3QueW1sIC0td29ya2Zsb3dgIGNhbiBiZSB1c2VkIHRvIHZhbGlkYXRlIGEgc2luZ2xlIGZpbGUuIEl0IGlzIHJlY29tbWVuZGVkIHRvIHVzZSB0aGVzZSBjb21tYW5kcyB0byB2ZXJpZnkgZXhpc3RpbmcgYWN0aW9ucyBhbmQgd29ya2Zsb3dzIHBhc3MgYmVmb3JlIHVwZ3JhZGluZyB0byBbRm9yZ2VqbyBydW5uZXIgdjguMC4wXShodHRwczovL2NvZGUuZm9yZ2Vqby5vcmcvZm9yZ2Vqby9ydW5uZXIvc3JjL2JyYW5jaC9tYWluL1JFTEVBU0UtTk9URVMubWQjOC0wLTApIG9yIGFib3ZlIHRvIG5vdCBkaXNydXB0IGV4aXN0aW5nIHdvcmtmbG93cy4=-->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.<!--description-->
<!--end release-notes-assistant-->

Reviewed-on: https://code.forgejo.org/forgejo/runner/pulls/757
Reviewed-by: Michael Kriese <michael.kriese@gmx.de>
Co-authored-by: Earl Warren <contact@earl-warren.org>
Co-committed-by: Earl Warren <contact@earl-warren.org>
This commit is contained in:
Earl Warren 2025-07-31 05:37:12 +00:00 committed by earl-warren
parent 86c528e510
commit 20f115fdac
No known key found for this signature in database
GPG key ID: F128CBE6AB3A7201
40 changed files with 545 additions and 8 deletions

View file

@ -122,7 +122,7 @@ install: $(GOFILES)
build: go-check $(EXECUTABLE) 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 $@ $(GO) build -v -tags 'netgo osusergo $(TAGS)' -ldflags '$(EXTLDFLAGS)-s -w $(LDFLAGS)' -o $@
.PHONY: deps-tools .PHONY: deps-tools

View file

@ -15,7 +15,6 @@ import (
) )
func Execute(ctx context.Context) { func Execute(ctx context.Context) {
// ./act_runner
rootCmd := &cobra.Command{ rootCmd := &cobra.Command{
Use: "forgejo-runner [event name to run]\nIf no event name passed, will default to \"on: push\"", 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.", 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 := "" configFile := ""
rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "Config file path") rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "Config file path")
// ./act_runner register
var regArgs registerArgs var regArgs registerArgs
registerCmd := &cobra.Command{ registerCmd := &cobra.Command{
Use: "register", Use: "register",
@ -43,7 +41,6 @@ func Execute(ctx context.Context) {
rootCmd.AddCommand(createRunnerFileCmd(ctx, &configFile)) rootCmd.AddCommand(createRunnerFileCmd(ctx, &configFile))
// ./act_runner daemon
daemonCmd := &cobra.Command{ daemonCmd := &cobra.Command{
Use: "daemon", Use: "daemon",
Short: "Run as a runner daemon", Short: "Run as a runner daemon",
@ -52,7 +49,6 @@ func Execute(ctx context.Context) {
} }
rootCmd.AddCommand(daemonCmd) rootCmd.AddCommand(daemonCmd)
// ./act_runner job
jobCmd := &cobra.Command{ jobCmd := &cobra.Command{
Use: "one-job", Use: "one-job",
Short: "Run only one job", Short: "Run only one job",
@ -61,10 +57,10 @@ func Execute(ctx context.Context) {
} }
rootCmd.AddCommand(jobCmd) rootCmd.AddCommand(jobCmd)
// ./act_runner exec
rootCmd.AddCommand(loadExecCmd(ctx)) rootCmd.AddCommand(loadExecCmd(ctx))
// ./act_runner config rootCmd.AddCommand(loadValidateCmd(ctx))
rootCmd.AddCommand(&cobra.Command{ rootCmd.AddCommand(&cobra.Command{
Use: "generate-config", Use: "generate-config",
Short: "Generate an example config file", Short: "Generate an example config file",
@ -74,7 +70,6 @@ func Execute(ctx context.Context) {
}, },
}) })
// ./act_runner cache-server
var cacheArgs cacheServerArgs var cacheArgs cacheServerArgs
cacheCmd := &cobra.Command{ cacheCmd := &cobra.Command{
Use: "cache-server", Use: "cache-server",

View file

@ -0,0 +1 @@
Use make-repositories.sh to change good-repository and bad-repository

View file

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

View file

@ -0,0 +1 @@
ref: refs/heads/master

View file

@ -0,0 +1,6 @@
[core]
repositoryformatversion = 0
filemode = true
bare = true
[remote "origin"]
url = /tmp/tmp.jyjE6tqWGS/bad

View file

@ -0,0 +1 @@
Unnamed repository; edit this file 'description' to name the repository.

View file

@ -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]
# *~

View file

@ -0,0 +1,2 @@
# pack-refs with: peeled fully-peeled sorted
99a66b16c83472b94e5e275ae6bf85ba89a8e201 refs/heads/master

View file

@ -0,0 +1,6 @@
on: [push]
jobs:
test:
ruins-on: docker
steps:
- run: echo All good!

View file

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

View file

@ -0,0 +1 @@
ref: refs/heads/master

View file

@ -0,0 +1,6 @@
[core]
repositoryformatversion = 0
filemode = true
bare = true
[remote "origin"]
url = /tmp/tmp.jyjE6tqWGS/good

View file

@ -0,0 +1 @@
Unnamed repository; edit this file 'description' to name the repository.

View file

@ -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]
# *~

View file

@ -0,0 +1,2 @@
# pack-refs with: peeled fully-peeled sorted
f09a73e2ddef7b7834661e4c7b388a22f654f164 refs/heads/master

View file

@ -0,0 +1,6 @@
on: [push]
jobs:
test:
runs-on: docker
steps:
- run: echo All good!

View file

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

View file

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

View file

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

1
release-notes/757.md Normal file
View file

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

20
testutils/file.go Normal file
View file

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