Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ Contributions are welcome. See [CONTRIBUTING.md](./CONTRIBUTING.md) to get start

## Requirements

GitHub Enterprise Server (GHES) is not supported.

Requires the [`gh` CLI](https://cli.github.com/). Install it first, then install the extension:

```bash
Expand Down Expand Up @@ -48,6 +50,22 @@ lockfile to the new SHA. Suspicious pins whose recorded commit is no longer
reachable upstream are left as errors — use `--accept-moved` to re-resolve
those as well.

### GitHub Enterprise Cloud with data residency

Authenticate `gh` to your tenant, then
run the extension from your tenant repository checkout:

```bash
gh auth login --hostname octocorp.ghe.com
# From the repository checkout:
gh actions-lock
```

With no conflicting environment overrides, the CLI infers the host from the
repository remote and uses the credentials stored by `gh` for that host. You do
not need to export a token or pass `--hostname` on every run. The account must
have read access to the tenant repositories used by your workflows.

### Self repository actions (`$/…`)

`uses: $/…` references an action or reusable workflow in the **same repository** as
Expand Down
19 changes: 19 additions & 0 deletions cmd/gh-actions-lock/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,24 @@ import (
"github.com/stretchr/testify/require"
)

func TestCheckCommand_HelpExplainsHostAndAuthOverrides(t *testing.T) {
cmd := newRootCmd(nil)
var out strings.Builder
cmd.SetOut(&out)
cmd.SetArgs([]string{"--help"})
require.NoError(t, cmd.Execute())
for _, text := range []string{
"Host selection: --hostname, then GH_HOST",
"GH_REPO or a remote on a host known to gh",
"GH_TOKEN takes precedence over GITHUB_TOKEN",
"GitHub Enterprise Server (GHES) is not supported.",
"--hostname selects the host; it does not override token variables.",
"env -u GH_TOKEN -u GITHUB_TOKEN gh auth status --hostname TENANT.ghe.com",
} {
assert.Contains(t, out.String(), text)
}
}

func TestCheckCommand_JSONWithHTTPMocks(t *testing.T) {
reg := &httpmock.Registry{}
defer reg.Verify(t)
Expand Down Expand Up @@ -1030,6 +1048,7 @@ jobs:
for _, f := range payload.Findings {
if f.Category == "ref-moved" {
hasRefMoved = true
assert.Equal(t, "run `gh actions-lock --relock` to refresh the lock entry", f.Remediation)
}
}
assert.True(t, hasRefMoved,
Expand Down
12 changes: 11 additions & 1 deletion cmd/gh-actions-lock/format/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"io"
"strings"

"github.com/github/gh-actions-lock/internal/ghapi"
"github.com/github/gh-actions-lock/internal/pipeline/checks"
)

Expand Down Expand Up @@ -74,6 +75,7 @@ type Finding struct {
// Dependency is the JSON-safe view of a resolved dependency, deduplicated
// across workflows in the JSON output.
type Dependency struct {
Hostname string `json:"hostname,omitempty"`
NWO string `json:"nwo"`
Ref string `json:"ref"`
SHA string `json:"sha"`
Expand Down Expand Up @@ -117,8 +119,14 @@ func findingFromReport(f checks.Finding) Finding {
// the comma-separated user selection (e.g. "valid,findings,workflows").
// cliVersion and lockfileVersion are emitted as top-level fields so consumers
// can pin behavior to a known schema.
func WriteJSON(w io.Writer, report *checks.Report, valid bool, fieldsCSV, cliVersion, lockfileVersion string) error {
func WriteJSON(w io.Writer, report *checks.Report, valid bool, fieldsCSV, cliVersion, lockfileVersion, homeHost string) error {
fields := strings.Split(fieldsCSV, ",")
outputHostname := func(host string) string {
if ghapi.IsProxima(homeHost) && host == "github.com" {
return host
}
return ""
}

// Build all data lazily.
var allFindings []Finding
Expand Down Expand Up @@ -176,6 +184,7 @@ func WriteJSON(w io.Writer, report *checks.Report, valid bool, fieldsCSV, cliVer
continue
}
d := Dependency{
Hostname: outputHostname(inv.Dep.Hostname),
NWO: inv.Dep.NWO,
Ref: inv.Dep.Ref,
SHA: inv.Dep.SHA,
Expand Down Expand Up @@ -212,6 +221,7 @@ func WriteJSON(w io.Writer, report *checks.Report, valid bool, fieldsCSV, cliVer
}
for _, inv := range wr.Inventory {
wf.Dependencies = append(wf.Dependencies, Dependency{
Hostname: outputHostname(inv.Dep.Hostname),
NWO: inv.Dep.NWO,
Ref: inv.Dep.Ref,
SHA: inv.Dep.SHA,
Expand Down
52 changes: 51 additions & 1 deletion cmd/gh-actions-lock/format/json_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,56 @@
package format

import "testing"
import (
"bytes"
"encoding/json"
"testing"

"github.com/github/gh-actions-lock/internal/dep"
"github.com/github/gh-actions-lock/internal/pipeline/checks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestJSONHostnameOnlyForProximaDotcomDependencies(t *testing.T) {
for _, tt := range []struct {
name, homeHost, depHost, want string
}{
{"dotcom", "github.com", "github.com", ""},
{"tenant local", "tenant.ghe.com", "tenant.ghe.com", ""},
{"tenant public", "tenant.ghe.com", "github.com", "github.com"},
{"unresolved", "tenant.ghe.com", "", ""},
} {
t.Run(tt.name, func(t *testing.T) {
report := &checks.Report{Workflows: []checks.WorkflowReport{{
Path: ".github/workflows/ci.yml",
Inventory: []checks.InventoryEntry{{
Dep: dep.Dependency{Hostname: tt.depHost, NWO: "o/r", Ref: "v1", SHA: "abc"},
Direct: true,
}},
}}}
var out bytes.Buffer
require.NoError(t, WriteJSON(&out, report, true, "dependencies,workflows", "dev", "v0.0.3", tt.homeHost))
var payload struct {
Dependencies []map[string]any `json:"dependencies"`
Workflows []struct {
Dependencies []map[string]any `json:"dependencies"`
} `json:"workflows"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &payload))
require.Len(t, payload.Dependencies, 1)
require.Len(t, payload.Workflows, 1)
require.Len(t, payload.Workflows[0].Dependencies, 1)
for _, entry := range []map[string]any{payload.Dependencies[0], payload.Workflows[0].Dependencies[0]} {
if tt.want == "" {
assert.NotContains(t, entry, "hostname")
} else {
assert.Equal(t, tt.want, entry["hostname"])
}
}
assert.Equal(t, tt.depHost, report.Workflows[0].Inventory[0].Dep.Hostname)
})
}
}

func TestValidateJSONFields(t *testing.T) {
tests := []struct {
Expand Down
4 changes: 2 additions & 2 deletions cmd/gh-actions-lock/format/terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ func renderTermFindingDetail(out *ui.UI, f checks.Finding, dep string) {
if f.Category == checks.UnreachablePin && f.Dependency != nil {
owner, repo := f.Dependency.OwnerRepo()
if owner != "" {
out.TermDetail(" ↳ %s", out.TermDim(fmt.Sprintf("https://github.com/%s/%s/releases", owner, repo)))
out.TermDetail(" ↳ %s", out.TermDim(DepReleaseURL(f.Dependency.Hostname, owner+"/"+repo, nil)))
}
}
if IsAlertedCategory(f.Category) && f.Remediation != "" {
Expand Down Expand Up @@ -278,7 +278,7 @@ func renderFindingDetail(out *ui.UI, f checks.Finding, dep string) {
if f.Category == checks.UnreachablePin && f.Dependency != nil {
owner, repo := f.Dependency.OwnerRepo()
if owner != "" {
out.Detail(" ↳ %s", out.Dim(fmt.Sprintf("https://github.com/%s/%s/releases", owner, repo)))
out.Detail(" ↳ %s", out.Dim(DepReleaseURL(f.Dependency.Hostname, owner+"/"+repo, nil)))
}
}
if IsAlertedCategory(f.Category) && f.Remediation != "" {
Expand Down
9 changes: 6 additions & 3 deletions cmd/gh-actions-lock/format/url.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,20 @@ type TagObjectCheck func(owner, repo, sha string) bool
// /commit/<tagobject-sha> returns 404 because the tag object is not a
// commit. Non-SHA refs link to /releases/tag/<ref>. A nil isTagObject
// (or one that returns false) falls back to the plain /commit/<sha> path.
func DepReleaseURL(dep string, isTagObject TagObjectCheck) string {
func DepReleaseURL(hostname, dep string, isTagObject TagObjectCheck) string {
if hostname == "" {
hostname = "github.com"
}
ar := parserlock.ParseActionRef(dep)
if ar == nil {
// ParseActionRef rejects refless inputs; fall back to splitting
// the bare NWO so links to dep keys without a ref still render.
if owner, repo, ok := parserlock.SplitNWO(dep); ok {
return "https://github.com/" + owner + "/" + repo + "/releases"
return "https://" + hostname + "/" + owner + "/" + repo + "/releases"
}
return ""
}
base := "https://github.com/" + ar.Owner + "/" + ar.Repo
base := "https://" + hostname + "/" + ar.Owner + "/" + ar.Repo
ref := ar.Ref
if isHexSHA(ref) {
if isTagObject != nil && isTagObject(ar.Owner, ar.Repo, ref) {
Expand Down
9 changes: 8 additions & 1 deletion cmd/gh-actions-lock/format/url_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,17 @@ func TestDepReleaseURL(t *testing.T) {

tests := []struct {
name string
hostname string
dep string
isTagObject TagObjectCheck
want string
}{
{
name: "tenant release stays on its host",
hostname: "tenant.ghe.com",
dep: "o/r@v1",
want: "https://tenant.ghe.com/o/r/releases/tag/v1",
},
{
name: "commit-sha pin → /commit/",
dep: "actions/checkout@" + commitSHA,
Expand Down Expand Up @@ -83,7 +90,7 @@ func TestDepReleaseURL(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, DepReleaseURL(tt.dep, tt.isTagObject))
assert.Equal(t, tt.want, DepReleaseURL(tt.hostname, tt.dep, tt.isTagObject))
})
}
}
2 changes: 1 addition & 1 deletion cmd/gh-actions-lock/pin_summary.go
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@ func renderInvestigationAlerts(console *ui.UI, investigated []pin.Entry, r *reso
ui.Pluralize(len(groups), "requires", "require"))
for _, g := range groups {
dep := g.NWO + "@" + g.Ref
console.TermDetail(" %s", console.TermLink(console.TermYellow(dep), format.DepReleaseURL(dep, r.IsKnownTagObject)))
console.TermDetail(" %s", console.TermLink(console.TermYellow(dep), format.DepReleaseURL(g.Hostname, dep, r.IsKnownTagObject)))
for _, wf := range g.workflows {
console.TermDetail(" └─ %s", console.TermDim(wf))
}
Expand Down
Loading
Loading