Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
6 changes: 5 additions & 1 deletion docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,8 @@ A top-level workflow that does not declare the effective event is excluded befor

**🟡 Supported subset.** Calls may use a local path or a literal public GitHub reference such as `owner/repository/.github/workflows/ci.yml@v1`. A public reference resolves once per operation to an immutable commit. Nested `./.github/workflows/...` calls resolve in that pinned repository.

Like GitHub, a `./...` action inside a remote reusable workflow resolves in the caller job's workspace, not relative to the called workflow file. A remote workflow may check out its own pinned repository into a workspace subdirectory and then invoke a local action there. Buildkite binds that action to the called workflow's immutable repository source, rewrites the matching checkout to the exact commit, verifies the workspace copy against the source, and executes the verified source copy.
Comment thread
zhming0 marked this conversation as resolved.
Outdated

**✅ Supported:**

- Local `./.github/workflows/...` paths.
Expand Down Expand Up @@ -681,6 +683,8 @@ Mutable refs work only while they resolve to the upstream `main` snapshot or a k

The adapter checks out a detached commit or static branch from the event repository at the workspace root or a clean top-level directory. It uses Buildkite repository-provider Git credentials when the job provides them; otherwise, it fetches anonymously. Credentials are scoped to each fetch command and verified submodule fetch command and are never persisted.

The table below describes event-repository checkouts. The [source-backed reusable-workflow checkout](#reusable-workflows) is the only exception: its repository, ref, and path must match immutable workflow provenance and a local-action alias. A tag checkout must use the exact `refs/tags/...` ref or commit; Buildkite rejects the bare tag because `actions/checkout` gives a same-named branch precedence. Buildkite discards its `token` input and fetches the exact commit anonymously.

| Input | Supported values |
| --- | --- |
| `repository` | Omitted, or the event `owner/repo`. |
Expand Down Expand Up @@ -710,7 +714,7 @@ The `false` value and omission do not run submodule commands. The `true` value r

See the [security model](security.md#checkout-and-submodules) for credential, Git, and job-isolation boundaries.

Alternate repositories, tags, non-event dynamic commits, LFS, sparse checkout, GitHub Enterprise Server, and credential persistence remain unsupported. Commit and branch checkouts remain detached and confined to the event repository.
Outside the source-backed reusable-workflow exception, alternate repositories, tags, non-event dynamic commits, LFS, sparse checkout, GitHub Enterprise Server, and credential persistence remain unsupported. Commit and branch checkouts remain detached and confined to the event repository.

### Upload artifact action

Expand Down
4 changes: 2 additions & 2 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Workflow files, action metadata, event snapshots, and job plans are untrusted in

Digests and immutable source locks detect changed code. They do not make code trusted or grant credentials.

Public reusable workflows use the same bounded repository source and cache as public actions. Each requested ref resolves once per validation, compilation, or upload operation to an immutable commit and repository digest. Plans also bind each selected workflow file digest. Runtime jobs do not load remote workflow YAML from the caller workspace.
Public reusable workflows use the same bounded repository source and cache as public actions. Each requested ref resolves once per validation, compilation, or upload operation to an immutable commit and repository digest. Plans retain the branch or tag namespace when applicable and bind each selected workflow file digest. Runtime jobs do not load remote workflow YAML from the caller workspace. Source-checked local actions use the same provenance: the runtime verifies the caller-workspace alias against the immutable action directory, then executes the verified source copy. See [Reusable workflows](compatibility.md#reusable-workflows).

Push and pull request path-filter admission uses Buildkite's reserved linked-webhook metadata only after binding it to the Buildkite repository and commit and matching local Git history. Missing, shallow, ambiguous, oversized, or mismatched evidence prevents admission. Explicit and generated snapshots cannot grant this admission. This check controls workflow selection; it does not make the selected workflow trusted.

Expand All @@ -36,7 +36,7 @@ Reusable-workflow call conditions are immutable plan guards evaluated in caller

| Credential | Current boundary |
| --- | --- |
| Repository checkout | The verified adapter checks the event repository and exact commit. Buildkite authorizes managed private access; credentials are command-scoped and not persisted. |
| Repository checkout | The verified adapter checks the event repository and exact commit. A source-checked public reusable workflow may instead anonymously check out its provenance-bound public repository at the exact commit. Buildkite authorizes managed private access only for the event repository; credentials are command-scoped and not persisted. |
| `GITHUB_TOKEN` | Supported static uses receive one short-lived token for the event repository and top-level requesting workflow permissions. Omitted workflow permissions mean exactly `contents: read`; GitHub repository and organization settings are not inherited. Reusable-workflow jobs receive the same permissions because Buildkite does not inspect called workflow permission maps. Buildkite verifies the pipeline repository, immutable commit, top-level workflow policy, and build provenance. Pull requests are limited to `contents: read`; merge queues are denied. The token is not ambient. |
| Cache token | When caching is configured, every JavaScript or Docker action lifecycle receives a fresh job-bound token. This includes compatible clients such as `actions/setup-go`, not only `actions/cache`. Shell steps do not receive it. |
| Ordinary workflow secrets | Static names are resolved with `buildkite-agent secret get` in the destination job. The job's Buildkite identity and Secret access policies are the sole authorization boundary. Values are registered with Agent and local redaction before use. |
Expand Down
32 changes: 23 additions & 9 deletions internal/action/integration/checkout.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,16 +118,14 @@ func sortedCheckoutCommits() []string {
// ValidateCheckoutInputs enforces the release-specific input contract
// implemented by the tokenless event-repository checkout adapter.
func ValidateCheckoutInputs(commit string, inputs map[string]string, repository, sha string) error {
if err := ValidateCheckoutInputNames(inputs); err != nil {
return err
}
names := sortedNames(inputs)
seen := make(map[string]bool, len(names))
generation := checkoutGeneration(commit)
for _, name := range names {
value := inputs[name]
normalized := strings.ToLower(name)
if seen[normalized] {
return fmt.Errorf("duplicate case-insensitive input %q is unsupported", name)
}
seen[normalized] = true
if checkoutInputIntroduced[normalized] > generation {
return fmt.Errorf("explicit input %q is unsupported by this actions/checkout release", name)
}
Expand All @@ -137,7 +135,7 @@ func ValidateCheckoutInputs(commit string, inputs map[string]string, repository,
continue
}
case "ref":
if value == "" || ValidCheckoutSHA(value) || validCheckoutBranch(value) {
if value == "" || ValidCheckoutSHA(value) || ValidCheckoutBranch(value) {
continue
}
case "persist-credentials":
Expand Down Expand Up @@ -170,7 +168,7 @@ func ValidateCheckoutInputs(commit string, inputs map[string]string, repository,
continue
}
case "path":
if value == "" || validCheckoutPath(value) {
if value == "" || ValidCheckoutPath(value) {
continue
}
case "ssh-key", "ssh-known-hosts", "sparse-checkout":
Expand Down Expand Up @@ -199,7 +197,22 @@ func ValidateCheckoutInputs(commit string, inputs map[string]string, repository,
return nil
}

func validCheckoutBranch(value string) bool {
// ValidateCheckoutInputNames rejects names whose case-insensitive lookup would be ambiguous.
func ValidateCheckoutInputNames(inputs map[string]string) error {
names := sortedNames(inputs)
seen := make(map[string]bool, len(names))
for _, name := range names {
normalized := strings.ToLower(name)
Comment thread
zhming0 marked this conversation as resolved.
Outdated
if seen[normalized] {
return fmt.Errorf("duplicate case-insensitive input %q is unsupported", name)
}
seen[normalized] = true
}
return nil
}

// ValidCheckoutBranch reports whether value is a bounded branch-like Git ref.
func ValidCheckoutBranch(value string) bool {
if strings.HasPrefix(value, "refs/heads/") {
value = strings.TrimPrefix(value, "refs/heads/")
} else if strings.HasPrefix(value, "refs/") {
Expand Down Expand Up @@ -243,6 +256,7 @@ func ValidCheckoutSHA(value string) bool {
return true
}

func validCheckoutPath(value string) bool {
// ValidCheckoutPath reports whether value is one safe top-level checkout directory.
func ValidCheckoutPath(value string) bool {
return len(value) <= 255 && value != "." && value != ".." && !strings.EqualFold(value, ".git") && !strings.Contains(value, "/") && !strings.Contains(value, "\\") && !strings.ContainsAny(value, "\r\n\x00") && filepath.IsLocal(value)
}
29 changes: 21 additions & 8 deletions internal/action/source/action_resolution_snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,14 @@ type actionResolutionSnapshotCurrent struct {
}

type actionResolutionSnapshotEntry struct {
Schema string `json:"schema"`
Owner string `json:"owner"`
Repository string `json:"repository"`
Ref string `json:"ref"`
Commit string `json:"commit,omitempty"`
Missing bool `json:"missing,omitempty"`
ResolvedAt time.Time `json:"resolved_at"`
Schema string `json:"schema"`
Owner string `json:"owner"`
Repository string `json:"repository"`
Ref string `json:"ref"`
Commit string `json:"commit,omitempty"`
ResolvedRef string `json:"resolved_ref,omitempty"`
Missing bool `json:"missing,omitempty"`
ResolvedAt time.Time `json:"resolved_at"`
}

// WithActionResolutionSnapshot pins mutable refs to durable per-generation
Expand Down Expand Up @@ -213,12 +214,16 @@ func (s *actionResolutionSnapshot) resolve(ctx context.Context, ref Reference, r
return Resolved{}, err
}
resolved, err := resolve(ctx, ref)
if err == nil && resolved.ResolvedRef == "" {
resolved.ResolvedRef = resolved.Commit
}
entry := actionResolutionSnapshotEntry{
Schema: actionResolutionSnapshotSchema, Owner: strings.ToLower(ref.Owner),
Repository: strings.ToLower(ref.Repository), Ref: ref.Ref, ResolvedAt: time.Now().UTC(),
}
if err == nil {
entry.Commit = resolved.Commit
entry.ResolvedRef = resolved.ResolvedRef
} else {
var notPublic *NotPublicError
if !errors.As(err, &notPublic) {
Expand Down Expand Up @@ -292,7 +297,15 @@ func loadActionResolutionSnapshotEntry(path string, ref Reference) (Resolved, er
if entry.Missing {
return Resolved{}, &NotPublicError{}, true
}
return Resolved{Reference: ref, Commit: entry.Commit}, nil, true
if entry.ResolvedRef == "" {
// Entries created before resolved refs were recorded retain their exact
// commit but cannot grant a qualified branch or tag checkout.
entry.ResolvedRef = entry.Commit
}
if !validResolvedRef(ref, entry.Commit, entry.ResolvedRef) {
return Resolved{}, fmt.Errorf("action resolution snapshot entry is invalid"), true
}
return Resolved{Reference: ref, Commit: entry.Commit, ResolvedRef: entry.ResolvedRef}, nil, true
}

func loadActionResolutionJSON(path string, value any) bool {
Expand Down
22 changes: 13 additions & 9 deletions internal/action/source/mutable_ref_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,13 @@ type mutableRefCache struct {
}

type mutableRefEntry struct {
Schema string `json:"schema"`
Owner string `json:"owner"`
Repository string `json:"repository"`
Ref string `json:"ref"`
Commit string `json:"commit"`
ResolvedAt time.Time `json:"resolved_at"`
Schema string `json:"schema"`
Owner string `json:"owner"`
Repository string `json:"repository"`
Ref string `json:"ref"`
Commit string `json:"commit"`
ResolvedRef string `json:"resolved_ref"`
ResolvedAt time.Time `json:"resolved_at"`
}

func newMutableRefCache(root string, freshness time.Duration) (*mutableRefCache, error) {
Expand Down Expand Up @@ -64,9 +65,12 @@ func (c *mutableRefCache) resolve(ctx context.Context, ref Reference, resolve fu
if err != nil {
return Resolved{}, err
}
if resolved.ResolvedRef == "" {
resolved.ResolvedRef = resolved.Commit
}
entry := mutableRefEntry{
Schema: "buildkite-gha-action-ref-resolution/v1", Owner: strings.ToLower(ref.Owner),
Repository: strings.ToLower(ref.Repository), Ref: ref.Ref, Commit: resolved.Commit, ResolvedAt: time.Now().UTC(),
Repository: strings.ToLower(ref.Repository), Ref: ref.Ref, Commit: resolved.Commit, ResolvedRef: resolved.ResolvedRef, ResolvedAt: time.Now().UTC(),
}
if err := c.store(path, entry); err != nil {
return resolved, nil
Expand Down Expand Up @@ -97,11 +101,11 @@ func (c *mutableRefCache) load(path string, ref Reference, now time.Time) (Resol
if decoder.Decode(&entry) != nil || decoder.Decode(&struct{}{}) != io.EOF ||
entry.Schema != "buildkite-gha-action-ref-resolution/v1" ||
entry.Owner != strings.ToLower(ref.Owner) || entry.Repository != strings.ToLower(ref.Repository) ||
entry.Ref != ref.Ref || !shaRE.MatchString(entry.Commit) || entry.ResolvedAt.After(now) ||
entry.Ref != ref.Ref || !validResolvedRef(ref, entry.Commit, entry.ResolvedRef) || entry.ResolvedAt.After(now) ||
now.Sub(entry.ResolvedAt) >= c.freshness {
return Resolved{}, false
}
return Resolved{Reference: ref, Commit: entry.Commit}, true
return Resolved{Reference: ref, Commit: entry.Commit, ResolvedRef: entry.ResolvedRef}, true
}

func (c *mutableRefCache) store(path string, entry mutableRefEntry) error {
Expand Down
15 changes: 11 additions & 4 deletions internal/action/source/source.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,12 @@ var (
// Reference is a parsed remote action reference.
type Reference struct{ Owner, Repository, Path, Ref, Raw string }

// Resolved pins a requested reference to an immutable commit.
// Resolved pins a requested reference to an immutable commit and retains the
// selected branch or tag namespace when one was resolved.
type Resolved struct {
Reference Reference
Commit string
ResolvedRef string
SourceDigest string
}

Expand Down Expand Up @@ -283,7 +285,7 @@ func (r *Resolver) Resolve(ctx context.Context, ref Reference) (Resolved, error)
}
ref = parsed
if shaRE.MatchString(ref.Ref) {
return Resolved{Reference: ref, Commit: ref.Ref}, nil
return Resolved{Reference: ref, Commit: ref.Ref, ResolvedRef: ref.Ref}, nil
}
if r.cfg.resolutionSnapshot != nil {
return r.cfg.resolutionSnapshot.resolve(ctx, ref, r.resolveMutable)
Expand Down Expand Up @@ -324,7 +326,7 @@ func (r *Resolver) resolveMutable(ctx context.Context, ref Reference) (Resolved,
if err != nil {
return Resolved{}, err
}
return Resolved{Reference: ref, Commit: sha}, nil
return Resolved{Reference: ref, Commit: sha, ResolvedRef: "refs/" + kind + "/" + ref.Ref}, nil
}
var nf *NotPublicError
if !errors.As(err, &nf) {
Expand All @@ -343,8 +345,13 @@ func (r *Resolver) resolveCommit(ctx context.Context, ref Reference) (Resolved,
if !shaRE.MatchString(v.SHA) {
return Resolved{}, fmt.Errorf("GitHub returned malformed commit SHA")
}
return Resolved{Reference: ref, Commit: v.SHA}, nil
return Resolved{Reference: ref, Commit: v.SHA, ResolvedRef: v.SHA}, nil
}

func validResolvedRef(ref Reference, commit, resolvedRef string) bool {
return shaRE.MatchString(commit) && (resolvedRef == commit || resolvedRef == "refs/tags/"+ref.Ref || resolvedRef == "refs/heads/"+ref.Ref)
}

func (r *Resolver) peel(ctx context.Context, ref Reference, typ, sha string) (string, error) {
for range 5 {
if !shaRE.MatchString(sha) {
Expand Down
32 changes: 26 additions & 6 deletions internal/action/source/source_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,31 @@ func TestResolverTagPeelingAndHeaders(t *testing.T) {
}
ref, _ := Parse("owner/repo@v1")
got, err := r.Resolve(t.Context(), ref)
if err != nil || got.Commit != testSHA || len(calls) != 2 {
if err != nil || got.Commit != testSHA || got.ResolvedRef != "refs/tags/v1" || len(calls) != 2 {
t.Fatalf("Resolve = %#v, %v; calls %v", got, err, calls)
}
}

func TestResolverRetainsBranchNamespace(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/git/ref/heads/") {
_, _ = fmt.Fprintf(w, `{"object":{"type":"commit","sha":"%s"}}`, testSHA)
return
}
http.NotFound(w, r)
}))
defer ts.Close()
resolver, err := NewResolver(ts.Client(), WithTestEndpoints(ts.URL))
if err != nil {
t.Fatal(err)
}
ref, _ := Parse("owner/repo@main")
resolved, err := resolver.Resolve(t.Context(), ref)
if err != nil || resolved.Commit != testSHA || resolved.ResolvedRef != "refs/heads/main" {
t.Fatalf("Resolve() = %#v, %v", resolved, err)
}
}

func TestResolverOptionalAuthenticationAndVisibility(t *testing.T) {
for _, tt := range []struct {
name string
Expand Down Expand Up @@ -230,12 +250,12 @@ func TestResolverCachesMutableRefsWithBoundedFreshness(t *testing.T) {
t.Fatal(err)
}
resolved, err := resolver.Resolve(t.Context(), ref)
if err != nil || resolved.Commit != testSHA || calls.Load() != 1 {
if err != nil || resolved.Commit != testSHA || resolved.ResolvedRef != "refs/tags/v1" || calls.Load() != 1 {
t.Fatalf("cached Resolve() = %#v, %v; calls = %d", resolved, err, calls.Load())
}
time.Sleep(60 * time.Millisecond)
resolved, err = resolver.Resolve(t.Context(), ref)
if err != nil || resolved.Commit != commit || calls.Load() != 2 {
if err != nil || resolved.Commit != commit || resolved.ResolvedRef != "refs/tags/v1" || calls.Load() != 2 {
t.Fatalf("revalidated Resolve() = %#v, %v; calls = %d", resolved, err, calls.Load())
}
}
Expand Down Expand Up @@ -1107,14 +1127,14 @@ func TestActionResolutionSnapshotPinsAndRefreshesMutableRefs(t *testing.T) {
ref, _ := Parse("owner/repo@v1")
first := newResolver(false)
resolved, err := first.Resolve(t.Context(), ref)
if err != nil || resolved.Commit != testSHA || requests.Load() != 1 {
if err != nil || resolved.Commit != testSHA || resolved.ResolvedRef != "refs/tags/v1" || requests.Load() != 1 {
t.Fatalf("first resolution = %#v, %v; requests %d", resolved, err, requests.Load())
}
firstGeneration := first.ResolutionSnapshotID()
refreshed.Store(true)
second := newResolver(false)
resolved, err = second.Resolve(t.Context(), ref)
if err != nil || resolved.Commit != testSHA || requests.Load() != 1 || second.ResolutionSnapshotID() != firstGeneration {
if err != nil || resolved.Commit != testSHA || resolved.ResolvedRef != "refs/tags/v1" || requests.Load() != 1 || second.ResolutionSnapshotID() != firstGeneration {
t.Fatalf("reused resolution = %#v, %v; requests %d; generation %q", resolved, err, requests.Load(), second.ResolutionSnapshotID())
}
if err := os.Remove(second.cfg.resolutionSnapshot.entryPath(ref)); err != nil {
Expand All @@ -1128,7 +1148,7 @@ func TestActionResolutionSnapshotPinsAndRefreshesMutableRefs(t *testing.T) {
}
third := newResolver(true)
resolved, err = third.Resolve(t.Context(), ref)
if err != nil || resolved.Commit != nextSHA || requests.Load() != 2 || third.ResolutionSnapshotID() == firstGeneration {
if err != nil || resolved.Commit != nextSHA || resolved.ResolvedRef != "refs/tags/v1" || requests.Load() != 2 || third.ResolutionSnapshotID() == firstGeneration {
t.Fatalf("refreshed resolution = %#v, %v; requests %d; generation %q", resolved, err, requests.Load(), third.ResolutionSnapshotID())
}
}
Expand Down
6 changes: 5 additions & 1 deletion internal/cli/validate_batch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ type batchCountingActionSource struct {
func (s *batchCountingActionSource) Fetch(_ context.Context, ref actionsource.Reference) (actionsource.Resolved, actionsource.Materialized, error) {
s.calls++
commit := strings.Repeat("a", 40)
return actionsource.Resolved{Reference: ref, Commit: commit, SourceDigest: s.digest}, actionsource.Materialized{RepositoryRoot: s.root, ActionRoot: s.root, SourceDigest: s.digest}, nil
resolvedRef := "refs/tags/" + ref.Ref
if ref.Ref == commit {
resolvedRef = commit
}
return actionsource.Resolved{Reference: ref, Commit: commit, ResolvedRef: resolvedRef, SourceDigest: s.digest}, actionsource.Materialized{RepositoryRoot: s.root, ActionRoot: s.root, SourceDigest: s.digest}, nil
}

func TestValidateBatchWritesAndResumesAtomicReports(t *testing.T) {
Expand Down
Loading