Skip to content
Open
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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,30 @@ ratchet upgrade -out workflow-compiled.yml workflow.yml
> [!NOTE]
> Performs an `update` if the constraint ref is for a branch.

#### Min release age

When running `pin`, `update`, or `upgrade`, you can require that GitHub Actions
commits and releases be at least a minimum age before ratchet will resolve them.
This reduces the risk of pinning to a freshly compromised release (similar to
[pnpm's minimumReleaseAge](https://pnpm.io/settings#minimumreleaseage)).

```shell
# Only adopt versions at least 24 hours old
ratchet update -min-release-age 24h workflow.yml

# Same via environment variable (flag overrides env default)
export RATCHET_MIN_RELEASE_AGE=24h
ratchet upgrade workflow.yml

# Disable for an emergency refresh
ratchet update -min-release-age 0 workflow.yml
```

By default, min release age is **off** (`0`). It applies to **GitHub Actions
only**; container image resolution is unchanged. Pinned workflows in git are not
affected until you refresh pins. This does not protect against an attacker
repointing a tag to an old commit.

#### Lint

The `lint` command reports if all versions are pinned, printing any violations,
Expand Down
20 changes: 16 additions & 4 deletions command/pin.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"os"
"strings"
"time"

"github.com/sethvargo/ratchet/internal/concurrency"
"github.com/sethvargo/ratchet/parser"
Expand Down Expand Up @@ -36,9 +37,10 @@ FLAGS
`

type PinCommand struct {
flagConcurrency int64
flagParser string
flagOut string
flagConcurrency int64
flagParser string
flagOut string
flagMinReleaseAge time.Duration
}

func (c *PinCommand) Desc() string {
Expand All @@ -57,6 +59,14 @@ func (c *PinCommand) Flags() *flag.FlagSet {
f.StringVar(&c.flagParser, "parser", "actions", "parser to use")
f.StringVar(&c.flagOut, "out", "", "output path (defaults to input file)")

minReleaseAge, err := resolver.MinReleaseAgeFromEnv()
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(2)
}
f.DurationVar(&c.flagMinReleaseAge, "min-release-age", minReleaseAge,
"minimum age of GitHub Actions commit/release before pin/update (0=off); also RATCHET_MIN_RELEASE_AGE")

return f
}

Expand All @@ -71,7 +81,9 @@ func (c *PinCommand) Run(ctx context.Context, originalArgs []string) error {
return err
}

res, err := resolver.NewDefaultResolver(ctx)
res, err := resolver.NewDefaultResolver(ctx, resolver.Policy{
MinReleaseAge: c.flagMinReleaseAge,
})
if err != nil {
return fmt.Errorf("failed to create resolver: %w", err)
}
Expand Down
4 changes: 3 additions & 1 deletion command/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ func (c *UpdateCommand) Run(ctx context.Context, originalArgs []string) error {
return err
}

res, err := resolver.NewDefaultResolver(ctx)
res, err := resolver.NewDefaultResolver(ctx, resolver.Policy{
MinReleaseAge: c.flagMinReleaseAge,
})
if err != nil {
return fmt.Errorf("failed to create resolver: %w", err)
}
Expand Down
22 changes: 17 additions & 5 deletions command/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"os"
"strings"
"time"

"github.com/sethvargo/ratchet/internal/concurrency"
"github.com/sethvargo/ratchet/parser"
Expand Down Expand Up @@ -33,10 +34,11 @@ FLAGS
`

type UpgradeCommand struct {
flagConcurrency int64
flagParser string
flagOut string
flagPin bool
flagConcurrency int64
flagParser string
flagOut string
flagPin bool
flagMinReleaseAge time.Duration
}

func (c *UpgradeCommand) Desc() string {
Expand All @@ -56,6 +58,14 @@ func (c *UpgradeCommand) Flags() *flag.FlagSet {
f.StringVar(&c.flagOut, "out", "", "output path (defaults to input file)")
f.BoolVar(&c.flagPin, "pin", true, "pin resolved upgraded versions")

minReleaseAge, err := resolver.MinReleaseAgeFromEnv()
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(2)
}
f.DurationVar(&c.flagMinReleaseAge, "min-release-age", minReleaseAge,
"minimum age of GitHub Actions commit/release before upgrade (0=off); also RATCHET_MIN_RELEASE_AGE")

return f
}

Expand All @@ -70,7 +80,9 @@ func (c *UpgradeCommand) Run(ctx context.Context, originalArgs []string) error {
return err
}

res, err := resolver.NewDefaultResolver(ctx)
res, err := resolver.NewDefaultResolver(ctx, resolver.Policy{
MinReleaseAge: c.flagMinReleaseAge,
})
if err != nil {
return fmt.Errorf("failed to create resolver: %w", err)
}
Expand Down
70 changes: 57 additions & 13 deletions resolver/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ func NormalizeActionsRef(in string) string {
// Actions resolves GitHub references.
type Actions struct {
client *github.Client
policy Policy
}

// NewActions creates a new resolver for GitHub Actions.
func NewActions(ctx context.Context) (*Actions, error) {
func NewActions(ctx context.Context, policy Policy) (*Actions, error) {
httpClient := &http.Client{}
if ActionsToken != "" {
ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: ActionsToken})
Expand All @@ -47,6 +48,7 @@ func NewActions(ctx context.Context) (*Actions, error) {

return &Actions{
client: client,
policy: policy,
}, nil
}

Expand All @@ -65,6 +67,22 @@ func (g *Actions) Resolve(ctx context.Context, value string) (string, error) {
return "", fmt.Errorf("failed to get commit sha: %w", err)
}

if g.policy.enabled() {
commit, _, err := g.client.Repositories.GetCommit(ctx, owner, repo, sha, nil)
if err != nil {
return "", fmt.Errorf("failed to get commit: %w", err)
}
now := time.Now()
if !commitOlderThan(commit, g.policy.MinReleaseAge, now) {
commitTime := commitDate(commit)
ago := now.Sub(commitTime).Round(time.Minute)
return "", fmt.Errorf(
"%s/%s@%s points to commit %s (%s ago), younger than min-release-age %s; wait or use -min-release-age 0",
owner, repo, ref, sha[:7], ago, g.policy.MinReleaseAge,
)
}
}

name := owner + "/" + repo
if path != "" {
name = name + "/" + path
Expand Down Expand Up @@ -98,29 +116,55 @@ func (g *Actions) LatestVersion(ctx context.Context, value string) (string, erro
return value, nil
}

release, _, err := g.client.Repositories.GetLatestRelease(ctx, owner, repo)
if err != nil {
return "", fmt.Errorf("failed to get latest release: %w", err)
}

name := owner + "/" + repo
if path != "" {
name = name + "/" + path
}
version := *release.TagName
if strings.HasPrefix(ref, "v") {
refPrecision := strings.Count(githubRef.ref, ".")
for strings.Count(version, ".") < refPrecision {
version += ".0"

var version string
if g.policy.enabled() {
releases, err := g.listReleases(ctx, owner, repo)
if err != nil {
return "", err
}
versionParts := strings.Split(version, ".")
version = strings.Join(versionParts[:refPrecision+1], ".")
now := time.Now()
var ok bool
version, ok = selectEligibleRelease(releases, g.policy.MinReleaseAge, ref, now)
if !ok {
return "", fmt.Errorf(
"no release for %s/%s published at least %s ago matching %q",
owner, repo, g.policy.MinReleaseAge, ref,
)
}
} else {
release, _, err := g.client.Repositories.GetLatestRelease(ctx, owner, repo)
if err != nil {
return "", fmt.Errorf("failed to get latest release: %w", err)
}
version = trimReleaseVersion(*release.TagName, ref)
}

result := fmt.Sprintf("%s@%s", name, version)
return result, nil
}

func (g *Actions) listReleases(ctx context.Context, owner, repo string) ([]*github.RepositoryRelease, error) {
opts := &github.ListOptions{PerPage: 100}
var all []*github.RepositoryRelease
for {
releases, resp, err := g.client.Repositories.ListReleases(ctx, owner, repo, opts)
if err != nil {
return nil, fmt.Errorf("failed to list releases: %w", err)
}
all = append(all, releases...)
if resp.NextPage == 0 {
break
}
opts.Page = resp.NextPage
}
return all, nil
}

func ParseActionRef(s string) (*GitHubRef, error) {
parts := strings.SplitN(s, "/", 2)
if len(parts) < 2 {
Expand Down
4 changes: 2 additions & 2 deletions resolver/actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ func TestActions_Resolve(t *testing.T) {
t.Parallel()

ctx := context.Background()
resolver, err := NewActions(ctx)
resolver, err := NewActions(ctx, Policy{})
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -59,7 +59,7 @@ func TestActions_LatestVersion(t *testing.T) {
t.Parallel()

ctx := context.Background()
resolver, err := NewActions(ctx)
resolver, err := NewActions(ctx, Policy{})
if err != nil {
t.Fatal(err)
}
Expand Down
101 changes: 101 additions & 0 deletions resolver/policy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package resolver

import (
"fmt"
"os"
"strings"
"time"

"github.com/google/go-github/v73/github"
)

// Policy configures resolution behavior. Zero value disables optional checks.
type Policy struct {
MinReleaseAge time.Duration // 0 = off
}

func (p Policy) enabled() bool {
return p.MinReleaseAge > 0
}

// MinReleaseAgeFromEnv reads RATCHET_MIN_RELEASE_AGE (e.g. "24h", "1440m").
// Returns 0 if unset.
func MinReleaseAgeFromEnv() (time.Duration, error) {
v := os.Getenv("RATCHET_MIN_RELEASE_AGE")
if v == "" {
return 0, nil
}
d, err := time.ParseDuration(v)
if err != nil {
return 0, fmt.Errorf("invalid RATCHET_MIN_RELEASE_AGE %q: %w", v, err)
}
if d < 0 {
return 0, fmt.Errorf("invalid RATCHET_MIN_RELEASE_AGE %q: must be non-negative", v)
}
return d, nil
}

func commitDate(commit *github.RepositoryCommit) time.Time {
if commit == nil {
return time.Time{}
}
c := commit.GetCommit()
if c == nil {
return time.Time{}
}
if committer := c.GetCommitter(); committer != nil {
if d := committer.GetDate(); !d.Time.IsZero() {
return d.Time
}
}
if author := c.GetAuthor(); author != nil {
if d := author.GetDate(); !d.Time.IsZero() {
return d.Time
}
}
return time.Time{}
}

// commitOlderThan reports whether the commit is at least minAge old relative to now.
func commitOlderThan(commit *github.RepositoryCommit, minAge time.Duration, now time.Time) bool {
t := commitDate(commit)
if t.IsZero() {
return true
}
return now.Sub(t) >= minAge
}

func trimReleaseVersion(version, ref string) string {
if !strings.HasPrefix(ref, "v") {
return version
}
refPrecision := strings.Count(ref, ".")
for strings.Count(version, ".") < refPrecision {
version += ".0"
}
versionParts := strings.Split(version, ".")
return strings.Join(versionParts[:refPrecision+1], ".")
}

// selectEligibleRelease returns the tag name of the newest release that satisfies
// minAge and semver trimming for ref.
func selectEligibleRelease(releases []*github.RepositoryRelease, minAge time.Duration, ref string, now time.Time) (string, bool) {
for _, rel := range releases {
if rel.GetDraft() || rel.GetPrerelease() {
continue
}
published := rel.GetPublishedAt()
if published.Time.IsZero() {
continue
}
if now.Sub(published.Time) < minAge {
continue
}
tag := rel.GetTagName()
if tag == "" {
continue
}
return trimReleaseVersion(tag, ref), true
}
return "", false
}
Loading