Skip to content

Commit ee50b28

Browse files
Merge pull request #10 from actionforge/github-improvements
Code Cleanup and GitHub improvements
2 parents 5355805 + 79e5d71 commit ee50b28

17 files changed

Lines changed: 373 additions & 74 deletions

core/base.go

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ func getIndexPortRegex() *regexp.Regexp {
3939
return indexPortRegex
4040
}
4141

42+
type PortValidationOpts struct {
43+
IsGroupNode bool
44+
}
45+
4246
type CredentialType int
4347

4448
const (
@@ -402,7 +406,7 @@ func (n *NodeTypeDefinitionFull) IsValid() error {
402406
return nil
403407
}
404408

405-
func PortDefValidation(portId string, portDef PortDefinition) error {
409+
func PortDefValidation(portId string, portDef PortDefinition, opts PortValidationOpts) error {
406410
if portId == "" {
407411
return errors.New("port id is missing")
408412
}
@@ -417,14 +421,14 @@ func PortDefValidation(portId string, portDef PortDefinition) error {
417421
// [0]: "exec"
418422
// [1]: ""
419423
// [2]: ""
420-
if strings.Contains(m[2], "-") {
424+
if strings.Contains(m[2], "-") && !opts.IsGroupNode {
421425
return CreateErr(nil, nil, "execution port '%v' must not contain hyphens", portId)
422426
}
423427
}
424428
} else if !portDef.Exec {
425429
if m != nil {
426430
return CreateErr(nil, nil, "port '%v' starts with 'exec-' but is not flagged as exec", portId)
427-
} else if strings.Contains(portId, "-") {
431+
} else if strings.Contains(portId, "-") && !opts.IsGroupNode {
428432
return CreateErr(nil, nil, "port '%v' must not contain hyphens", portId)
429433
}
430434
}
@@ -491,7 +495,9 @@ func RegisterNodeFactory(nodeDefStr string, fn nodeFactoryFunc) error {
491495
}
492496

493497
if nodeDef.Id != "core/test" {
494-
err = PortDefValidation(string(inputId), inputDef.PortDefinition)
498+
err = PortDefValidation(string(inputId), inputDef.PortDefinition, PortValidationOpts{
499+
IsGroupNode: strings.HasPrefix(nodeDef.Id, "core/group@"),
500+
})
495501
if err != nil {
496502
return CreateErr(nil, err, "input '%v' is invalid", inputId)
497503
}
@@ -537,7 +543,10 @@ func RegisterNodeFactory(nodeDefStr string, fn nodeFactoryFunc) error {
537543
outputIndexes[outputDef.Index] = string(outputId)
538544

539545
if nodeDef.Id != "core/test" {
540-
err = PortDefValidation(string(outputId), outputDef.PortDefinition)
546+
err = PortDefValidation(string(outputId), outputDef.PortDefinition, PortValidationOpts{
547+
IsGroupNode: strings.HasPrefix(nodeDef.Id, "core/group@"),
548+
})
549+
541550
if err != nil {
542551
return CreateErr(nil, err, "input '%v' is invalid", outputId)
543552
}

core/github.go

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@ import (
77
"maps"
88
"os"
99
"os/exec"
10+
"path/filepath"
11+
"runtime"
1012
"strings"
1113

1214
"github.com/actionforge/actrun-cli/utils"
15+
"github.com/go-git/go-git/v5"
1316
"github.com/google/shlex"
1417
)
1518

@@ -249,3 +252,201 @@ func decodeJsonFromEnvValue[T any](envValue string) (map[string]T, error) {
249252
}
250253
return envMap, nil
251254
}
255+
256+
func getRunnerOS() string {
257+
switch runtime.GOOS {
258+
case "darwin":
259+
return "macOS"
260+
case "linux":
261+
return "Linux"
262+
case "windows":
263+
return "Windows"
264+
default:
265+
return runtime.GOOS
266+
}
267+
}
268+
269+
func getRunnerArch() string {
270+
switch runtime.GOARCH {
271+
case "arm64", "aarch64":
272+
return "ARM64"
273+
case "amd64":
274+
return "X64"
275+
default:
276+
return runtime.GOARCH
277+
}
278+
}
279+
280+
// Extracts owner/repo from a git remote URL. Supports http and ssh formats.
281+
func parseRepoFromRemoteURL(remoteURL string) (string, error) {
282+
remoteURL = strings.TrimSpace(remoteURL)
283+
284+
// handle ssh format
285+
if strings.HasPrefix(remoteURL, "git@") {
286+
// git@github.com:user/repo.git -> user/repo
287+
colonIdx := strings.Index(remoteURL, ":")
288+
if colonIdx == -1 {
289+
return "", fmt.Errorf("invalid SSH remote URL format: %s", remoteURL)
290+
}
291+
path := remoteURL[colonIdx+1:]
292+
path = strings.TrimSuffix(path, ".git")
293+
return path, nil
294+
}
295+
296+
// handle https format
297+
if strings.HasPrefix(remoteURL, "https://") || strings.HasPrefix(remoteURL, "http://") {
298+
path := remoteURL
299+
path = strings.TrimPrefix(path, "https://")
300+
path = strings.TrimPrefix(path, "http://")
301+
302+
// remove the host, eg github.com
303+
slashIdx := strings.Index(path, "/")
304+
if slashIdx == -1 {
305+
return "", fmt.Errorf("invalid HTTPS remote URL format: %s", remoteURL)
306+
}
307+
path = path[slashIdx+1:]
308+
path = strings.TrimSuffix(path, ".git")
309+
return path, nil
310+
}
311+
312+
return "", fmt.Errorf("unsupported remote URL format: %s", remoteURL)
313+
}
314+
315+
func SetupGitHubActionsEnv(finalEnv map[string]string) error {
316+
sourceWorkspace := finalEnv["GITHUB_WORKSPACE"]
317+
if sourceWorkspace == "" {
318+
return CreateErr(nil, nil, "GITHUB_WORKSPACE environment variable is required").
319+
SetHint("Set GITHUB_WORKSPACE to the path of a git repository.")
320+
}
321+
322+
eventName := finalEnv["GITHUB_EVENT_NAME"]
323+
if eventName == "" {
324+
return CreateErr(nil, nil, "GITHUB_EVENT_NAME environment variable is required").
325+
SetHint("Set GITHUB_EVENT_NAME to the event that triggered the workflow (e.g., push, pull_request).")
326+
}
327+
328+
repo, err := git.PlainOpenWithOptions(sourceWorkspace, &git.PlainOpenOptions{
329+
DetectDotGit: true,
330+
})
331+
if err != nil {
332+
return CreateErr(nil, err, "unable to open git repository at GITHUB_WORKSPACE").
333+
SetHint("Ensure GITHUB_WORKSPACE points to a valid git repository.")
334+
}
335+
336+
remote, err := repo.Remote("origin")
337+
if err != nil {
338+
return CreateErr(nil, err, "remote \"origin\" not found in git repository").
339+
SetHint("Your repository must have a GitHub remote named \"origin\".")
340+
}
341+
342+
remoteURLs := remote.Config().URLs
343+
if len(remoteURLs) == 0 {
344+
return CreateErr(nil, nil, "remote \"origin\" has no URLs configured").
345+
SetHint("Set the origin URL with: git remote set-url origin <url>")
346+
}
347+
348+
repoName, err := parseRepoFromRemoteURL(remoteURLs[0])
349+
if err != nil {
350+
return CreateErr(nil, err, "unable to parse repository from remote URL").
351+
SetHint("Ensure the origin remote URL is a valid GitHub repository URL.")
352+
}
353+
354+
head, err := repo.Head()
355+
if err != nil {
356+
return CreateErr(nil, err, "failed to get git HEAD").
357+
SetHint("Ensure you have at least one commit in the repository.")
358+
}
359+
360+
// here we default to main if we are not in a branch
361+
branch := "main"
362+
if head.Name().IsBranch() {
363+
branch = head.Name().Short()
364+
}
365+
366+
sha := head.Hash().String()
367+
368+
// create RUNNER_WORKSPACE with an empty directory for the actual GITHUB_WORKSPACE
369+
runnerWorkspace, err := os.MkdirTemp("", "actrun-runner-")
370+
if err != nil {
371+
return CreateErr(nil, err, "failed to create runner workspace directory").
372+
SetHint("Check that you have write permissions to the system temp directory.")
373+
}
374+
375+
// extract repo name for the workspace dir name
376+
repoParts := strings.Split(repoName, "/")
377+
repoBaseName := repoParts[len(repoParts)-1]
378+
379+
// here create the actual GITHUB_WORKSPACE inside the runner workspace
380+
githubWorkspace := filepath.Join(runnerWorkspace, repoBaseName)
381+
if err := os.MkdirAll(githubWorkspace, 0755); err != nil {
382+
return CreateErr(nil, err, "failed to create github workspace directory").
383+
SetHint("Check that you have write permissions to the system temp directory.")
384+
}
385+
386+
// create temp dir for runner files
387+
tempDir, err := os.MkdirTemp("", "actrun-")
388+
if err != nil {
389+
return CreateErr(nil, err, "failed to create temp directory").
390+
SetHint("Check that you have write permissions to the system temp directory.")
391+
}
392+
393+
homeDir, err := os.UserHomeDir()
394+
if err != nil {
395+
return CreateErr(nil, err, "failed to get home directory").
396+
SetHint("Ensure the HOME environment variable is set correctly.")
397+
}
398+
toolCacheDir := filepath.Join(homeDir, ".actrun", "tool-cache")
399+
400+
setIfNotSet := func(key, value string) {
401+
if finalEnv[key] == "" {
402+
finalEnv[key] = value
403+
}
404+
}
405+
406+
setIfNotSet("CI", "true")
407+
setIfNotSet("GITHUB_ACTIONS", "true")
408+
setIfNotSet("GITHUB_REPOSITORY", repoName)
409+
setIfNotSet("GITHUB_REF", "refs/heads/"+branch)
410+
setIfNotSet("GITHUB_REF_NAME", branch)
411+
setIfNotSet("GITHUB_SHA", sha)
412+
setIfNotSet("RUNNER_OS", getRunnerOS())
413+
setIfNotSet("RUNNER_ARCH", getRunnerArch())
414+
setIfNotSet("RUNNER_TOOL_CACHE", toolCacheDir)
415+
setIfNotSet("GITHUB_OUTPUT", filepath.Join(tempDir, "output"))
416+
setIfNotSet("GITHUB_ENV", filepath.Join(tempDir, "env"))
417+
setIfNotSet("GITHUB_PATH", filepath.Join(tempDir, "path"))
418+
setIfNotSet("GITHUB_STATE", filepath.Join(tempDir, "state"))
419+
setIfNotSet("GITHUB_STEP_SUMMARY", filepath.Join(tempDir, "summary"))
420+
setIfNotSet("RUNNER_TEMP", tempDir)
421+
422+
// override a few envs here no matter if they were set or not
423+
finalEnv["GITHUB_WORKSPACE"] = githubWorkspace
424+
finalEnv["RUNNER_WORKSPACE"] = runnerWorkspace
425+
426+
err = os.MkdirAll(toolCacheDir, 0755)
427+
if err != nil {
428+
return CreateErr(nil, err, "failed to create tool cache directory").
429+
SetHint("Check that you have write permissions to %s.", toolCacheDir)
430+
}
431+
432+
fileCommandFiles := []string{
433+
finalEnv["GITHUB_OUTPUT"],
434+
finalEnv["GITHUB_ENV"],
435+
finalEnv["GITHUB_PATH"],
436+
finalEnv["GITHUB_STATE"],
437+
finalEnv["GITHUB_STEP_SUMMARY"],
438+
}
439+
440+
for _, filePath := range fileCommandFiles {
441+
if filePath != "" {
442+
f, err := os.Create(filePath)
443+
if err != nil {
444+
return CreateErr(nil, err, "failed to create file command file %s", filePath).
445+
SetHint("Check that you have write permissions to the runner temp directory.")
446+
}
447+
f.Close()
448+
}
449+
}
450+
451+
return nil
452+
}

core/graph.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,38 @@ func RunGraph(ctx context.Context, graphName string, graphContent []byte, opts R
399399
printExplicit(envTracker, false)
400400
}
401401

402+
if isGitHubWorkflow {
403+
err = SetupGitHubActionsEnv(finalEnv)
404+
if err != nil {
405+
return CreateErr(nil, err, "failed to setup GitHub Actions environment")
406+
}
407+
}
408+
409+
// set cwd for current process. `ACT_CWD` is used for non GitHub workflows
410+
if cwd := finalEnv["GITHUB_WORKSPACE"]; cwd != "" {
411+
originalCwd, err := os.Getwd()
412+
if err != nil {
413+
return CreateErr(nil, err, "failed to get current working directory")
414+
}
415+
if err := os.Chdir(cwd); err != nil {
416+
return CreateErr(nil, err, "failed to change working directory to GITHUB_WORKSPACE")
417+
}
418+
defer func() {
419+
_ = os.Chdir(originalCwd)
420+
}()
421+
} else if cwd := finalEnv["ACT_CWD"]; cwd != "" {
422+
originalCwd, err := os.Getwd()
423+
if err != nil {
424+
return CreateErr(nil, err, "failed to get current working directory")
425+
}
426+
if err := os.Chdir(cwd); err != nil {
427+
return CreateErr(nil, err, "failed to change working directory to ACT_CWD")
428+
}
429+
defer func() {
430+
_ = os.Chdir(originalCwd)
431+
}()
432+
}
433+
402434
// construct the `github` context
403435
var ghContext map[string]any
404436
var errGh error

0 commit comments

Comments
 (0)