From a8f6386170dff1fe7d410367385589fc08577502 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Jul 2026 03:26:44 +0000 Subject: [PATCH 1/4] Initial plan From 17f58d1ad592a82b8d2084025e4d674022dca8ee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Jul 2026 03:32:44 +0000 Subject: [PATCH 2/4] Plan: allow steps outputs in safe-outputs github-token Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/skills/agentic-workflows/SKILL.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/skills/agentic-workflows/SKILL.md b/.github/skills/agentic-workflows/SKILL.md index 615a51e551e..3fc711d4035 100644 --- a/.github/skills/agentic-workflows/SKILL.md +++ b/.github/skills/agentic-workflows/SKILL.md @@ -49,6 +49,7 @@ Load these files from `github/gh-aw` (they are not available locally). - `.github/aw/optimize-agentic-workflow.md` - `.github/aw/patterns.md` - `.github/aw/pr-reviewer.md` +- `.github/aw/release-workflow.md` - `.github/aw/report.md` - `.github/aw/reuse.md` - `.github/aw/safe-outputs-automation.md` From 51da0cd90e0311419100e97c8c460fe8a9ddefdd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Jul 2026 03:39:24 +0000 Subject: [PATCH 3/4] Allow steps outputs in github-token expressions Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/parser/schema_test.go | 18 ++++++++++++++++++ pkg/parser/schemas/main_workflow_schema.json | 6 +++--- pkg/workflow/github_token_validation_test.go | 15 +++++++++++++++ pkg/workflow/skills_frontmatter.go | 4 ++-- pkg/workflow/skills_frontmatter_test.go | 12 ++++++++++++ 5 files changed, 50 insertions(+), 5 deletions(-) diff --git a/pkg/parser/schema_test.go b/pkg/parser/schema_test.go index 86f389daa73..1609d00d49c 100644 --- a/pkg/parser/schema_test.go +++ b/pkg/parser/schema_test.go @@ -1227,6 +1227,24 @@ func TestMainWorkflowSchema_CreateDiscussionRequiredCategoryAllowed(t *testing.T } } +func TestMainWorkflowSchema_GitHubTokenAllowsStepOutputs(t *testing.T) { + t.Parallel() + + frontmatter := map[string]any{ + "on": "daily", + "safe-outputs": map[string]any{ + "github-token": "${{ steps.fetch_token.outputs.token }}", + "create-issue": map[string]any{ + "github-token": "${{ steps.fetch_token.outputs.token }}", + }, + }, + } + + if err := validateWithSchema(frontmatter, mainWorkflowSchema, "main workflow file"); err != nil { + t.Fatalf("expected steps.*.outputs.* github-token expression to pass schema validation, got: %v", err) + } +} + func TestMainWorkflowSchemaPushToPullRequestBranchHasMaxPatchSize(t *testing.T) { schemaPath := "schemas/main_workflow_schema.json" schemaContent, err := os.ReadFile(schemaPath) diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 5ee805b2450..d0e05952629 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -13250,9 +13250,9 @@ }, "github_token": { "type": "string", - "pattern": "^\\$\\{\\{\\s*(secrets\\.[A-Za-z_][A-Za-z0-9_]*(\\s*\\|\\|\\s*secrets\\.[A-Za-z_][A-Za-z0-9_]*)*|needs\\.[A-Za-z_][A-Za-z0-9_]*\\.outputs\\.[A-Za-z_][A-Za-z0-9_]*)\\s*\\}\\}$", - "description": "GitHub token expression. Accepts a secrets expression (e.g., `${{ secrets.NAME }}` or `${{ secrets.NAME1 || secrets.NAME2 }}`) or a job output expression (e.g., `${{ needs.auth.outputs.token }}`). Pattern details: secret names match `[A-Za-z_][A-Za-z0-9_]*`; job IDs and output names in dot notation match `[A-Za-z_][A-Za-z0-9_]*` (identifiers without hyphens).", - "examples": ["${{ secrets.GITHUB_TOKEN }}", "${{ secrets.CUSTOM_PAT }}", "${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}", "${{ needs.auth.outputs.token }}"] + "pattern": "^\\$\\{\\{\\s*(secrets\\.[A-Za-z_][A-Za-z0-9_]*(\\s*\\|\\|\\s*secrets\\.[A-Za-z_][A-Za-z0-9_]*)*|needs\\.[A-Za-z_][A-Za-z0-9_]*\\.outputs\\.[A-Za-z_][A-Za-z0-9_]*|steps\\.[A-Za-z_][A-Za-z0-9_]*\\.outputs\\.[A-Za-z_][A-Za-z0-9_]*)\\s*\\}\\}$", + "description": "GitHub token expression. Accepts a secrets expression (e.g., `${{ secrets.NAME }}` or `${{ secrets.NAME1 || secrets.NAME2 }}`), a cross-job output expression (e.g., `${{ needs.auth.outputs.token }}`), or a same-job step output expression (e.g., `${{ steps.fetch_token.outputs.token }}`). Pattern details: secret names, job IDs, step IDs, and output names in dot notation match `[A-Za-z_][A-Za-z0-9_]*` (identifiers without hyphens).", + "examples": ["${{ secrets.GITHUB_TOKEN }}", "${{ secrets.CUSTOM_PAT }}", "${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}", "${{ needs.auth.outputs.token }}", "${{ steps.fetch_token.outputs.token }}"] }, "github_app": { "type": "object", diff --git a/pkg/workflow/github_token_validation_test.go b/pkg/workflow/github_token_validation_test.go index 63ff6c9bc47..23053cd424a 100644 --- a/pkg/workflow/github_token_validation_test.go +++ b/pkg/workflow/github_token_validation_test.go @@ -174,6 +174,11 @@ func TestGitHubTokenValidationInSafeOutputs(t *testing.T) { token: "${{ needs.auth.outputs.token }}", expectError: false, }, + { + name: "valid same-job step output token in safe-outputs", + token: "${{ steps.fetch_token.outputs.token }}", + expectError: false, + }, { name: "invalid token in safe-outputs", token: "ghp_plaintext_token", @@ -236,6 +241,11 @@ func TestGitHubTokenValidationInIndividualSafeOutput(t *testing.T) { token: "${{ needs.auth.outputs.token }}", expectError: false, }, + { + name: "valid same-job step output token in individual safe-output", + token: "${{ steps.fetch_token.outputs.token }}", + expectError: false, + }, { name: "invalid token in individual safe-output", token: "github_pat_plaintext", @@ -298,6 +308,11 @@ func TestGitHubTokenValidationInGitHubTool(t *testing.T) { token: "${{ needs.auth.outputs.token }}", expectError: false, }, + { + name: "valid same-job step output token in github tool", + token: "${{ steps.fetch_token.outputs.token }}", + expectError: false, + }, { name: "invalid token in github tool", token: "plaintext_secret", diff --git a/pkg/workflow/skills_frontmatter.go b/pkg/workflow/skills_frontmatter.go index 8a0e90383c8..1e2759f0669 100644 --- a/pkg/workflow/skills_frontmatter.go +++ b/pkg/workflow/skills_frontmatter.go @@ -12,7 +12,7 @@ import ( var skillsFrontmatterLog = logger.New("workflow:skills_frontmatter") var skillSpecRegexp = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*)?@[0-9a-f]{40}$`) -var githubTokenExpressionRegexp = regexp.MustCompile(`^\$\{\{\s*(secrets\.[A-Za-z_][A-Za-z0-9_]*(\s*\|\|\s*secrets\.[A-Za-z_][A-Za-z0-9_]*)*|needs\.[A-Za-z_][A-Za-z0-9_]*\.outputs\.[A-Za-z_][A-Za-z0-9_]*)\s*\}\}$`) +var githubTokenExpressionRegexp = regexp.MustCompile(`^\$\{\{\s*(secrets\.[A-Za-z_][A-Za-z0-9_]*(\s*\|\|\s*secrets\.[A-Za-z_][A-Za-z0-9_]*)*|needs\.[A-Za-z_][A-Za-z0-9_]*\.outputs\.[A-Za-z_][A-Za-z0-9_]*|steps\.[A-Za-z_][A-Za-z0-9_]*\.outputs\.[A-Za-z_][A-Za-z0-9_]*)\s*\}\}$`) // SkillReference describes a single skills[] entry in workflow frontmatter. // It supports both legacy string-only entries and object entries with per-skill auth. @@ -94,7 +94,7 @@ func validateFrontmatterSkills(frontmatter map[string]any) error { } if !githubTokenExpressionRegexp.MatchString(token) { return fmt.Errorf( - "skills[%d].github-token must be a valid GitHub token expression. Example: skills[%d].github-token: \"${{ secrets.NAME }}\" or \"${{ needs.auth.outputs.token }}\"", + "skills[%d].github-token must be a valid GitHub token expression. Example: skills[%d].github-token: \"${{ secrets.NAME }}\", \"${{ needs.auth.outputs.token }}\", or \"${{ steps.fetch_token.outputs.token }}\"", i, i, ) diff --git a/pkg/workflow/skills_frontmatter_test.go b/pkg/workflow/skills_frontmatter_test.go index f8e3170fbcb..5ecdbd9be9b 100644 --- a/pkg/workflow/skills_frontmatter_test.go +++ b/pkg/workflow/skills_frontmatter_test.go @@ -84,6 +84,18 @@ func TestValidateFrontmatterSkills(t *testing.T) { require.NoError(t, err) }) + t.Run("accepts object form with steps output github-token", func(t *testing.T) { + err := validateFrontmatterSkills(map[string]any{ + "skills": []any{ + map[string]any{ + "skill": "githubnext/skills@1f181b37d3fe5862ab590648f25a292e345b5de6", + "github-token": "${{ steps.fetch_token.outputs.token }}", + }, + }, + }) + require.NoError(t, err) + }) + t.Run("rejects object form with github-token literal", func(t *testing.T) { err := validateFrontmatterSkills(map[string]any{ "skills": []any{ From 5026560b1f9da3427545f47fc4956dbbfba40de6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Jul 2026 08:53:43 +0000 Subject: [PATCH 4/4] Scope same-job github-token validation Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- pkg/parser/schema_test.go | 26 ++++- pkg/parser/schemas/main_workflow_schema.json | 112 ++++++++++--------- pkg/workflow/github_token_validation_test.go | 6 +- pkg/workflow/skills_frontmatter.go | 6 +- pkg/workflow/skills_frontmatter_test.go | 5 +- 5 files changed, 92 insertions(+), 63 deletions(-) diff --git a/pkg/parser/schema_test.go b/pkg/parser/schema_test.go index 1609d00d49c..04f0fcde4d3 100644 --- a/pkg/parser/schema_test.go +++ b/pkg/parser/schema_test.go @@ -1233,9 +1233,9 @@ func TestMainWorkflowSchema_GitHubTokenAllowsStepOutputs(t *testing.T) { frontmatter := map[string]any{ "on": "daily", "safe-outputs": map[string]any{ - "github-token": "${{ steps.fetch_token.outputs.token }}", + "github-token": "${{ steps.fetch-token.outputs.my-token }}", "create-issue": map[string]any{ - "github-token": "${{ steps.fetch_token.outputs.token }}", + "github-token": "${{ steps.fetch-token.outputs.my-token }}", }, }, } @@ -1245,6 +1245,28 @@ func TestMainWorkflowSchema_GitHubTokenAllowsStepOutputs(t *testing.T) { } } +func TestMainWorkflowSchema_SkillsGitHubTokenRejectsStepOutputs(t *testing.T) { + t.Parallel() + + frontmatter := map[string]any{ + "on": "daily", + "skills": []any{ + map[string]any{ + "skill": "githubnext/skills@1f181b37d3fe5862ab590648f25a292e345b5de6", + "github-token": "${{ steps.fetch-token.outputs.my-token }}", + }, + }, + } + + err := validateWithSchema(frontmatter, mainWorkflowSchema, "main workflow file") + if err == nil { + t.Fatal("expected skills[].github-token steps.*.outputs.* expression to fail schema validation") + } + if !strings.Contains(err.Error(), "github-token") { + t.Fatalf("expected schema error to mention github-token, got: %v", err) + } +} + func TestMainWorkflowSchemaPushToPullRequestBranchHasMaxPatchSize(t *testing.T) { schemaPath := "schemas/main_workflow_schema.json" schemaContent, err := os.ReadFile(schemaPath) diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index d0e05952629..3c2276b7133 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -4038,7 +4038,7 @@ "default": true }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "Optional custom GitHub token (e.g., '${{ secrets.CUSTOM_PAT }}'). For 'remote' type, defaults to GH_AW_GITHUB_TOKEN if not specified." }, "toolsets": { @@ -4590,7 +4590,7 @@ "description": "Controls whether AI-generated footer is added to the managed comment. Defaults to true." }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for comment-memory operations. Overrides global github-token if specified." }, "staged": { @@ -5248,7 +5248,7 @@ "default": true }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { @@ -5351,7 +5351,7 @@ "description": "List of additional repositories in format 'owner/repo' that agent sessions can be created in. When specified, the agent can use a 'repo' field in the output to specify which repository to create the agent session in. The target repository (current or target-repo) is always implicitly allowed." }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { @@ -5426,7 +5426,7 @@ "description": "List of additional repositories in format 'owner/repo' that agent sessions can be created in. When specified, the agent can use a 'repo' field in the output to specify which repository to create the agent session in. The target repository (current or target-repo) is always implicitly allowed." }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { @@ -5483,7 +5483,7 @@ ] }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "project": { @@ -5648,7 +5648,7 @@ ] }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Must have Projects write permission. Overrides global github-token if specified." }, "target-owner": { @@ -5779,7 +5779,7 @@ ] }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified. Must have Projects: Read+Write permission." }, "project": { @@ -5935,7 +5935,7 @@ "description": "Time until the discussion expires and should be automatically closed. Supports integer (days), relative time format like '2h' (2 hours), '7d' (7 days), '2w' (2 weeks), '1m' (1 month), '1y' (1 year), or false to disable expiration. Minimum duration: 2 hours. When set, a maintenance workflow will be generated. Defaults to 7 days if not specified." }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { @@ -6176,7 +6176,7 @@ ] }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." } }, @@ -6360,7 +6360,7 @@ "description": "List of additional repositories in format 'owner/repo' that pull requests can be closed in. The target repository is always implicitly allowed." }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { @@ -6457,7 +6457,7 @@ "description": "List of additional repositories in format 'owner/repo' that pull requests can be marked as ready in. The target repository is always implicitly allowed." }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { @@ -6597,7 +6597,7 @@ "default": true }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "required-labels": { @@ -6811,11 +6811,11 @@ ] }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "head-github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "Optional GitHub token used only for branch writes to head-repo. Use this when the upstream PR repository and the fork head repository require different credentials." }, "head-github-app": { @@ -7117,7 +7117,7 @@ "description": "List of additional repositories in format 'owner/repo' that PR review comments can be created in. When specified, the agent can use a 'repo' field in the output to specify which repository to create the review comment in. The target repository (current or target-repo) is always implicitly allowed." }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { @@ -7226,7 +7226,7 @@ "description": "When true, after posting a replacement review this workflow dismisses older REQUEST_CHANGES reviews previously posted by the same workflow on the same pull request. This is best-effort and requires workflow markers in prior review bodies." }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { @@ -7308,7 +7308,7 @@ "description": "List of additional repositories in format 'owner/repo' where review dismissals are allowed. The target repository (current or target-repo) is always implicitly allowed." }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { @@ -7398,7 +7398,7 @@ "default": true }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { @@ -7480,7 +7480,7 @@ "description": "List of additional repositories in format 'owner/repo' that PR review threads can be resolved in. When specified, the agent can use a 'repo' field in the output to specify which repository to resolve threads in. The target repository (current or target-repo) is always implicitly allowed." }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { @@ -7550,7 +7550,7 @@ "description": "Driver name for SARIF tool.driver.name field (default: 'GitHub Agentic Workflows Security Scanner')" }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "target-repo": { @@ -7616,7 +7616,7 @@ ] }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { @@ -7675,7 +7675,7 @@ ] }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { @@ -7781,7 +7781,7 @@ "description": "Target repository in format 'owner/repo' for cross-repository label addition. Takes precedence over trial target repo settings." }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "allowed-repos": { @@ -7886,7 +7886,7 @@ "description": "Target repository in format 'owner/repo' for cross-repository label removal. Takes precedence over trial target repo settings." }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "allowed-repos": { @@ -8030,7 +8030,7 @@ "description": "List of additional repositories in format 'owner/repo' that reviewers can be added in. The target repository is always implicitly allowed." }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { @@ -8120,7 +8120,7 @@ "description": "List of additional repositories in format 'owner/repo' that milestone assignments can target. The target repository is always implicitly allowed." }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { @@ -8244,7 +8244,7 @@ "description": "Base branch for pull request creation in the target repository. Defaults to the target repo's default branch. Only relevant when pull-request-repo is configured." }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { @@ -8330,7 +8330,7 @@ "default": false }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "allowed-repos": { @@ -8436,7 +8436,7 @@ "description": "List of allowed repositories in format 'owner/repo' for cross-repository unassignment operations. Use with 'repo' field in tool calls." }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { @@ -8554,7 +8554,7 @@ ] }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { @@ -8643,7 +8643,7 @@ "description": "List of additional repositories in format 'owner/repo' that issues can be updated in. When specified, the agent can use a 'repo' field in the output to specify which repository to update the issue in. The target repository (current or target-repo) is always implicitly allowed." }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { @@ -8747,7 +8747,7 @@ "description": "List of additional repositories in format 'owner/repo' that pull requests can be updated in. The target repository is always implicitly allowed." }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { @@ -8857,7 +8857,7 @@ "description": "List of additional repositories in format 'owner/repo' that pull requests can be merged in. The target repository is always implicitly allowed." }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { @@ -8992,7 +8992,7 @@ "maximum": 10240 }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { @@ -9295,11 +9295,11 @@ "description": "List of additional repositories in format 'owner/repo' where issue types can be set. When specified, the agent can use a 'repo' field in the output to specify which repository to target. The target repository (current or target-repo) is always implicitly allowed." }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "head-github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "Optional GitHub token used only for branch writes to head-repo. Use this when the upstream pull request repository and the fork head repository require different credentials." }, "issue-intent": { @@ -9391,7 +9391,7 @@ "description": "List of additional repositories in format 'owner/repo' where issue fields can be updated. When specified, the agent can use a 'repo' field in the output to specify which repository to target. The target repository (current or target-repo) is always implicitly allowed." }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "issue-intent": { @@ -9469,7 +9469,7 @@ ] }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for dispatching workflows. Overrides global github-token if specified." }, "target-repo": { @@ -9621,7 +9621,7 @@ ] }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for dispatching. Overrides global github-token if specified." }, "staged": { @@ -9671,7 +9671,7 @@ ] }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token passed to called workflows. Overrides global github-token if specified." }, "staged": { @@ -9751,7 +9751,7 @@ "default": [] }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { @@ -9829,7 +9829,7 @@ "default": [] }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { @@ -9890,7 +9890,7 @@ ] }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "report-as-issue": { @@ -9976,7 +9976,7 @@ ] }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { @@ -10097,7 +10097,7 @@ "additionalProperties": false }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { @@ -10195,7 +10195,7 @@ "additionalProperties": false }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for safe output jobs. Typically a secret reference like ${{ secrets.GITHUB_TOKEN }} or ${{ secrets.CUSTOM_PAT }}", "examples": ["${{ secrets.GITHUB_TOKEN }}", "${{ secrets.CUSTOM_PAT }}", "${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}"] }, @@ -10354,7 +10354,7 @@ "$ref": "#/properties/permissions" }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token for this specific job" }, "output": { @@ -10926,7 +10926,7 @@ "default": [] }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { @@ -11046,7 +11046,7 @@ "description": "Target repository in format 'owner/repo' for cross-repository label replacement. Takes precedence over trial target repo settings." }, "github-token": { - "$ref": "#/$defs/github_token", + "$ref": "#/$defs/github_token_same_job", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "allowed-repos": { @@ -13250,9 +13250,15 @@ }, "github_token": { "type": "string", - "pattern": "^\\$\\{\\{\\s*(secrets\\.[A-Za-z_][A-Za-z0-9_]*(\\s*\\|\\|\\s*secrets\\.[A-Za-z_][A-Za-z0-9_]*)*|needs\\.[A-Za-z_][A-Za-z0-9_]*\\.outputs\\.[A-Za-z_][A-Za-z0-9_]*|steps\\.[A-Za-z_][A-Za-z0-9_]*\\.outputs\\.[A-Za-z_][A-Za-z0-9_]*)\\s*\\}\\}$", - "description": "GitHub token expression. Accepts a secrets expression (e.g., `${{ secrets.NAME }}` or `${{ secrets.NAME1 || secrets.NAME2 }}`), a cross-job output expression (e.g., `${{ needs.auth.outputs.token }}`), or a same-job step output expression (e.g., `${{ steps.fetch_token.outputs.token }}`). Pattern details: secret names, job IDs, step IDs, and output names in dot notation match `[A-Za-z_][A-Za-z0-9_]*` (identifiers without hyphens).", - "examples": ["${{ secrets.GITHUB_TOKEN }}", "${{ secrets.CUSTOM_PAT }}", "${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}", "${{ needs.auth.outputs.token }}", "${{ steps.fetch_token.outputs.token }}"] + "pattern": "^\\$\\{\\{\\s*(secrets\\.[A-Za-z_][A-Za-z0-9_]*(\\s*\\|\\|\\s*secrets\\.[A-Za-z_][A-Za-z0-9_]*)*|needs\\.[A-Za-z_][A-Za-z0-9_]*\\.outputs\\.[A-Za-z_][A-Za-z0-9_]*)\\s*\\}\\}$", + "description": "GitHub token expression for cross-job or activation-time contexts. Accepts a secrets expression (e.g., `${{ secrets.NAME }}` or `${{ secrets.NAME1 || secrets.NAME2 }}`) or a cross-job output expression (e.g., `${{ needs.auth.outputs.token }}`). Pattern details: secret names, job IDs, and output names in dot notation match `[A-Za-z_][A-Za-z0-9_]*` (identifiers without hyphens).", + "examples": ["${{ secrets.GITHUB_TOKEN }}", "${{ secrets.CUSTOM_PAT }}", "${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}", "${{ needs.auth.outputs.token }}"] + }, + "github_token_same_job": { + "type": "string", + "pattern": "^\\$\\{\\{\\s*(secrets\\.[A-Za-z_][A-Za-z0-9_]*(\\s*\\|\\|\\s*secrets\\.[A-Za-z_][A-Za-z0-9_]*)*|needs\\.[A-Za-z_][A-Za-z0-9_]*\\.outputs\\.[A-Za-z_][A-Za-z0-9_]*|steps\\.[A-Za-z_][A-Za-z0-9_-]*\\.outputs\\.[A-Za-z_][A-Za-z0-9_-]*)\\s*\\}\\}$", + "description": "GitHub token expression for same-job contexts. Accepts a secrets expression (e.g., `${{ secrets.NAME }}` or `${{ secrets.NAME1 || secrets.NAME2 }}`), a cross-job output expression (e.g., `${{ needs.auth.outputs.token }}`), or a same-job step output expression (e.g., `${{ steps.fetch-token.outputs.my-token }}`). Pattern details: secret names and job IDs match `[A-Za-z_][A-Za-z0-9_]*`; same-job step IDs and output names match `[A-Za-z_][A-Za-z0-9_-]*`.", + "examples": ["${{ secrets.GITHUB_TOKEN }}", "${{ secrets.CUSTOM_PAT }}", "${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}", "${{ needs.auth.outputs.token }}", "${{ steps.fetch-token.outputs.my-token }}"] }, "github_app": { "type": "object", diff --git a/pkg/workflow/github_token_validation_test.go b/pkg/workflow/github_token_validation_test.go index 23053cd424a..cd432c4aec5 100644 --- a/pkg/workflow/github_token_validation_test.go +++ b/pkg/workflow/github_token_validation_test.go @@ -176,7 +176,7 @@ func TestGitHubTokenValidationInSafeOutputs(t *testing.T) { }, { name: "valid same-job step output token in safe-outputs", - token: "${{ steps.fetch_token.outputs.token }}", + token: "${{ steps.fetch-token.outputs.my-token }}", expectError: false, }, { @@ -243,7 +243,7 @@ func TestGitHubTokenValidationInIndividualSafeOutput(t *testing.T) { }, { name: "valid same-job step output token in individual safe-output", - token: "${{ steps.fetch_token.outputs.token }}", + token: "${{ steps.fetch-token.outputs.my-token }}", expectError: false, }, { @@ -310,7 +310,7 @@ func TestGitHubTokenValidationInGitHubTool(t *testing.T) { }, { name: "valid same-job step output token in github tool", - token: "${{ steps.fetch_token.outputs.token }}", + token: "${{ steps.fetch-token.outputs.my-token }}", expectError: false, }, { diff --git a/pkg/workflow/skills_frontmatter.go b/pkg/workflow/skills_frontmatter.go index 1e2759f0669..6f575801925 100644 --- a/pkg/workflow/skills_frontmatter.go +++ b/pkg/workflow/skills_frontmatter.go @@ -12,7 +12,7 @@ import ( var skillsFrontmatterLog = logger.New("workflow:skills_frontmatter") var skillSpecRegexp = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*)?@[0-9a-f]{40}$`) -var githubTokenExpressionRegexp = regexp.MustCompile(`^\$\{\{\s*(secrets\.[A-Za-z_][A-Za-z0-9_]*(\s*\|\|\s*secrets\.[A-Za-z_][A-Za-z0-9_]*)*|needs\.[A-Za-z_][A-Za-z0-9_]*\.outputs\.[A-Za-z_][A-Za-z0-9_]*|steps\.[A-Za-z_][A-Za-z0-9_]*\.outputs\.[A-Za-z_][A-Za-z0-9_]*)\s*\}\}$`) +var skillsGitHubTokenExpressionRegexp = regexp.MustCompile(`^\$\{\{\s*(secrets\.[A-Za-z_][A-Za-z0-9_]*(\s*\|\|\s*secrets\.[A-Za-z_][A-Za-z0-9_]*)*|needs\.[A-Za-z_][A-Za-z0-9_]*\.outputs\.[A-Za-z_][A-Za-z0-9_]*)\s*\}\}$`) // SkillReference describes a single skills[] entry in workflow frontmatter. // It supports both legacy string-only entries and object entries with per-skill auth. @@ -92,9 +92,9 @@ func validateFrontmatterSkills(frontmatter map[string]any) error { if !ok { return fmt.Errorf("skills[%d].github-token must be a string. Example: skills[%d].github-token: \"${{ secrets.MY_TOKEN }}\"", i, i) } - if !githubTokenExpressionRegexp.MatchString(token) { + if !skillsGitHubTokenExpressionRegexp.MatchString(token) { return fmt.Errorf( - "skills[%d].github-token must be a valid GitHub token expression. Example: skills[%d].github-token: \"${{ secrets.NAME }}\", \"${{ needs.auth.outputs.token }}\", or \"${{ steps.fetch_token.outputs.token }}\"", + "skills[%d].github-token must be a valid GitHub token expression. Example: skills[%d].github-token: \"${{ secrets.NAME }}\" or \"${{ needs.auth.outputs.token }}\"", i, i, ) diff --git a/pkg/workflow/skills_frontmatter_test.go b/pkg/workflow/skills_frontmatter_test.go index 5ecdbd9be9b..20341036ea3 100644 --- a/pkg/workflow/skills_frontmatter_test.go +++ b/pkg/workflow/skills_frontmatter_test.go @@ -84,7 +84,7 @@ func TestValidateFrontmatterSkills(t *testing.T) { require.NoError(t, err) }) - t.Run("accepts object form with steps output github-token", func(t *testing.T) { + t.Run("rejects object form with steps output github-token", func(t *testing.T) { err := validateFrontmatterSkills(map[string]any{ "skills": []any{ map[string]any{ @@ -93,7 +93,8 @@ func TestValidateFrontmatterSkills(t *testing.T) { }, }, }) - require.NoError(t, err) + require.Error(t, err) + require.ErrorContains(t, err, "skills[0].github-token must be a valid GitHub token expression") }) t.Run("rejects object form with github-token literal", func(t *testing.T) {