From 629b7e13270c10de027b5b04e94e73c59a0dbadd Mon Sep 17 00:00:00 2001 From: Sebastian Rath Date: Wed, 8 Apr 2026 23:11:42 -0400 Subject: [PATCH 01/10] Propagate P4CLIENT and BUILD_REF to agent subprocess environment - Add P4Client field to CheckoutResult so the worker can pass the workspace name to graph execution - Set P4CLIENT and BUILD_REF in the subprocess env - Add CredentialTypeP4 to core credential types --- agent/vcs/p4.go | 4 ++-- agent/vcs/vcs.go | 4 ++++ agent/worker.go | 4 ++++ core/base.go | 1 + 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/agent/vcs/p4.go b/agent/vcs/p4.go index 587cac7..59038c6 100644 --- a/agent/vcs/p4.go +++ b/agent/vcs/p4.go @@ -93,7 +93,7 @@ func (p *P4Provider) Checkout(ctx context.Context, url, ref, pipeline, destDir s return CheckoutResult{}, fmt.Errorf("p4 sync pipeline file failed: %w", err) } - return CheckoutResult{Dir: root, Persistent: true}, nil + return CheckoutResult{Dir: root, Persistent: true, P4Client: p.clientName}, nil } // Create temporary workspace @@ -132,7 +132,7 @@ func (p *P4Provider) Checkout(ctx context.Context, url, ref, pipeline, destDir s return CheckoutResult{}, fmt.Errorf("p4 sync pipeline file failed: %w", err) } - return CheckoutResult{Dir: absDir}, nil + return CheckoutResult{Dir: absDir, P4Client: p.clientName}, nil } func (p *P4Provider) Cleanup(ctx context.Context) error { diff --git a/agent/vcs/vcs.go b/agent/vcs/vcs.go index 9be4289..419a1ce 100644 --- a/agent/vcs/vcs.go +++ b/agent/vcs/vcs.go @@ -33,6 +33,10 @@ type CheckoutResult struct { Persistent bool // SHA is the resolved commit SHA (or changelist number for P4) after checkout. SHA string + // P4Client is the Perforce workspace name created or reused during checkout. + // The worker should set P4CLIENT in the subprocess environment so that + // p4 commands within the graph can operate on the same workspace. + P4Client string } // Provider handles VCS checkout operations. diff --git a/agent/worker.go b/agent/worker.go index 58f4b21..405307a 100644 --- a/agent/worker.go +++ b/agent/worker.go @@ -358,12 +358,16 @@ func (w *Worker) execute(ctx context.Context, job *ClaimResponse) { env = append(env, "BUILD_TMPDIR="+tmpDir) env = append(env, "BUILD_VCS_TYPE="+job.VCSType) env = append(env, "BUILD_VCS_URL="+job.VCSURL) + env = append(env, "BUILD_REF="+ref) if job.RepoID != "" { env = append(env, "BUILD_REPO_ID="+job.RepoID) } if checkout.SHA != "" { env = append(env, "BUILD_COMMIT_SHA="+checkout.SHA) } + if checkout.P4Client != "" { + env = append(env, "P4CLIENT="+checkout.P4Client) + } // Resolve env mappings from trigger config (if present) if len(job.EnvMappings) > 0 && job.MatrixValues != nil { diff --git a/core/base.go b/core/base.go index e7ba1bc..ad802b1 100644 --- a/core/base.go +++ b/core/base.go @@ -49,6 +49,7 @@ const ( CredentialTypeSSH CredentialType = iota CredentialTypeUsernamePassword CredentialTypeAccessKey + CredentialTypeP4 ) type Credentials interface { From 562497a09b7e31e3423a129287351bc05a115602 Mon Sep 17 00:00:00 2001 From: Sebastian Rath Date: Wed, 8 Apr 2026 23:12:02 -0400 Subject: [PATCH 02/10] Add P4 nodes: p4-credentials, p4-sync, p4-print, p4-run All nodes use the p4go library (requires -tags p4 build flag). Credentials are resolved from node inputs with fallback to P4PORT, P4USER, P4PASSWD, P4TRUST, P4CLIENT environment variables. SSL trust is handled automatically via p4go before each operation. --- nodes/p4-credentials@v1.go | 48 ++++++++++ nodes/p4-credentials@v1.yml | 59 ++++++++++++ nodes/p4-print@v1.go | 73 +++++++++++++++ nodes/p4-print@v1.yml | 53 +++++++++++ nodes/p4-run@v1.go | 65 ++++++++++++++ nodes/p4-run@v1.yml | 64 +++++++++++++ nodes/p4-sync@v1.go | 71 +++++++++++++++ nodes/p4-sync@v1.yml | 65 ++++++++++++++ nodes/p4-utils.go | 174 ++++++++++++++++++++++++++++++++++++ 9 files changed, 672 insertions(+) create mode 100644 nodes/p4-credentials@v1.go create mode 100644 nodes/p4-credentials@v1.yml create mode 100644 nodes/p4-print@v1.go create mode 100644 nodes/p4-print@v1.yml create mode 100644 nodes/p4-run@v1.go create mode 100644 nodes/p4-run@v1.yml create mode 100644 nodes/p4-sync@v1.go create mode 100644 nodes/p4-sync@v1.yml create mode 100644 nodes/p4-utils.go diff --git a/nodes/p4-credentials@v1.go b/nodes/p4-credentials@v1.go new file mode 100644 index 0000000..818e5b3 --- /dev/null +++ b/nodes/p4-credentials@v1.go @@ -0,0 +1,48 @@ +//go:build p4 + +package nodes + +import ( + _ "embed" + + "github.com/actionforge/actrun-cli/core" + ni "github.com/actionforge/actrun-cli/node_interfaces" +) + +//go:embed p4-credentials@v1.yml +var p4CredentialsDefinition string + +type P4CredentialsNode struct { + core.NodeBaseComponent + core.Inputs + core.Outputs +} + +func (n *P4CredentialsNode) OutputValueById(c *core.ExecutionState, outputId core.OutputId) (any, error) { + if outputId != ni.Core_p4_credentials_v1_Output_credential { + return nil, core.CreateErr(c, nil, "unknown output id '%s'", outputId) + } + + port, _ := core.InputValueById[core.SecretValue](c, n, ni.Core_p4_credentials_v1_Input_port) + user, _ := core.InputValueById[core.SecretValue](c, n, ni.Core_p4_credentials_v1_Input_user) + password, _ := core.InputValueById[core.SecretValue](c, n, ni.Core_p4_credentials_v1_Input_password) + trust, _ := core.InputValueById[string](c, n, ni.Core_p4_credentials_v1_Input_trust) + client, _ := core.InputValueById[string](c, n, ni.Core_p4_credentials_v1_Input_client) + + return P4Credentials{ + Port: port.Secret, + User: user.Secret, + Password: password.Secret, + Trust: trust, + Client: client, + }, nil +} + +func init() { + err := core.RegisterNodeFactory(p4CredentialsDefinition, func(ctx any, parent core.NodeBaseInterface, parentId string, nodeDef map[string]any, validate bool, opts core.RunOpts) (core.NodeBaseInterface, []error) { + return &P4CredentialsNode{}, nil + }) + if err != nil { + panic(err) + } +} diff --git a/nodes/p4-credentials@v1.yml b/nodes/p4-credentials@v1.yml new file mode 100644 index 0000000..e012cb0 --- /dev/null +++ b/nodes/p4-credentials@v1.yml @@ -0,0 +1,59 @@ +yaml-version: 3.0 +id: core/p4-credentials +name: P4 Credentials +version: 1 +category: perforce +icon: tablerServer +style: + header: + background: "#07a3ee" + color: "#ffffff" + body: + background: "#055f8a" +short_desc: Bundles Perforce P4 connection details into a reusable credential object. +addendum: | + This data node holds Perforce P4 server connection details and outputs them as a credential object. + + Connect the `credential` output to P4 nodes that accept a `credentials` input. + + When fields are left empty, the corresponding P4 environment variables are used as fallback at execution time. +compact: true +outputs: + credential: + type: credentials + desc: A credential object containing the Perforce P4 connection details. + index: 0 +inputs: + port: + type: secret + name: P4PORT + hint: "ssl:perforce.example.com:1666" + desc: The Perforce server address. Falls back to P4PORT env var if empty. + hide_socket: true + index: 0 + user: + type: secret + name: P4USER + hint: "joe" + desc: The Perforce username. Falls back to P4USER env var if empty. + hide_socket: true + index: 1 + password: + type: secret + name: P4PASSWD + desc: The Perforce password or ticket. Falls back to P4PASSWD env var if empty. + hide_socket: true + index: 2 + trust: + type: string + name: P4TRUST + desc: Path to the P4 trust file. Falls back to P4TRUST env var if empty. + optional: true + index: 3 + client: + type: string + name: P4CLIENT + hint: "my-workspace" + desc: The Perforce workspace/client name. Falls back to P4CLIENT env var if empty. + optional: true + index: 4 diff --git a/nodes/p4-print@v1.go b/nodes/p4-print@v1.go new file mode 100644 index 0000000..fbdb4cc --- /dev/null +++ b/nodes/p4-print@v1.go @@ -0,0 +1,73 @@ +//go:build p4 + +package nodes + +import ( + _ "embed" + "os" + "path/filepath" + + "github.com/actionforge/actrun-cli/core" + ni "github.com/actionforge/actrun-cli/node_interfaces" +) + +//go:embed p4-print@v1.yml +var p4PrintDefinition string + +type P4PrintNode struct { + core.NodeBaseComponent + core.Executions + core.Inputs + core.Outputs +} + +func (n *P4PrintNode) ExecuteImpl(c *core.ExecutionState, inputId core.InputId, prevError error) error { + depotPath, err := core.InputValueById[string](c, n, ni.Core_p4_print_v1_Input_depot_path) + if err != nil { + return err + } + + outputPath, err := core.InputValueById[string](c, n, ni.Core_p4_print_v1_Input_output_path) + if err != nil { + return err + } + + creds, _ := core.InputValueById[core.Credentials](c, n, ni.Core_p4_print_v1_Input_credentials) + + fields := buildP4Fields(c, creds) + + p4, err := connectP4(c, fields) + if err != nil { + return n.Execute(ni.Core_p4_print_v1_Output_exec_err, c, err) + } + defer func() { + p4.Disconnect() + p4.Close() + }() + + // Ensure parent directory exists + if dir := filepath.Dir(outputPath); dir != "" { + if err := os.MkdirAll(dir, 0755); err != nil { + mkdirErr := core.CreateErr(c, err, "failed to create output directory '%s'", dir) + return n.Execute(ni.Core_p4_print_v1_Output_exec_err, c, mkdirErr) + } + } + + // p4 print -q -o + _, runErr := p4.Run("print", "-q", "-o", outputPath, depotPath) + if runErr != nil { + printErr := core.CreateErr(c, runErr, "p4 print failed for %s", depotPath) + return n.Execute(ni.Core_p4_print_v1_Output_exec_err, c, printErr) + } + + return n.Execute(ni.Core_p4_print_v1_Output_exec_success, c, nil) +} + +func init() { + err := core.RegisterNodeFactory(p4PrintDefinition, func(ctx any, parent core.NodeBaseInterface, parentId string, nodeDef map[string]any, validate bool, opts core.RunOpts) (core.NodeBaseInterface, []error) { + return &P4PrintNode{}, nil + }) + if err != nil { + panic(err) + } +} diff --git a/nodes/p4-print@v1.yml b/nodes/p4-print@v1.yml new file mode 100644 index 0000000..0dcb172 --- /dev/null +++ b/nodes/p4-print@v1.yml @@ -0,0 +1,53 @@ +yaml-version: 3.0 +id: core/p4-print +name: P4 Print +version: 1 +category: perforce +icon: tablerFileDownload +style: + header: + background: "#07a3ee" + color: "#ffffff" + body: + background: "#055f8a" +short_desc: Downloads a file from the Perforce P4 depot without requiring a workspace. +addendum: | + Runs `p4 print -q -o ` to download a single file from the depot directly to disk. + + This does not require a workspace (P4CLIENT) to be set, making it useful for fetching individual files. + + For SSL servers, `p4 trust -y` is automatically executed before the command. +outputs: + exec-success: + name: Success + exec: true + desc: Executes if the file is downloaded successfully. + index: 0 + exec-err: + name: Error + exec: true + desc: Executes if there is an error downloading the file. + index: 1 +inputs: + exec: + exec: true + index: 0 + depot_path: + type: string + name: Depot Path + hint: "//depot/project/README.md" + desc: The depot path of the file to download. + index: 1 + required: true + output_path: + type: string + name: Output Path + desc: The local file path where the downloaded file will be saved. + index: 2 + required: true + credentials: + type: credentials + name: Credentials + desc: Optional P4 credentials. If not provided, falls back to P4 environment variables. + index: 3 + optional: true diff --git a/nodes/p4-run@v1.go b/nodes/p4-run@v1.go new file mode 100644 index 0000000..a52d0fd --- /dev/null +++ b/nodes/p4-run@v1.go @@ -0,0 +1,65 @@ +//go:build p4 + +package nodes + +import ( + _ "embed" + + "github.com/actionforge/actrun-cli/core" + ni "github.com/actionforge/actrun-cli/node_interfaces" +) + +//go:embed p4-run@v1.yml +var p4RunDefinition string + +type P4RunNode struct { + core.NodeBaseComponent + core.Executions + core.Inputs + core.Outputs +} + +func (n *P4RunNode) ExecuteImpl(c *core.ExecutionState, inputId core.InputId, prevError error) error { + command, err := core.InputValueById[string](c, n, ni.Core_p4_run_v1_Input_command) + if err != nil { + return err + } + + extraArgs, _ := core.InputValueById[[]string](c, n, ni.Core_p4_run_v1_Input_args) + creds, _ := core.InputValueById[core.Credentials](c, n, ni.Core_p4_run_v1_Input_credentials) + + fields := buildP4Fields(c, creds) + + p4, err := connectP4(c, fields) + if err != nil { + _ = n.SetOutputValue(c, ni.Core_p4_run_v1_Output_exit_code, 1, core.SetOutputValueOpts{}) + return n.Execute(ni.Core_p4_run_v1_Output_exec_err, c, err) + } + defer func() { + p4.Disconnect() + p4.Close() + }() + + results, runErr := p4.Run(command, extraArgs...) + output := formatP4Results(results) + + _ = n.SetOutputValue(c, ni.Core_p4_run_v1_Output_output, output, core.SetOutputValueOpts{}) + + if runErr != nil { + _ = n.SetOutputValue(c, ni.Core_p4_run_v1_Output_exit_code, 1, core.SetOutputValueOpts{}) + cmdErr := core.CreateErr(c, runErr, "p4 %s failed", command) + return n.Execute(ni.Core_p4_run_v1_Output_exec_err, c, cmdErr) + } + + _ = n.SetOutputValue(c, ni.Core_p4_run_v1_Output_exit_code, 0, core.SetOutputValueOpts{}) + return n.Execute(ni.Core_p4_run_v1_Output_exec_success, c, nil) +} + +func init() { + err := core.RegisterNodeFactory(p4RunDefinition, func(ctx any, parent core.NodeBaseInterface, parentId string, nodeDef map[string]any, validate bool, opts core.RunOpts) (core.NodeBaseInterface, []error) { + return &P4RunNode{}, nil + }) + if err != nil { + panic(err) + } +} diff --git a/nodes/p4-run@v1.yml b/nodes/p4-run@v1.yml new file mode 100644 index 0000000..cac6a14 --- /dev/null +++ b/nodes/p4-run@v1.yml @@ -0,0 +1,64 @@ +yaml-version: 3.0 +id: core/p4-run +name: P4 Run +version: 1 +category: perforce +icon: tablerTerminal +style: + header: + background: "#07a3ee" + color: "#ffffff" + body: + background: "#055f8a" +short_desc: Runs an arbitrary Perforce P4 command. +addendum: | + Executes any Perforce command via the `p4` CLI. Use this for commands not covered by dedicated P4 nodes (e.g. `p4 files`, `p4 changes`, `p4 client`). + + The `command` input is the p4 subcommand (e.g. "files", "changes", "describe"). + Additional arguments go into the `args` input. + + For SSL servers, `p4 trust -y` is automatically executed before the command. +outputs: + exec-success: + name: Success + exec: true + desc: Executes if the command succeeds (exit code 0). + index: 0 + exec-err: + name: Error + exec: true + desc: Executes if the command fails (non-zero exit code). + index: 1 + output: + type: string + name: Output + desc: The combined stdout/stderr output from the p4 command. + index: 2 + exit_code: + name: Exit Code + type: number + desc: The exit code of the p4 command. + index: 3 +inputs: + exec: + exec: true + index: 0 + command: + type: string + name: Command + hint: "files" + desc: The p4 subcommand to run (e.g. files, changes, describe, client). + index: 1 + required: true + args: + name: Args + type: "[]string" + desc: Additional arguments for the p4 command. + index: 2 + optional: true + credentials: + type: credentials + name: Credentials + desc: Optional P4 credentials. If not provided, falls back to P4 environment variables. + index: 3 + optional: true diff --git a/nodes/p4-sync@v1.go b/nodes/p4-sync@v1.go new file mode 100644 index 0000000..482d6a2 --- /dev/null +++ b/nodes/p4-sync@v1.go @@ -0,0 +1,71 @@ +//go:build p4 + +package nodes + +import ( + _ "embed" + + "github.com/actionforge/actrun-cli/core" + ni "github.com/actionforge/actrun-cli/node_interfaces" +) + +//go:embed p4-sync@v1.yml +var p4SyncDefinition string + +type P4SyncNode struct { + core.NodeBaseComponent + core.Executions + core.Inputs + core.Outputs +} + +func (n *P4SyncNode) ExecuteImpl(c *core.ExecutionState, inputId core.InputId, prevError error) error { + depotPath, err := core.InputValueById[string](c, n, ni.Core_p4_sync_v1_Input_depot_path) + if err != nil { + return err + } + + creds, _ := core.InputValueById[core.Credentials](c, n, ni.Core_p4_sync_v1_Input_credentials) + client, _ := core.InputValueById[string](c, n, ni.Core_p4_sync_v1_Input_client) + force, _ := core.InputValueById[bool](c, n, ni.Core_p4_sync_v1_Input_force) + + fields := buildP4Fields(c, creds) + if client != "" { + fields.Client = client + } + + p4, err := connectP4(c, fields) + if err != nil { + return n.Execute(ni.Core_p4_sync_v1_Output_exec_err, c, err) + } + defer func() { + p4.Disconnect() + p4.Close() + }() + + var syncArgs []string + if force { + syncArgs = append(syncArgs, "-f") + } + syncArgs = append(syncArgs, depotPath) + + results, runErr := p4.Run("sync", syncArgs...) + output := formatP4Results(results) + + _ = n.SetOutputValue(c, ni.Core_p4_sync_v1_Output_output, output, core.SetOutputValueOpts{}) + + if runErr != nil { + syncErr := core.CreateErr(c, runErr, "p4 sync failed") + return n.Execute(ni.Core_p4_sync_v1_Output_exec_err, c, syncErr) + } + return n.Execute(ni.Core_p4_sync_v1_Output_exec_success, c, nil) +} + +func init() { + err := core.RegisterNodeFactory(p4SyncDefinition, func(ctx any, parent core.NodeBaseInterface, parentId string, nodeDef map[string]any, validate bool, opts core.RunOpts) (core.NodeBaseInterface, []error) { + return &P4SyncNode{}, nil + }) + if err != nil { + panic(err) + } +} diff --git a/nodes/p4-sync@v1.yml b/nodes/p4-sync@v1.yml new file mode 100644 index 0000000..ebbfbb2 --- /dev/null +++ b/nodes/p4-sync@v1.yml @@ -0,0 +1,65 @@ +yaml-version: 3.0 +id: core/p4-sync +name: P4 Sync +version: 1 +category: perforce +icon: tablerRefresh +style: + header: + background: "#07a3ee" + color: "#ffffff" + body: + background: "#055f8a" +short_desc: Syncs files from a Perforce P4 depot path. +addendum: | + Runs `p4 sync` to download files from a depot path to the local workspace. + + Requires a Perforce workspace (P4CLIENT) to be set, either via the credentials input, environment variable, or the `client` input. + + For SSL servers, `p4 trust -y` is automatically executed before syncing. +outputs: + exec-success: + name: Success + exec: true + desc: Executes if the sync operation is successful. + index: 0 + exec-err: + name: Error + exec: true + desc: Executes if there is an error during sync. + index: 1 + output: + type: string + name: Output + desc: The stdout/stderr output from the p4 sync command. + index: 2 +inputs: + exec: + exec: true + index: 0 + depot_path: + type: string + name: Depot Path + hint: "//depot/..." + desc: The Perforce depot path to sync (e.g. //depot/project/...). + index: 1 + required: true + credentials: + type: credentials + name: Credentials + desc: Optional P4 credentials. If not provided, falls back to P4PORT/P4USER/P4PASSWD environment variables. + index: 2 + optional: true + client: + type: string + name: P4CLIENT + desc: The Perforce workspace name. Overrides the value from credentials or P4CLIENT env var. + index: 3 + optional: true + force: + type: bool + name: Force + desc: If true, forces the sync (-f flag), re-downloading all files regardless of have list. + index: 4 + optional: true + default: false diff --git a/nodes/p4-utils.go b/nodes/p4-utils.go new file mode 100644 index 0000000..a4972e8 --- /dev/null +++ b/nodes/p4-utils.go @@ -0,0 +1,174 @@ +//go:build p4 + +package nodes + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/actionforge/actrun-cli/core" + p4go "github.com/perforce/p4go" +) + +// P4Credentials bundles Perforce connection details into a credential object. +type P4Credentials struct { + Port string + User string + Password string + Trust string + Client string +} + +func (p P4Credentials) Type() core.CredentialType { + return core.CredentialTypeP4 +} + +// p4EnvFields holds resolved P4 connection details from credentials and/or environment. +type p4EnvFields struct { + Port string + User string + Password string + Trust string + Client string +} + +// buildP4Fields resolves P4 connection details from credentials (if provided) +// with fallback to the graph/OS environment for any empty field. +func buildP4Fields(c *core.ExecutionState, creds core.Credentials) p4EnvFields { + var p4 P4Credentials + if creds != nil { + if pc, ok := creds.(P4Credentials); ok { + p4 = pc + } + } + + resolve := func(credVal, envKey string) string { + if credVal != "" { + return credVal + } + return envOrOs(c, envKey) + } + + return p4EnvFields{ + Port: resolve(p4.Port, "P4PORT"), + User: resolve(p4.User, "P4USER"), + Password: resolve(p4.Password, "P4PASSWD"), + Trust: resolve(p4.Trust, "P4TRUST"), + Client: resolve(p4.Client, "P4CLIENT"), + } +} + +// connectP4 creates a p4go client, establishes trust for SSL, authenticates, +// and returns the connected client. Caller must call p4.Disconnect() and p4.Close(). +func connectP4(c *core.ExecutionState, fields p4EnvFields) (*p4go.P4, error) { + p4 := p4go.New() + + if fields.Port == "" { + return nil, core.CreateErr(c, nil, "P4PORT is not set. Provide it via credentials or environment variable.") + } + p4.SetPort(fields.Port) + + if fields.User != "" { + p4.SetUser(fields.User) + } + if fields.Password != "" { + p4.SetPassword(fields.Password) + } + if fields.Client != "" { + p4.SetClient(fields.Client) + } + + // SSL trust handling + if strings.HasPrefix(fields.Port, "ssl:") { + trustFile := fields.Trust + if trustFile == "" { + trustFile = filepath.Join(os.TempDir(), ".p4trust") + } + p4.SetTrustFile(trustFile) + } + + connected, err := p4.Connect() + if !connected { + p4.Close() + return nil, core.CreateErr(c, err, "failed to connect to Perforce server at %s", fields.Port) + } + + // Accept SSL fingerprint + if strings.HasPrefix(fields.Port, "ssl:") { + p4.Run("trust", "-y") + } + + // Authenticate if password is set + if fields.Password != "" { + if _, err := p4.RunLogin(); err != nil { + p4.Disconnect() + p4.Close() + return nil, core.CreateErr(c, err, "P4 login failed for user %s", fields.User) + } + } + + return p4, nil +} + +// runP4Cmd connects, runs a p4 command, and returns the output. +func runP4Cmd(c *core.ExecutionState, fields p4EnvFields, cmd string, args ...string) (string, error) { + p4, err := connectP4(c, fields) + if err != nil { + return "", err + } + defer func() { + p4.Disconnect() + p4.Close() + }() + + results, err := p4.Run(cmd, args...) + if err != nil { + return "", core.CreateErr(c, err, "p4 %s failed", cmd) + } + + return formatP4Results(results), nil +} + +// formatP4Results converts p4go results to a human-readable string. +func formatP4Results(results []p4go.P4Result) string { + var lines []string + for _, r := range results { + switch v := r.(type) { + case p4go.P4Data: + if s := strings.TrimSpace(string(v)); s != "" { + lines = append(lines, s) + } + case p4go.Dictionary: + line := formatP4Dict(v) + if line != "" { + lines = append(lines, line) + } + case p4go.P4Message: + if s := v.String(); s != "" { + lines = append(lines, strings.TrimSpace(s)) + } + default: + lines = append(lines, fmt.Sprintf("%v", r)) + } + } + return strings.Join(lines, "\n") +} + +// formatP4Dict formats a p4go Dictionary into a human-readable line. +func formatP4Dict(d p4go.Dictionary) string { + if depotFile, ok := d["depotFile"]; ok { + rev, _ := d["rev"] + action, _ := d["action"] + change, _ := d["change"] + fileType, _ := d["type"] + return fmt.Sprintf("%v#%v - %v change %v (%v)", depotFile, rev, action, change, fileType) + } + // Generic fallback + var parts []string + for k, v := range d { + parts = append(parts, fmt.Sprintf("%s=%v", k, v)) + } + return strings.Join(parts, " ") +} From 6fb6dc1d52fc57e456e5682ceeda0b3d5d92ca63 Mon Sep 17 00:00:00 2001 From: Sebastian Rath Date: Wed, 8 Apr 2026 23:12:10 -0400 Subject: [PATCH 03/10] Generate node interface stubs for P4 nodes --- .../interface_core_p4-credentials_v1.go | 23 ++++++++++++++++ node_interfaces/interface_core_p4-print_v1.go | 22 ++++++++++++++++ node_interfaces/interface_core_p4-run_v1.go | 26 +++++++++++++++++++ node_interfaces/interface_core_p4-sync_v1.go | 26 +++++++++++++++++++ 4 files changed, 97 insertions(+) create mode 100644 node_interfaces/interface_core_p4-credentials_v1.go create mode 100644 node_interfaces/interface_core_p4-print_v1.go create mode 100644 node_interfaces/interface_core_p4-run_v1.go create mode 100644 node_interfaces/interface_core_p4-sync_v1.go diff --git a/node_interfaces/interface_core_p4-credentials_v1.go b/node_interfaces/interface_core_p4-credentials_v1.go new file mode 100644 index 0000000..0f700b0 --- /dev/null +++ b/node_interfaces/interface_core_p4-credentials_v1.go @@ -0,0 +1,23 @@ +// Code generated by actrun. DO NOT EDIT. + +package node_interfaces + +import "github.com/actionforge/actrun-cli/core" // Bundles Perforce P4 connection details into a reusable credential object. + +// ==> (o) Inputs + +// The Perforce workspace/client name. Falls back to P4CLIENT env var if empty. +const Core_p4_credentials_v1_Input_client core.InputId = "client" +// The Perforce password or ticket. Falls back to P4PASSWD env var if empty. +const Core_p4_credentials_v1_Input_password core.InputId = "password" +// The Perforce server address. Falls back to P4PORT env var if empty. +const Core_p4_credentials_v1_Input_port core.InputId = "port" +// Path to the P4 trust file. Falls back to P4TRUST env var if empty. +const Core_p4_credentials_v1_Input_trust core.InputId = "trust" +// The Perforce username. Falls back to P4USER env var if empty. +const Core_p4_credentials_v1_Input_user core.InputId = "user" + +// Outputs (o) ==> + +// A credential object containing the Perforce P4 connection details. +const Core_p4_credentials_v1_Output_credential core.OutputId = "credential" diff --git a/node_interfaces/interface_core_p4-print_v1.go b/node_interfaces/interface_core_p4-print_v1.go new file mode 100644 index 0000000..3031a85 --- /dev/null +++ b/node_interfaces/interface_core_p4-print_v1.go @@ -0,0 +1,22 @@ +// Code generated by actrun. DO NOT EDIT. + +package node_interfaces + +import "github.com/actionforge/actrun-cli/core" // Downloads a file from the Perforce P4 depot without requiring a workspace. + +// ==> (o) Inputs + +// Optional P4 credentials. If not provided, falls back to P4 environment variables. +const Core_p4_print_v1_Input_credentials core.InputId = "credentials" +// The depot path of the file to download. +const Core_p4_print_v1_Input_depot_path core.InputId = "depot_path" +const Core_p4_print_v1_Input_exec core.InputId = "exec" +// The local file path where the downloaded file will be saved. +const Core_p4_print_v1_Input_output_path core.InputId = "output_path" + +// Outputs (o) ==> + +// Executes if there is an error downloading the file. +const Core_p4_print_v1_Output_exec_err core.OutputId = "exec-err" +// Executes if the file is downloaded successfully. +const Core_p4_print_v1_Output_exec_success core.OutputId = "exec-success" diff --git a/node_interfaces/interface_core_p4-run_v1.go b/node_interfaces/interface_core_p4-run_v1.go new file mode 100644 index 0000000..f7e7d9b --- /dev/null +++ b/node_interfaces/interface_core_p4-run_v1.go @@ -0,0 +1,26 @@ +// Code generated by actrun. DO NOT EDIT. + +package node_interfaces + +import "github.com/actionforge/actrun-cli/core" // Runs an arbitrary Perforce P4 command. + +// ==> (o) Inputs + +// Additional arguments for the p4 command. +const Core_p4_run_v1_Input_args core.InputId = "args" +// The p4 subcommand to run (e.g. files, changes, describe, client). +const Core_p4_run_v1_Input_command core.InputId = "command" +// Optional P4 credentials. If not provided, falls back to P4 environment variables. +const Core_p4_run_v1_Input_credentials core.InputId = "credentials" +const Core_p4_run_v1_Input_exec core.InputId = "exec" + +// Outputs (o) ==> + +// Executes if the command fails (non-zero exit code). +const Core_p4_run_v1_Output_exec_err core.OutputId = "exec-err" +// Executes if the command succeeds (exit code 0). +const Core_p4_run_v1_Output_exec_success core.OutputId = "exec-success" +// The exit code of the p4 command. +const Core_p4_run_v1_Output_exit_code core.OutputId = "exit_code" +// The combined stdout/stderr output from the p4 command. +const Core_p4_run_v1_Output_output core.OutputId = "output" diff --git a/node_interfaces/interface_core_p4-sync_v1.go b/node_interfaces/interface_core_p4-sync_v1.go new file mode 100644 index 0000000..aa8cad2 --- /dev/null +++ b/node_interfaces/interface_core_p4-sync_v1.go @@ -0,0 +1,26 @@ +// Code generated by actrun. DO NOT EDIT. + +package node_interfaces + +import "github.com/actionforge/actrun-cli/core" // Syncs files from a Perforce P4 depot path. + +// ==> (o) Inputs + +// The Perforce workspace name. Overrides the value from credentials or P4CLIENT env var. +const Core_p4_sync_v1_Input_client core.InputId = "client" +// Optional P4 credentials. If not provided, falls back to P4PORT/P4USER/P4PASSWD environment variables. +const Core_p4_sync_v1_Input_credentials core.InputId = "credentials" +// The Perforce depot path to sync (e.g. //depot/project/...). +const Core_p4_sync_v1_Input_depot_path core.InputId = "depot_path" +const Core_p4_sync_v1_Input_exec core.InputId = "exec" +// If true, forces the sync (-f flag), re-downloading all files regardless of have list. +const Core_p4_sync_v1_Input_force core.InputId = "force" + +// Outputs (o) ==> + +// Executes if there is an error during sync. +const Core_p4_sync_v1_Output_exec_err core.OutputId = "exec-err" +// Executes if the sync operation is successful. +const Core_p4_sync_v1_Output_exec_success core.OutputId = "exec-success" +// The stdout/stderr output from the p4 sync command. +const Core_p4_sync_v1_Output_output core.OutputId = "output" From 017cf5e505abec36d1f48237392bb299d21a183d Mon Sep 17 00:00:00 2001 From: Sebastian Rath Date: Wed, 8 Apr 2026 23:12:24 -0400 Subject: [PATCH 04/10] Add P4 connect e2e test against public demo server --- .../references/reference_p4_connect.sh_l5 | 3 ++ tests_e2e/scripts/p4_connect.act | 47 +++++++++++++++++++ tests_e2e/scripts/p4_connect.sh | 5 ++ 3 files changed, 55 insertions(+) create mode 100644 tests_e2e/references/reference_p4_connect.sh_l5 create mode 100644 tests_e2e/scripts/p4_connect.act create mode 100644 tests_e2e/scripts/p4_connect.sh diff --git a/tests_e2e/references/reference_p4_connect.sh_l5 b/tests_e2e/references/reference_p4_connect.sh_l5 new file mode 100644 index 0000000..f5dd617 --- /dev/null +++ b/tests_e2e/references/reference_p4_connect.sh_l5 @@ -0,0 +1,3 @@ +//depot/pong/background.jpg#1 - add change 3 (binary+F) +//depot/pong/build-pong-game.act#1 - add change 3 (text) +//depot/pong/tutorial-readme.md#1 - add change 3 (text) diff --git a/tests_e2e/scripts/p4_connect.act b/tests_e2e/scripts/p4_connect.act new file mode 100644 index 0000000..eacec3d --- /dev/null +++ b/tests_e2e/scripts/p4_connect.act @@ -0,0 +1,47 @@ +editor: + version: + created: v1.0.0 +entry: start +type: orchestrator +nodes: + - id: start + type: core/start@v1 + position: + x: 0 + y: 0 + - id: list-files + type: core/p4-run@v1 + position: + x: 200 + y: 0 + inputs: + command: files + args: + - "//depot/pong/..." + - id: print-result + type: core/print@v1 + position: + x: 300 + y: 0 + inputs: + values[0]: null +connections: + - src: + node: list-files + port: output + dst: + node: print-result + port: "values[0]" +executions: + - src: + node: start + port: exec + dst: + node: list-files + port: exec + - src: + node: list-files + port: exec-success + dst: + node: print-result + port: exec diff --git a/tests_e2e/scripts/p4_connect.sh b/tests_e2e/scripts/p4_connect.sh new file mode 100644 index 0000000..903f3f1 --- /dev/null +++ b/tests_e2e/scripts/p4_connect.sh @@ -0,0 +1,5 @@ +export P4PORT=ssl:p4demo.actionforge.dev:1666 +export P4USER=guest +export P4PASSWD=readonly1 + +#! test actrun ${ACT_GRAPH_FILES_DIR}${PATH_SEPARATOR}p4_connect.act From c8e03f292b9bc370d68ae2fe799cf5ffacdfbd84 Mon Sep 17 00:00:00 2001 From: Sebastian Rath Date: Wed, 8 Apr 2026 23:31:21 -0400 Subject: [PATCH 05/10] Make P4 temp workspace persistent for graph execution The worker must run in the workspace root so p4 sync in graph nodes can resolve paths correctly. Without Persistent: true, the worker copies the script to a separate work/ dir and deletes the checkout. --- agent/vcs/p4.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent/vcs/p4.go b/agent/vcs/p4.go index 59038c6..a76c37f 100644 --- a/agent/vcs/p4.go +++ b/agent/vcs/p4.go @@ -132,7 +132,7 @@ func (p *P4Provider) Checkout(ctx context.Context, url, ref, pipeline, destDir s return CheckoutResult{}, fmt.Errorf("p4 sync pipeline file failed: %w", err) } - return CheckoutResult{Dir: absDir, P4Client: p.clientName}, nil + return CheckoutResult{Dir: absDir, Persistent: true, P4Client: p.clientName}, nil } func (p *P4Provider) Cleanup(ctx context.Context) error { From 18a90c9004e7d2e2fecdf3d7733dcd685cb4136f Mon Sep 17 00:00:00 2001 From: Sebastian Rath Date: Thu, 9 Apr 2026 10:00:33 -0400 Subject: [PATCH 06/10] Add --labels flag, fix slot acquisition, fix p4_connect.act schema - Add --labels CLI flag for agent runtime label declaration - Send labels in heartbeat for server-side seeding - Cap slot acquisition at 256 with ephemeral UUID fallback - Fix p4_connect.act type from 'orchestrator' to 'generic' --- agent/models.go | 1 + agent/worker.go | 8 +++++--- cmd/cmd_agent.go | 4 +++- tests_e2e/scripts/p4_connect.act | 2 +- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/agent/models.go b/agent/models.go index 0b43872..b20cfe6 100644 --- a/agent/models.go +++ b/agent/models.go @@ -75,6 +75,7 @@ type StatusReport struct { // HeartbeatRequest is sent periodically with agent metrics. type HeartbeatRequest struct { UUID string `json:"uuid,omitempty"` + Labels string `json:"labels,omitempty"` CPUPercent float64 `json:"cpu_percent"` MemPercent float64 `json:"mem_percent"` MemUsedBytes int64 `json:"mem_used_bytes"` diff --git a/agent/worker.go b/agent/worker.go index 405307a..c6dd397 100644 --- a/agent/worker.go +++ b/agent/worker.go @@ -24,6 +24,7 @@ type Worker struct { client *Client docker DockerConfig vcsOpts vcs.Options + labels string pollInterval time.Duration uuid string log *logrus.Entry @@ -32,11 +33,12 @@ type Worker struct { lastCounters *RawCounters } -func NewWorker(client *Client, docker DockerConfig, vcsOpts vcs.Options) *Worker { +func NewWorker(client *Client, docker DockerConfig, vcsOpts vcs.Options, labels string) *Worker { return &Worker{ client: client, docker: docker, vcsOpts: vcsOpts, + labels: labels, pollInterval: 1 * time.Second, uuid: loadOrGenerateUUID(), log: logrus.WithField("component", "agent"), @@ -586,13 +588,13 @@ func (w *Worker) buildHeartbeatRequest() HeartbeatRequest { snap, err := Snapshot() if err != nil { w.log.WithError(err).Warn("metrics snapshot error") - return HeartbeatRequest{UUID: w.uuid} + return HeartbeatRequest{UUID: w.uuid, Labels: w.labels} } w.metricsMu.Lock() defer w.metricsMu.Unlock() - req := HeartbeatRequest{UUID: w.uuid} + req := HeartbeatRequest{UUID: w.uuid, Labels: w.labels} if w.lastCounters != nil { // CPU percent if snap.CPUInstant { diff --git a/cmd/cmd_agent.go b/cmd/cmd_agent.go index 9e0383a..b02a7a7 100644 --- a/cmd/cmd_agent.go +++ b/cmd/cmd_agent.go @@ -16,6 +16,7 @@ import ( var ( flagAgentServer string flagAgentToken string + flagAgentLabels string flagAgentDockerDisabled bool flagAgentDockerDefaultImage string flagAgentP4Client string @@ -35,6 +36,7 @@ func init() { if os.Getenv("ACT_AGENT_TOKEN") == "" { cmdAgent.MarkFlagRequired("token") } + cmdAgent.Flags().StringVar(&flagAgentLabels, "labels", envOr("ACT_AGENT_LABELS", ""), "Comma-separated labels for job matching (env: ACT_AGENT_LABELS)") cmdAgent.Flags().BoolVar(&flagAgentDockerDisabled, "docker-disabled", envOrBool("ACT_AGENT_DOCKER_DISABLED", false), "Disable Docker execution, always run natively") cmdAgent.Flags().StringVar(&flagAgentDockerDefaultImage, "docker-default-image", envOr("ACT_AGENT_DOCKER_DEFAULT_IMAGE", ""), "Force this Docker image for all scripts") cmdAgent.Flags().StringVar(&flagAgentP4Client, "p4-client", envOr("ACT_AGENT_P4CLIENT", ""), "Reuse an existing Perforce workspace instead of creating a temporary one (env: ACT_AGENT_P4CLIENT)") @@ -68,7 +70,7 @@ func cmdAgentRun(cmd *cobra.Command, args []string) { DefaultImage: flagAgentDockerDefaultImage, }, vcs.Options{ P4Client: flagAgentP4Client, - }) + }, flagAgentLabels) log.WithField("server", serverURL).Info("connecting") err := w.Run(ctx) diff --git a/tests_e2e/scripts/p4_connect.act b/tests_e2e/scripts/p4_connect.act index eacec3d..7a71c1e 100644 --- a/tests_e2e/scripts/p4_connect.act +++ b/tests_e2e/scripts/p4_connect.act @@ -2,7 +2,7 @@ editor: version: created: v1.0.0 entry: start -type: orchestrator +type: generic nodes: - id: start type: core/start@v1 From 3bbd64f3a9608e9cb991b4d4fc139efcd668594d Mon Sep 17 00:00:00 2001 From: Sebastian Rath Date: Thu, 9 Apr 2026 10:02:03 -0400 Subject: [PATCH 07/10] Track agent instances via persistent UUID slots Each agent process acquires a unique file-locked slot with a persistent UUID. The client sends X-Agent-UUID on all runner API calls so the server can distinguish multiple instances sharing the same token. --- agent/client.go | 8 +++++ agent/flock_unix.go | 16 +++++++++ agent/flock_windows.go | 45 ++++++++++++++++++++++++ agent/worker.go | 80 ++++++++++++++++++++++++++++++++---------- 4 files changed, 130 insertions(+), 19 deletions(-) create mode 100644 agent/flock_unix.go create mode 100644 agent/flock_windows.go diff --git a/agent/client.go b/agent/client.go index 64cf4a6..24253b4 100644 --- a/agent/client.go +++ b/agent/client.go @@ -12,6 +12,7 @@ import ( type Client struct { serverURL string token string + uuid string httpClient *http.Client } @@ -25,6 +26,10 @@ func NewClient(serverURL, token string) *Client { } } +func (c *Client) SetUUID(uuid string) { + c.uuid = uuid +} + func (c *Client) doRequest(method, path string, body interface{}) (*http.Response, error) { var bodyReader io.Reader if body != nil { @@ -40,6 +45,9 @@ func (c *Client) doRequest(method, path string, body interface{}) (*http.Respons return nil, err } req.Header.Set("Authorization", "Bearer "+c.token) + if c.uuid != "" { + req.Header.Set("X-Agent-UUID", c.uuid) + } if body != nil { req.Header.Set("Content-Type", "application/json") } diff --git a/agent/flock_unix.go b/agent/flock_unix.go new file mode 100644 index 0000000..2713af6 --- /dev/null +++ b/agent/flock_unix.go @@ -0,0 +1,16 @@ +//go:build !windows + +package agent + +import ( + "os" + "syscall" +) + +func lockFileExclusive(f *os.File) error { + return syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) +} + +func unlockFile(f *os.File) { + _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) +} diff --git a/agent/flock_windows.go b/agent/flock_windows.go new file mode 100644 index 0000000..e967bd0 --- /dev/null +++ b/agent/flock_windows.go @@ -0,0 +1,45 @@ +//go:build windows + +package agent + +import ( + "os" + "syscall" + "unsafe" +) + +var ( + modkernel32 = syscall.NewLazyDLL("kernel32.dll") + procLockFileEx = modkernel32.NewProc("LockFileEx") + procUnlockFileEx = modkernel32.NewProc("UnlockFileEx") +) + +const ( + lockfileExclusiveLock = 0x00000002 + lockfileFailImmediately = 0x00000001 +) + +func lockFileExclusive(f *os.File) error { + var overlapped syscall.Overlapped + r1, _, err := procLockFileEx.Call( + uintptr(f.Fd()), + uintptr(lockfileExclusiveLock|lockfileFailImmediately), + 0, + 1, 0, + uintptr(unsafe.Pointer(&overlapped)), + ) + if r1 == 0 { + return err + } + return nil +} + +func unlockFile(f *os.File) { + var overlapped syscall.Overlapped + procUnlockFileEx.Call( + uintptr(f.Fd()), + 0, + 1, 0, + uintptr(unsafe.Pointer(&overlapped)), + ) +} diff --git a/agent/worker.go b/agent/worker.go index c6dd397..be7a564 100644 --- a/agent/worker.go +++ b/agent/worker.go @@ -27,6 +27,7 @@ type Worker struct { labels string pollInterval time.Duration uuid string + slotCleanup func() log *logrus.Entry metricsMu sync.Mutex @@ -34,46 +35,84 @@ type Worker struct { } func NewWorker(client *Client, docker DockerConfig, vcsOpts vcs.Options, labels string) *Worker { + uuid, cleanup := acquireAgentSlot() + client.SetUUID(uuid) return &Worker{ client: client, docker: docker, vcsOpts: vcsOpts, labels: labels, pollInterval: 1 * time.Second, - uuid: loadOrGenerateUUID(), + uuid: uuid, + slotCleanup: cleanup, log: logrus.WithField("component", "agent"), } } -// uuidFilePath returns the path to the persistent UUID file in the user's config directory. -func uuidFilePath() string { +// agentSlotDir returns the directory for agent UUID slot files. +func agentSlotDir() string { dir, err := os.UserConfigDir() if err != nil { dir = os.TempDir() } - return filepath.Join(dir, "actionforge", "agent-uuid") + return filepath.Join(dir, "actionforge") } -// loadOrGenerateUUID loads a persistent UUID from disk, or generates and saves a new one. -func loadOrGenerateUUID() string { - path := uuidFilePath() - if data, err := os.ReadFile(path); err == nil { - if id := strings.TrimSpace(string(data)); len(id) == 36 { - return id +// acquireAgentSlot finds and locks the lowest available agent slot. +// Each slot has a persistent UUID file and a lock file. When the process +// exits, the lock is released so the next process can reuse that slot +// (and its UUID/metrics history). +// Returns the UUID and a cleanup function that releases the lock. +func acquireAgentSlot() (string, func()) { + dir := agentSlotDir() + _ = os.MkdirAll(dir, 0700) + + const maxSlots = 256 + for i := 0; i < maxSlots; i++ { + lockPath := filepath.Join(dir, fmt.Sprintf("agent-%d.lock", i)) + uuidPath := filepath.Join(dir, fmt.Sprintf("agent-%d.uuid", i)) + + lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + continue + } + + if err := lockFileExclusive(lockFile); err != nil { + lockFile.Close() + continue + } + + // Slot acquired — read or generate UUID + uuid := "" + if data, err := os.ReadFile(uuidPath); err == nil { + if id := strings.TrimSpace(string(data)); len(id) == 36 { + uuid = id + } + } + if uuid == "" { + var buf [16]byte + _, _ = rand.Read(buf[:]) + buf[6] = (buf[6] & 0x0f) | 0x40 // version 4 + buf[8] = (buf[8] & 0x3f) | 0x80 // variant 1 + uuid = fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", + buf[0:4], buf[4:6], buf[6:8], buf[8:10], buf[10:16]) + _ = os.WriteFile(uuidPath, []byte(uuid+"\n"), 0600) } + + cleanup := func() { + unlockFile(lockFile) + lockFile.Close() + } + return uuid, cleanup } - // Generate UUID v4 + // Fallback: all slots taken, generate ephemeral UUID with no lock var buf [16]byte _, _ = rand.Read(buf[:]) - buf[6] = (buf[6] & 0x0f) | 0x40 // version 4 - buf[8] = (buf[8] & 0x3f) | 0x80 // variant 1 - id := fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", - buf[0:4], buf[4:6], buf[6:8], buf[8:10], buf[10:16]) - - _ = os.MkdirAll(filepath.Dir(path), 0700) - _ = os.WriteFile(path, []byte(id+"\n"), 0600) - return id + buf[6] = (buf[6] & 0x0f) | 0x40 + buf[8] = (buf[8] & 0x3f) | 0x80 + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", + buf[0:4], buf[4:6], buf[6:8], buf[8:10], buf[10:16]), func() {} } // maxConsecutiveErrors is the number of consecutive connection errors before @@ -81,6 +120,9 @@ func loadOrGenerateUUID() string { const maxConsecutiveErrors = 10 func (w *Worker) Run(ctx context.Context) error { + if w.slotCleanup != nil { + defer w.slotCleanup() + } w.log.Info("starting") // Take initial snapshot for delta computation From 0daccc680ecbd5d18b8a8ae2626d054c18390b21 Mon Sep 17 00:00:00 2001 From: Sebastian Rath Date: Thu, 9 Apr 2026 19:55:03 -0400 Subject: [PATCH 08/10] Remove labels from HeartbeatRequest and worker struct --- agent/models.go | 1 - agent/worker.go | 8 +++----- cmd/cmd_agent.go | 4 +--- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/agent/models.go b/agent/models.go index b20cfe6..0b43872 100644 --- a/agent/models.go +++ b/agent/models.go @@ -75,7 +75,6 @@ type StatusReport struct { // HeartbeatRequest is sent periodically with agent metrics. type HeartbeatRequest struct { UUID string `json:"uuid,omitempty"` - Labels string `json:"labels,omitempty"` CPUPercent float64 `json:"cpu_percent"` MemPercent float64 `json:"mem_percent"` MemUsedBytes int64 `json:"mem_used_bytes"` diff --git a/agent/worker.go b/agent/worker.go index be7a564..7b131b4 100644 --- a/agent/worker.go +++ b/agent/worker.go @@ -24,7 +24,6 @@ type Worker struct { client *Client docker DockerConfig vcsOpts vcs.Options - labels string pollInterval time.Duration uuid string slotCleanup func() @@ -34,14 +33,13 @@ type Worker struct { lastCounters *RawCounters } -func NewWorker(client *Client, docker DockerConfig, vcsOpts vcs.Options, labels string) *Worker { +func NewWorker(client *Client, docker DockerConfig, vcsOpts vcs.Options) *Worker { uuid, cleanup := acquireAgentSlot() client.SetUUID(uuid) return &Worker{ client: client, docker: docker, vcsOpts: vcsOpts, - labels: labels, pollInterval: 1 * time.Second, uuid: uuid, slotCleanup: cleanup, @@ -630,13 +628,13 @@ func (w *Worker) buildHeartbeatRequest() HeartbeatRequest { snap, err := Snapshot() if err != nil { w.log.WithError(err).Warn("metrics snapshot error") - return HeartbeatRequest{UUID: w.uuid, Labels: w.labels} + return HeartbeatRequest{UUID: w.uuid} } w.metricsMu.Lock() defer w.metricsMu.Unlock() - req := HeartbeatRequest{UUID: w.uuid, Labels: w.labels} + req := HeartbeatRequest{UUID: w.uuid} if w.lastCounters != nil { // CPU percent if snap.CPUInstant { diff --git a/cmd/cmd_agent.go b/cmd/cmd_agent.go index b02a7a7..9e0383a 100644 --- a/cmd/cmd_agent.go +++ b/cmd/cmd_agent.go @@ -16,7 +16,6 @@ import ( var ( flagAgentServer string flagAgentToken string - flagAgentLabels string flagAgentDockerDisabled bool flagAgentDockerDefaultImage string flagAgentP4Client string @@ -36,7 +35,6 @@ func init() { if os.Getenv("ACT_AGENT_TOKEN") == "" { cmdAgent.MarkFlagRequired("token") } - cmdAgent.Flags().StringVar(&flagAgentLabels, "labels", envOr("ACT_AGENT_LABELS", ""), "Comma-separated labels for job matching (env: ACT_AGENT_LABELS)") cmdAgent.Flags().BoolVar(&flagAgentDockerDisabled, "docker-disabled", envOrBool("ACT_AGENT_DOCKER_DISABLED", false), "Disable Docker execution, always run natively") cmdAgent.Flags().StringVar(&flagAgentDockerDefaultImage, "docker-default-image", envOr("ACT_AGENT_DOCKER_DEFAULT_IMAGE", ""), "Force this Docker image for all scripts") cmdAgent.Flags().StringVar(&flagAgentP4Client, "p4-client", envOr("ACT_AGENT_P4CLIENT", ""), "Reuse an existing Perforce workspace instead of creating a temporary one (env: ACT_AGENT_P4CLIENT)") @@ -70,7 +68,7 @@ func cmdAgentRun(cmd *cobra.Command, args []string) { DefaultImage: flagAgentDockerDefaultImage, }, vcs.Options{ P4Client: flagAgentP4Client, - }, flagAgentLabels) + }) log.WithField("server", serverURL).Info("connecting") err := w.Run(ctx) From b05bd9055ab47caa8c50d3cec60c53706ae5b2ea Mon Sep 17 00:00:00 2001 From: Sebastian Rath Date: Thu, 9 Apr 2026 19:56:35 -0400 Subject: [PATCH 09/10] Handle lock file closure errors in acquireAgentSlot function --- agent/worker.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/agent/worker.go b/agent/worker.go index 7b131b4..c5226ef 100644 --- a/agent/worker.go +++ b/agent/worker.go @@ -76,7 +76,9 @@ func acquireAgentSlot() (string, func()) { } if err := lockFileExclusive(lockFile); err != nil { - lockFile.Close() + if cerr := lockFile.Close(); cerr != nil { + logrus.WithError(cerr).Warn("failed to close lock file") + } continue } @@ -99,7 +101,9 @@ func acquireAgentSlot() (string, func()) { cleanup := func() { unlockFile(lockFile) - lockFile.Close() + if cerr := lockFile.Close(); cerr != nil { + logrus.WithError(cerr).Warn("failed to close lock file") + } } return uuid, cleanup } From 32d9b02bc1ef668896968f7f65cacb59b71022df Mon Sep 17 00:00:00 2001 From: Sebastian Rath Date: Thu, 9 Apr 2026 19:59:39 -0400 Subject: [PATCH 10/10] Fix GitHub findings --- .github/workflows/workflow.yml | 12 +++++++++++- nodes/gh-action@v1.go | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index df61c09..ee102d4 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -101,7 +101,17 @@ jobs: runner-path: ${{ github.workspace }}/actrun graph-file: build-test-publish.act inputs: ${{ toJson(inputs) }} - secrets: ${{ toJson(secrets) }} + secrets: >- + { + "APPLE_P12_CERTIFICATE_PASSWORD": "${{ secrets.APPLE_P12_CERTIFICATE_PASSWORD }}", + "APPLE_P12_CERTIFICATE_BASE64": "${{ secrets.APPLE_P12_CERTIFICATE_BASE64 }}", + "TESTE2E_DO_S3_ACCESS_KEY": "${{ secrets.TESTE2E_DO_S3_ACCESS_KEY }}", + "TESTE2E_DO_S3_SECRET_KEY": "${{ secrets.TESTE2E_DO_S3_SECRET_KEY }}", + "TESTE2E_AWS_S3_ACCESS_KEY": "${{ secrets.TESTE2E_AWS_S3_ACCESS_KEY }}", + "TESTE2E_AWS_S3_SECRET_KEY": "${{ secrets.TESTE2E_AWS_S3_SECRET_KEY }}", + "PUBLISH_S3_SECRET_KEY": "${{ secrets.PUBLISH_S3_SECRET_KEY }}", + "PUBLISH_S3_ACCESS_KEY": "${{ secrets.PUBLISH_S3_ACCESS_KEY }}" + } matrix: ${{ toJson(matrix) }} docker-manifest: diff --git a/nodes/gh-action@v1.go b/nodes/gh-action@v1.go index d8e2ca1..0b89567 100644 --- a/nodes/gh-action@v1.go +++ b/nodes/gh-action@v1.go @@ -41,7 +41,7 @@ var ( // 3. ([-\w\.]+) -> Repo Name (Required) // 4. (/[^@]+)? -> Path (Optional). Matches slash followed by anything NOT an @ // 5. (@[-\w\.]+)? -> Ref/Version (Optional). Matches @ followed by chars -var nodeTypeIdRegex = regexp.MustCompile(`^(github.com/)?([-\w\.]+)/([-\w\.]+)(/[^@]+)?(@[-\w\.]+)?$`) +var nodeTypeIdRegex = regexp.MustCompile(`^(github\.com/)?([-\w\.]+)/([-\w\.]+)(/[^@]+)?(@[-\w\.]+)?$`) type ActionType int