Skip to content
Draft
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
139 changes: 139 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ Contributions are welcome. See [CONTRIBUTING.md](./CONTRIBUTING.md) to get start

## Requirements

Supported targets are `github.com` and GitHub Enterprise Cloud with data
residency (`*.ghe.com`), subject to the [availability note below](#github-enterprise-cloud-with-data-residency).
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 +52,141 @@ 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

> [!IMPORTANT]
> Hostname-aware tenant/public resolution is not yet released. It is being
> developed in [#137](https://github.com/github/gh-actions-lock/pull/137);
> neither v0.1.6 nor v0.1.7-rc.1 includes it. Installing or upgrading the
> published extension does not install this draft implementation.

With a build that includes this support, 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.

#### Host and credential overrides

Host selection and credential selection are separate. Host selection uses the
first available source:

1. `--hostname`.
2. `GH_HOST`.
3. The current repository from `gh`: `GH_REPO` if set, otherwise a remote on a
host known to `gh`. Among eligible remotes, `upstream` takes precedence over
`github`, then `origin`.
4. `github.com` if the current repository cannot be determined.

A host-qualified `GH_REPO` such as `octocorp.ghe.com/OWNER/REPO` overrides remote
discovery. An unqualified `OWNER/REPO` uses `gh`'s default host: the sole
configured host if there is one, otherwise `github.com` (unless `GH_HOST` is set).
Authenticate to the tenant before relying on remote discovery. For an
unambiguous host override:

```bash
gh actions-lock --hostname octocorp.ghe.com --no-interactive
```

For `github.com` and `*.ghe.com`, the first **nonempty** credential source wins:
`GH_TOKEN`, then `GITHUB_TOKEN`, then stored credentials for that host.

Stored credentials come from `gh` configuration or its secure credential store.
`GH_ENTERPRISE_TOKEN` does **not** select credentials for `*.ghe.com`.
Conversely, a dotcom `GH_TOKEN` can override valid stored tenant credentials
and cause a tenant `401`. `--hostname` does not override token environment
variables. For tenant requests, a rejected token is not retried using stored
credentials or anonymous access.

#### Diagnose authentication without exposing tokens

Check which overrides are set without printing their values:

```bash
for name in GH_HOST GH_REPO GH_TOKEN GITHUB_TOKEN; do
if printenv "$name" >/dev/null; then
printf '%s is set\n' "$name"
fi
done
```

If the overrides are unintended, test stored tenant credentials with a
command-scoped clean environment. These commands do not change your shell's
environment or print token values:

```bash
env -u GH_TOKEN -u GITHUB_TOKEN \
gh auth status --hostname octocorp.ghe.com
env -u GH_TOKEN -u GITHUB_TOKEN \
gh api --hostname octocorp.ghe.com user --silent
```

If needed, sign in without the conflicting token overrides:

```bash
env -u GH_TOKEN -u GITHUB_TOKEN \
gh auth login --hostname octocorp.ghe.com
```

Then, from the tenant checkout, bypass unintended host, repository, and token
overrides for a read-only remote check:

```bash
env -u GH_HOST -u GH_REPO -u GH_TOKEN -u GITHUB_TOKEN \
gh actions-lock --hostname octocorp.ghe.com --rescan --no-fix
```

Keep intentional overrides, especially in automation; supply a token valid for
the selected host instead. Do not share token values or use
`gh auth status --show-token` in diagnostic output. A `403` can also mean missing repository
access or an organization policy restriction; changing hosts or retrying
anonymously is not a remedy.

#### Resolution and lockfile behavior

New dependencies resolve on the tenant first. Only a repository-level `404`
permits fallback to a **public** repository on `github.com`. A tenant repository
shadows its dotcom namesake even when the requested ref is missing. Authorization
errors, rate limits, and network failures do not trigger fallback.

Generation and refresh write v0.0.3. An omitted `hostname` binds a pin to the
home host: `github.com` in a dotcom repository, or the selected tenant in a
`*.ghe.com` repository. Tenant-local pins omit `hostname`; public dotcom pins
on a tenant explicitly record `hostname: github.com`. Dotcom-root output omits
`hostname`. No explicit tenant hostname is emitted.

Proxima execution requires v0.0.3. During migration, legacy v0.0.1/v0.0.2 pins
retain their dotcom binding, with repository IDs verified before writing
explicit `github.com`. Older preview files with an explicit home-tenant hostname
are rewritten to omit it without changing the pinned identity. On Proxima,
recorded repository IDs are checked on the bound host before pins are reused.
An omitted v0.0.3 public pin from an older producer is now tenant-bound: a missing
tenant repository or mismatched IDs fails without falling back or rewriting the
pin. Restore a correctly host-bound lockfile or review the dependencies before
regenerating. Read-only checks do not migrate files or prove that their wire
format is accepted for execution. `--verify-local` checks coverage, not host
identity.

Existing pins retain their SHA and host binding during ordinary runs.
`--rescan` and `--relock` do not change the bound host. Conflicting host
assignments for the same repository are rejected.

Public fallback uses unauthenticated dotcom requests, subject to GitHub's
anonymous API rate limit. Tenant tokens and headers are never forwarded to
dotcom.

If any dependency cannot be resolved, generation exits nonzero without writing
an incomplete lockfile. `--json` reports `valid: false`. `--verify-local` checks
only recorded coverage; it does not prove successful remote resolution.

### 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
3 changes: 3 additions & 0 deletions cmd/gh-actions-lock/format/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,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 @@ -176,6 +177,7 @@ func WriteJSON(w io.Writer, report *checks.Report, valid bool, fieldsCSV, cliVer
continue
}
d := Dependency{
Hostname: inv.Dep.Hostname,
NWO: inv.Dep.NWO,
Ref: inv.Dep.Ref,
SHA: inv.Dep.SHA,
Expand Down Expand Up @@ -212,6 +214,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: inv.Dep.Hostname,
NWO: inv.Dep.NWO,
Ref: inv.Dep.Ref,
SHA: inv.Dep.SHA,
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