Skip to content

Commit

Permalink
Add support for validating one job in a workflow
Browse files Browse the repository at this point in the history
Extend the `ghasum verify` command with the ability to verify a single
job in a workflow. In particular, when the provided target ends with
`:suffix` the suffix will be stripped and used as the job id/key, the
rest of the target is then treated as the workflow to verify. This will
result in the verification scope being limited to only that job (in that
workflow).

The new behavior is tested primarily through `testscript` tests covering
both good weather and bad weather scenarios (this also improves the good
weather scenarios for verification by covering the scenario where only
some of the checksums are valid and only those are being validated for
both workflow and job verification). Additionally, local functionality
is unit tested in places where unit tests already exist.
  • Loading branch information
ericcornelissen committed Mar 10, 2024
1 parent 9dacfb0 commit fe57fdd
Show file tree
Hide file tree
Showing 9 changed files with 359 additions and 38 deletions.
36 changes: 27 additions & 9 deletions cmd/ghasum/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,10 @@ func cmdVerify(argv []string) error {
return err
}

c, err := cache.New(*flagCache, *flagNoCache)
if err != nil {
return errors.Join(errCache, err)
var job string
if i := strings.LastIndexByte(target, 0x3A); i >= 0 {
job = target[i+1:]
target = target[0:i]
}

stat, err := os.Stat(target)
Expand All @@ -66,10 +67,16 @@ func cmdVerify(argv []string) error {
target = repo
}

c, err := cache.New(*flagCache, *flagNoCache)
if err != nil {
return errors.Join(errCache, err)
}

cfg := ghasum.Config{
Repo: os.DirFS(target),
Path: target,
Workflow: workflow,
Job: job,
Cache: c,
}

Expand Down Expand Up @@ -97,15 +104,26 @@ func helpVerify() string {
Verify the Actions in the target against the stored checksums. If no target is
provided it will default to the current working directory. If the checksums do
not match this command will error with a non-zero exit code.
not match this command will error with a non-zero exit code. If ghasum is not
yet initialized this command errors (see "ghasum help init").
The target can be either a directory or a file. If it is a directory it must be
the root of a repository (that is, it should contain the .github directory). In
this case checksums will be verified for every workflow in the repository. If it
is a file it must be a workflow file in a repository. In this case checksums
will be verified only for the given workflow.
the root of a repository (that is, it should contain the .github directory). For
example:
ghasum verify my-project
In this case checksums will be verified for every workflow in the repository. If
it is a file it must be a workflow file in a repository. For example:
ghasum verify my-project/.github/workflows/workflow.yml
In this case checksums will be verified for all jobs in the given workflow. If
it is a file it may specify a job by using a ":job" suffix. For example:
ghasum verify my-project/.github/workflows/workflow.yml:job-key
If ghasum is not yet initialized this command errors (see "ghasum help init").
In this case checksums will be verified only for the given job in the workflow.
The available flags are:
Expand Down
15 changes: 12 additions & 3 deletions internal/gha/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,11 @@ func workflowsInRepo(repo fs.FS) ([][]byte, error) {
return nil
}

file, err := repo.Open(entryPath)
data, err := workflowInRepo(repo, entryPath)
if err != nil {
return fmt.Errorf("could not open workflow at %q: %v", entryPath, err)
return err
}

data, _ := io.ReadAll(file)
workflows = append(workflows, data)
return nil
}
Expand All @@ -87,3 +86,13 @@ func workflowsInRepo(repo fs.FS) ([][]byte, error) {

return workflows, nil
}

func workflowInRepo(repo fs.FS, path string) ([]byte, error) {
file, err := repo.Open(path)
if err != nil {
return nil, fmt.Errorf("could not open workflow at %q: %v", path, err)
}

data, _ := io.ReadAll(file)
return data, nil
}
44 changes: 41 additions & 3 deletions internal/gha/gha.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
package gha

import (
"fmt"
"io/fs"
"path"
)
Expand Down Expand Up @@ -63,13 +64,50 @@ func RepoActions(repo fs.FS) ([]GitHubAction, error) {
return actions, nil
}

// WorkflowActions extracts the GitHub Actions used in the provided workflow.
func WorkflowActions(rawWorkflow []byte) ([]GitHubAction, error) {
w, err := parseWorkflow(rawWorkflow)
// WorkflowActions extracts the GitHub Actions used in the specified workflow at
// the given file system hierarchy.
func WorkflowActions(repo fs.FS, path string) ([]GitHubAction, error) {
data, err := workflowInRepo(repo, path)
if err != nil {
return nil, err
}

w, err := parseWorkflow(data)
if err != nil {
return nil, err
}

actions, err := actionsInWorkflows([]workflow{w})
if err != nil {
return nil, err
}

return actions, nil
}

// JobActions extracts the GitHub Actions used in the specified job in the
// specified workflow at the given file system hierarchy.
func JobActions(repo fs.FS, path, name string) ([]GitHubAction, error) {
data, err := workflowInRepo(repo, path)
if err != nil {
return nil, err
}

w, err := parseWorkflow(data)
if err != nil {
return nil, err
}

for job := range w.Jobs {
if job != name {
delete(w.Jobs, job)
}
}

if len(w.Jobs) == 0 {
return nil, fmt.Errorf("job %q not found in workflow %q", name, path)
}

actions, err := actionsInWorkflows([]workflow{w})
if err != nil {
return nil, err
Expand Down
196 changes: 185 additions & 11 deletions internal/gha/gha_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,50 +150,224 @@ func TestWorkflowActions(t *testing.T) {
t.Parallel()

type TestCase struct {
workflow []byte
wantErr bool
workflows map[string]mockFsEntry
workflow string
wantErr bool
}

testCases := []TestCase{
{
workflow: []byte(workflowWithNoJobs),
workflows: map[string]mockFsEntry{
"workflow.yml": {
Content: []byte(workflowWithNoJobs),
},
},
workflow: ".github/workflows/workflow.yml",
wantErr: false,
},
{
workflow: []byte(workflowWithJobNoSteps),
workflows: map[string]mockFsEntry{
"workflow.yml": {
Content: []byte(workflowWithJobNoSteps),
},
},
workflow: ".github/workflows/workflow.yml",
wantErr: false,
},
{
workflow: []byte(workflowWithJobWithSteps),
workflows: map[string]mockFsEntry{
"workflow.yml": {
Content: []byte(workflowWithJobWithSteps),
},
},
workflow: ".github/workflows/workflow.yml",
wantErr: false,
},
{
workflow: []byte(workflowWithJobsWithSteps),
workflows: map[string]mockFsEntry{
"workflow.yml": {
Content: []byte(workflowWithJobsWithSteps),
},
},
workflow: ".github/workflows/workflow.yml",
wantErr: false,
},
{
workflow: []byte(workflowWithNestedActions),
workflows: map[string]mockFsEntry{
"workflow.yml": {
Content: []byte(workflowWithNestedActions),
},
},
workflow: ".github/workflows/workflow.yml",
wantErr: false,
},
{
workflow: []byte(workflowWithSyntaxError),
workflows: map[string]mockFsEntry{
"workflow.yml": {
Content: []byte(workflowWithSyntaxError),
},
},
workflow: ".github/workflows/workflow.yml",
wantErr: true,
},
{
workflow: []byte(workflowWithInvalidUses),
workflows: map[string]mockFsEntry{
"workflow.yml": {
Content: []byte(workflowWithInvalidUses),
},
},
workflow: ".github/workflows/workflow.yml",
wantErr: true,
},
{
workflows: map[string]mockFsEntry{},
workflow: ".github/workflows/workflow.yml",
wantErr: true,
},
}

for i, tc := range testCases {
t.Run(fmt.Sprintf("#%d", i), func(t *testing.T) {
t.Parallel()

_, err := WorkflowActions(tc.workflow)
repo, err := mockRepo(tc.workflows)
if err != nil {
t.Fatalf("Could not initialize file system: %+v", err)
}

_, err = WorkflowActions(repo, tc.workflow)
if err == nil && tc.wantErr {
t.Error("Unexpected success")
} else if err != nil && !tc.wantErr {
t.Errorf("Unexpected failure (got %v)", err)
}
})
}
}

func TestJobActions(t *testing.T) {
t.Parallel()

type TestCase struct {
workflows map[string]mockFsEntry
workflow string
job string
wantErr bool
}

testCases := []TestCase{
{
workflows: map[string]mockFsEntry{
"workflow.yml": {
Content: []byte(workflowWithJobNoSteps),
},
},
workflow: ".github/workflows/workflow.yml",
job: "no-steps",
wantErr: false,
},
{
workflows: map[string]mockFsEntry{
"workflow.yml": {
Content: []byte(workflowWithJobWithSteps),
},
},
workflow: ".github/workflows/workflow.yml",
job: "only-job",
wantErr: false,
},
{
workflows: map[string]mockFsEntry{
"workflow.yml": {
Content: []byte(workflowWithJobsWithSteps),
},
},
workflow: ".github/workflows/workflow.yml",
job: "job-a",
wantErr: false,
},
{
workflows: map[string]mockFsEntry{
"workflow.yml": {
Content: []byte(workflowWithJobsWithSteps),
},
},
workflow: ".github/workflows/workflow.yml",
job: "job-b",
wantErr: false,
},
{
workflows: map[string]mockFsEntry{
"workflow.yml": {
Content: []byte(workflowWithNestedActions),
},
},
workflow: ".github/workflows/workflow.yml",
job: "only-job",
wantErr: false,
},
{
workflows: map[string]mockFsEntry{
"workflow.yml": {
Content: []byte(workflowWithNoJobs),
},
},
workflow: ".github/workflows/workflow.yml",
job: "anything",
wantErr: true,
},
{
workflows: map[string]mockFsEntry{
"workflow.yml": {
Content: []byte(workflowWithJobWithSteps),
},
},
workflow: ".github/workflows/workflow.yml",
job: "missing",
wantErr: true,
},
{
workflows: map[string]mockFsEntry{
"workflow.yml": {
Content: []byte(workflowWithSyntaxError),
},
},
workflow: ".github/workflows/workflow.yml",
job: "anything",
wantErr: true,
},
{
workflows: map[string]mockFsEntry{
"workflow.yml": {
Content: []byte(workflowWithInvalidUses),
},
},
workflow: ".github/workflows/workflow.yml",
job: "job",
wantErr: true,
},
{
workflows: map[string]mockFsEntry{},
workflow: ".github/workflows/workflow.yml",
job: "anything",
wantErr: true,
},
}

for i, tc := range testCases {
t.Run(fmt.Sprintf("#%d", i), func(t *testing.T) {
t.Parallel()

repo, err := mockRepo(tc.workflows)
if err != nil {
t.Fatalf("Could not initialize file system: %+v", err)
}

_, err = JobActions(repo, tc.workflow, tc.job)
if err == nil && tc.wantErr {
t.Error("Unexpected success")
} else if err != nil && !tc.wantErr {
t.Error("Unexpected failure")
t.Errorf("Unexpected failure (got %v)", err)
}
})
}
Expand Down
Loading

0 comments on commit fe57fdd

Please sign in to comment.