Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions docs/adr/51455-allow-non-sha-refs-in-skills-frontmatter.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
# ADR-51455: Allow Non-SHA Refs in Skills Frontmatter, Pinned at Compile Time

**Date**: 2026-08-08
**Status**: Draft
**Deciders**: pelikhan (PR author), copilot-swe-agent (implementation)

---

### Context

The `skills:` frontmatter field in workflow markdown files previously required every remote skill reference to be pinned to a full 40-character lowercase commit SHA (e.g., `owner/repo@abc123...def456`). This forced authors to manually look up the current SHA for a branch or tag, paste it in, and update it by hand whenever they wanted to upgrade a dependency. The requirement was intended to guarantee reproducible, tamper-resistant builds — the same `.lock.yml` would always activate the exact same skill code. However, the manual SHA-management burden reduced the ergonomics of skill authoring without adding a meaningful security benefit beyond what compile-time pinning already provides, since the compiler already pins `uses:` action references using a shared GitHub API + cache resolver.

### Decision

We will relax the validation of the `skills:` frontmatter field to accept branch names, tag names, or full commit SHAs as the `<ref>` portion of `owner/repo@<ref>` and `owner/repo/skill/path@<ref>` entries. Non-SHA refs are resolved to their current commit SHA at compile time using the compiler's existing `ActionResolver` (the same infrastructure used to pin `uses:` action references), and the resolved SHA is written into the compiled `.lock.yml`. The original source frontmatter retains the human-readable branch/tag name for authoring convenience. Ambiguous SHA-like strings (hex chars, 7–39 chars) are still rejected to prevent ref confusion. GitHub Actions expressions (`${{ ... }}`) remain unsupported as skill refs. Entries with no ref (`owner/repo@`) are permitted but emit a compiler warning recommending an explicit ref.

### Alternatives Considered

#### Alternative 1: Keep Requiring Full SHA-Only Refs (Status Quo)

Authors continue to specify a 40-character lowercase SHA for every remote skill. The compiler does not need a ref-resolution pass; validation remains a simple regexp.

Rejected because: the ergonomic cost is high — updating a skill pin requires looking up the SHA via `gh api` or browsing GitHub, and there is no automation to help. The security guarantee is not meaningfully stronger than compile-time pinning, since the `.lock.yml` would still be the authoritative artifact used at runtime.

#### Alternative 2: Accept Branch/Tag Refs Without Pinning at Compile Time

Accept any branch/tag ref in frontmatter and pass it through to the `.lock.yml` as-is, relying on the runtime skill installer to resolve the ref at activation time.

Rejected because: this breaks reproducibility — two activations of the same `.lock.yml` at different times can pick up different code if the branch has moved. It would also undermine the security posture that SHA pinning provides, since a compromised branch could silently change what code runs in a workflow.

#### Alternative 3: Resolve Refs at a Separate Pre-Compile Step (CI/CD Automation)

A separate CI job or bot resolves branch/tag refs to SHAs and opens a PR to update the frontmatter, similar to Dependabot-style pin management.

Rejected because: it requires additional infrastructure, introduces lag between authoring and pinning, and does not improve the immediate authoring experience. The compile-time resolution already has access to the resolver and cache, making a separate step redundant.

### Consequences

#### Positive
- Authors can reference skill dependencies by branch or tag name (e.g., `@main`, `@v1.2.3`, `@release/1.0`), eliminating the need to manually look up and maintain SHA strings.
- The compiled `.lock.yml` always contains fully-pinned SHAs, preserving the existing reproducibility and tamper-resistance guarantee at the artifact level.
- Reuses the existing `ActionResolver` + cache infrastructure, keeping the implementation surface small and consistent with how `uses:` action pinning already works.
- Ambiguous SHA-like strings (truncated or malformed SHAs) are explicitly rejected, preventing a class of ref-confusion bugs.

#### Negative
- Resolution failures (e.g., no network access, missing GitHub auth) degrade gracefully to a compiler warning rather than a hard error, which means a `.lock.yml` can be emitted with an unpinned ref in offline or restricted-auth build environments.
- The new `resolveFrontmatterSkillRefs` compiler pass adds a GitHub API call per non-SHA skill ref during compilation; in environments where the cache is cold this adds latency.
- The `owner/repo@` (no-ref) form is now syntactically valid, which may be accidentally used by authors who omit a ref, producing non-reproducible builds that only show a warning.

#### Neutral
- The validation regexp is replaced by structured parsing (split on `@`, validate repo path and ref separately), which is slightly more complex but allows clearer, per-field error messages.
- Existing workflows using full SHA refs are unaffected — they pass through the new validation unchanged and are not re-resolved.

---

*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
17 changes: 11 additions & 6 deletions docs/src/content/docs/reference/frontmatter-full.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,12 +60,17 @@ labels: []
# Array of strings

# Optional list of skill references to install during activation. Supports remote
# repository-wide installs (`owner/repo@<sha>`), remote path-scoped installs
# (`owner/repo/skill/path@<sha>`), and local path references (e.g. `skills/rig` or
# `.github/skills/my-skill`). Remote static references must be pinned to a full
# 40-character lowercase commit SHA. Local paths are installed with --from-local
# at runtime and are rewritten to a remote repospec by `gh aw add`. GitHub Actions
# expressions (`${{ ... }}`) are also accepted and are evaluated at runtime.
# repository-wide installs (`owner/repo@<ref>`), remote path-scoped installs
# (`owner/repo/skill/path@<ref>`), and local path references (e.g. `skills/rig` or
# `.github/skills/my-skill`). `<ref>` may be a branch, tag, or full 40-character
# lowercase commit SHA; non-SHA refs are resolved and rewritten to the matching commit
# SHA at compile time. If resolution fails (e.g. no network access or authentication),
# the compiler keeps the original unpinned ref and emits a warning. Omitting the ref
# (`owner/repo@`) installs from the
# repository's default branch and is not pinned, which triggers a compiler warning.
# Local paths are installed with --from-local at runtime and are rewritten to a
# remote repospec by `gh aw add`. GitHub Actions expressions (`${{ ... }}`) are also
# accepted and are evaluated at runtime.
# Entries may also be objects to configure per-skill authentication via
# github-token or github-app.
# (optional)
Expand Down
15 changes: 10 additions & 5 deletions docs/src/content/docs/reference/frontmatter.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -237,8 +237,8 @@ Supported entry formats:

- String form (shared authentication):
- `skills/name` or `.github/skills/name` (local development path; installed with `--from-local`)
- `owner/repo@<40-char-sha>`
- `owner/repo/skill/path@<40-char-sha>`
- `owner/repo@<ref>`
- `owner/repo/skill/path@<ref>`
- Object form (per-skill authentication):
- `skill` (required)
- `github-token` (optional)
Expand All@@ -247,12 +247,17 @@ Supported entry formats:
`github-token` and `github-app` are mutually exclusive for each object entry.
`github-token` must be an expression such as `${{ secrets.NAME }}` or
`${{ needs.auth.outputs.token }}`.
Static external references must be pinned to a 40-character lowercase commit SHA.
`<ref>` may be a branch, tag, or 40-character lowercase commit SHA. Non-SHA
refs are resolved and rewritten to the matching commit SHA at compile time.
If resolution fails (for example, due to missing network access or authentication),
the compiler keeps the original unpinned ref and emits a warning. Omitting the ref
(`owner/repo@`) installs from the repository's default branch on every run
and is not pinned; the compiler emits a warning recommending an explicit ref.

```yaml wrap
skills:
# Shared auth via workflow-level activation token
- mattpocock/skills/tdd@801dca688564c529fa84f247f64472520d9ebe28
# Shared auth via workflow-level activation token; sha-pinned automatically at compile time
- mattpocock/skills/tdd@main

# Per-skill PAT (or fallback) for private skill repositories
- skill: mattpocock/skills/diagnosing-bugs@801dca688564c529fa84f247f64472520d9ebe28
Expand Down
6 changes: 3 additions & 3 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,12 +58,12 @@
},
"skills": {
"type": "array",
"description": "Optional list of skill references to install during activation. Supports remote repository-wide installs (`owner/repo@<sha>`), remote path-scoped installs (`owner/repo/skill/path@<sha>`), and local path references (e.g. `skills/rig` or `.github/skills/my-skill`). Remote static references must be pinned to a full 40-character lowercase commit SHA. Local paths are installed with --from-local at runtime and are rewritten to a remote repospec by `gh aw add`. GitHub Actions expressions (`${{ ... }}`) are also accepted and are evaluated at runtime. Entries may also be objects to configure per-skill authentication via github-token or github-app.",
"description": "Optional list of skill references to install during activation. Supports remote repository-wide installs (`owner/repo@<sha>`), remote path-scoped installs (`owner/repo/skill/path@<sha>`), and local path references (e.g. `skills/rig` or `.github/skills/my-skill`). Remote static references may use a full 40-character lowercase commit SHA, or a branch/tag name (resolved to a SHA at compile time). Omitting the ref (`owner/repo@`) opts out of pinning and triggers a compile-time warning. Local paths are installed with --from-local at runtime and are rewritten to a remote repospec by `gh aw add`. GitHub Actions expressions (`${{ ... }}`) are also accepted and are evaluated at runtime. Entries may also be objects to configure per-skill authentication via github-token or github-app.",
"items": {
"oneOf": [
{
"type": "string",
"pattern": "^[A-Za-z0-9_.-]+\\/[A-Za-z0-9_.-]+(?:\\/[A-Za-z0-9_.-]+(?:\\/[A-Za-z0-9_.-]+)*)?@[0-9a-f]{40}$"
"pattern": "^[A-Za-z0-9_.-]+\\/[A-Za-z0-9_.-]+(?:\\/[A-Za-z0-9_.-]+(?:\\/[A-Za-z0-9_.-]+)*)?@(?:[0-9a-f]{40}|[A-Za-z0-9](?:[A-Za-z0-9_./-]*[A-Za-z0-9_.-])?)?$"
},
{
"type": "string",
Expand All@@ -90,7 +90,7 @@
"oneOf": [
{
"type": "string",
"pattern": "^[A-Za-z0-9_.-]+\\/[A-Za-z0-9_.-]+(?:\\/[A-Za-z0-9_.-]+(?:\\/[A-Za-z0-9_.-]+)*)?@[0-9a-f]{40}$"
"pattern": "^[A-Za-z0-9_.-]+\\/[A-Za-z0-9_.-]+(?:\\/[A-Za-z0-9_.-]+(?:\\/[A-Za-z0-9_.-]+)*)?@(?:[0-9a-f]{40}|[A-Za-z0-9](?:[A-Za-z0-9_./-]*[A-Za-z0-9_.-])?)?$"
},
{
"type": "string",
Expand Down
35 changes: 33 additions & 2 deletions pkg/workflow/action_resolver.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -189,7 +189,11 @@ func ParseTagRefTSV(line string) (sha, objType string, err error) {
return sha, objType, nil
}

// resolveFromGitHub uses gh CLI to resolve the SHA for an action@version
// resolveFromGitHub uses gh CLI to resolve the SHA for an action@version.
// It first attempts to resolve as a tag via the git/refs/tags endpoint (which
// also handles annotated-tag peeling). If the tag lookup fails — indicating the
// ref is a branch name or an arbitrary commit ref — it falls back to the commits
// endpoint, which accepts branch names, tag names, and SHAs.
func (r *ActionResolver) resolveFromGitHub(ctx context.Context, repo, version string) (string, error) {
// Extract base repository (for actions like "github/codeql-action/upload-sarif")
baseRepo := gitutil.ExtractBaseRepo(repo)
Expand All@@ -214,7 +218,11 @@ func (r *ActionResolver) resolveFromGitHub(ctx context.Context, repo, version st
ForceGHHostEnv(cmd, "github.com")
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to resolve %s@%s: %w", repo, version, err)
// Tag lookup failed. The ref may be a branch name rather than a tag.
// Fall back to the commits endpoint, which resolves both branch and tag
// names as well as SHAs, so that authors can pin to branches (e.g. "main").
resolverLog.Printf("Tag lookup for %s@%s failed (%v); falling back to commits endpoint", repo, version, err)
return r.resolveRefViaCommitsEndpoint(ctx, baseRepo, repo, version)
}

sha, objType, err := ParseTagRefTSV(string(output))
Expand DownExpand Up@@ -246,6 +254,29 @@ func (r *ActionResolver) resolveFromGitHub(ctx context.Context, repo, version st
return sha, nil
}

// resolveRefViaCommitsEndpoint resolves a branch name, tag name, or arbitrary ref
// to its commit SHA using the GitHub API commits endpoint
// (GET /repos/{owner}/{repo}/commits/{ref}), which accepts all ref types.
// This is the fallback used when the tags-specific endpoint returns an error.
func (r *ActionResolver) resolveRefViaCommitsEndpoint(ctx context.Context, baseRepo, repo, version string) (string, error) {
commitsPath := fmt.Sprintf("/repos/%s/commits/%s", baseRepo, version)
resolverLog.Printf("Querying commits endpoint: %s", commitsPath)
callCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
cmd := ExecGHContext(callCtx, "api", commitsPath, "--jq", ".sha")
ForceGHHostEnv(cmd, "github.com")
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to resolve %s@%s: %w", repo, version, err)
}
sha := strings.TrimSpace(string(output))
if !gitutil.IsValidFullSHA(sha) {
return "", fmt.Errorf("unexpected response resolving %s@%s: got %q (expected 40-char hex SHA)", repo, version, sha)
}
resolverLog.Printf("Resolved %s@%s to commit SHA %s via commits endpoint", repo, version, sha)
return sha, nil
}

// peelTagObject resolves a single annotated-tag object to its underlying object by
// querying the GitHub API. It is called iteratively for chained tag objects.
// The timeout context is created and immediately deferred within this function so
Expand Down
6 changes: 3 additions & 3 deletions pkg/workflow/compiler_orchestrator_frontmatter_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -273,7 +273,7 @@ func TestParseFrontmatterSection_InvalidSkillsRef(t *testing.T) {
on: workflow_dispatch
engine: copilot
skills:
- githubnext/skills@main
- githubnext/skills@1f181b37d3fe5862ab590648f25a292e345b5de
---

# Workflow
Expand All@@ -288,7 +288,7 @@ skills:
require.Error(t, err)
assert.Nil(t, result)
assert.True(t,
strings.Contains(err.Error(), "40-char-sha") || strings.Contains(err.Error(), "does not match pattern"),
strings.Contains(err.Error(), "truncated or malformed") || strings.Contains(err.Error(), "does not match pattern"),
"expected skills validation error, got: %v", err,
)
}
Expand DownExpand Up@@ -316,7 +316,7 @@ skills:
require.Error(t, err, "expected error: GitHub Actions expressions are not allowed in skills refs")
assert.Nil(t, result)
assert.True(t,
strings.Contains(err.Error(), "40-char-sha") || strings.Contains(err.Error(), "does not match pattern"),
strings.Contains(err.Error(), "does not support expressions") || strings.Contains(err.Error(), "does not match pattern"),
"expected skills validation error, got: %v", err,
)
}
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/compiler_validators.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -157,6 +157,7 @@ func (c *Compiler) validateToolConfiguration(workflowData *WorkflowData, markdow
return err
}
c.emitGeneralToolWarnings(workflowData, markdownPath)
c.resolveFrontmatterSkillRefs(workflowData, markdownPath)
if err := c.validateThreatDetectionSandboxRequirement(workflowData, markdownPath); err != nil {
return err
}
Expand Down
Loading
Loading