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
29 changes: 26 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,10 @@ 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
the defining file, resolved at the **running commit**. Because it always resolves to
that repository's running SHA it is **inherently pinned** — no lockfile entry is
required, and it is valid anywhere a relative `./…` reference is:
the defining file, resolved at the **running commit** of that workflow or action.
This is independent of the workspace populated by `actions/checkout`. The reference
is **inherently pinned** and needs no lockfile entry. It is valid anywhere a relative
`./…` reference is:

```yaml
steps:
Expand All @@ -117,6 +118,28 @@ leave `./…` refs untouched on fix runs, opt out with
gh actions-lock --no-migrate-local-actions
```

Migration stops before writing files if a local action follows an
`actions/checkout` step with a non-empty `ref` other than `${{ github.sha }}`
in the same job, including checkouts and local references reached through
in-repo composite actions. Checkout state follows nested `./…` and `$/…`
calls and carries back to subsequent caller steps. Since composite definitions
are migrated repository-wide, their callers are checked even in workflows
outside a scoped run. This includes workflows in both `.github/workflows` and
the directory configured by `GH_ACTIONS_LOCK_WORKFLOWS_DIR`. A custom ref can
make `./…` load different code from `$/…`. The error identifies the action file
and the checkout's file and line.
Review the checkout and action references together, or use `--no-migrate-local-actions`
to preserve the local references; workflows that keep `./…` remain ineligible
for onboarding. This check covers explicit checkout steps in workflows and
in-repo composite actions, not workspace changes made by scripts or remote actions.

For a workflow triggered by `pull_request_target`, `$/…` in that workflow uses
the default-branch commit selected for the run (`github.sha`), regardless of the
PR's base branch (see [GitHub's event semantics](https://github.blog/changelog/2025-11-07-actions-pull_request_target-and-environment-branch-protections-changes/)).
A checkout of the same repository with no `ref`, or with `ref: ${{ github.sha }}`,
uses that commit too. Checking out `ref: ${{ github.event.pull_request.head.sha }}`
would make `./…` use the PR head instead, so the same guard rejects migration.

## How it works

A repo gets a lockfile (located at [`.github/workflows/actions.lock`](https://github.com/github/gh-actions-lock/blob/main/.github/workflows/actions.lock)) and workflows are onboarded to the lockfile on a per-workflow basis.
Expand Down
105 changes: 104 additions & 1 deletion cmd/gh-actions-lock/migrate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,114 @@ import (
"github.com/stretchr/testify/require"
)

func TestMigrateLocalActions_CustomCheckoutRefWritesNothing(t *testing.T) {
for _, args := range [][]string{nil, {"--json"}, {".github/workflows/first.yml"}} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
t.Chdir(t.TempDir())
require.NoError(t, os.Mkdir(".git", 0o755))
files := map[string]string{
".github/workflows/first.yml": "jobs:\n build:\n steps:\n - uses: ./.github/actions/local\n",
".github/workflows/second.yml": `jobs:
refresh:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ env.TARGET_BRANCH }}
- uses: $/.github/actions/local
`,
".github/actions/local/action.yml": "runs:\n using: composite\n steps:\n - uses: ./.github/actions/helper\n",
".github/actions/helper/action.yml": "runs:\n using: composite\n steps:\n - run: echo hello\n shell: bash\n",
".github/workflows/actions.lock": "version: 'v0.0.3'\nworkflows: {}\ndependencies: {}\n",
}
for path, content := range files {
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
}
before := snapshotWorkflows(t)
transport := &requestCountingTransport{}

_, _, err := runCommandWithHTTP(t, transport, args...)

require.Error(t, err)
assert.Contains(t, err.Error(), "second.yml")
assert.Contains(t, err.Error(), "${{ env.TARGET_BRANCH }}")
assert.Contains(t, err.Error(), "--no-migrate-local-actions")
assert.Equal(t, before, snapshotWorkflows(t))
assert.Zero(t, transport.calls.Load(), "unsafe migration stops before resolving dependencies")
})
}
}

func TestMigrateLocalActions_ConfiguredWorkflowCallers(t *testing.T) {
for _, tt := range []struct {
name string
callerDir string
absoluteDir bool
blocked bool
}{
{name: "relative configured directory blocks unsafe migration", callerDir: ".github/custom-workflows", blocked: true},
{name: "absolute configured directory blocks unsafe migration", callerDir: ".github/custom-workflows", absoluteDir: true, blocked: true},
{name: "default directory remains checked with an override", callerDir: ".github/workflows", blocked: true},
{name: "running commit checkout allows migration", callerDir: ".github/custom-workflows"},
} {
t.Run(tt.name, func(t *testing.T) {
t.Chdir(t.TempDir())
require.NoError(t, os.Mkdir(".git", 0o755))
workflowsDir := filepath.Join(".github", "custom-workflows")
configuredDir := workflowsDir
if tt.absoluteDir {
absDir, err := filepath.Abs(workflowsDir)
require.NoError(t, err)
configuredDir = absDir
}
t.Setenv("GH_ACTIONS_LOCK_WORKFLOWS_DIR", configuredDir)
ref := "${{ github.sha }}"
if tt.blocked {
ref = "other"
}
firstPath := filepath.Join(workflowsDir, "first.yml")
secondPath := filepath.Join(tt.callerDir, "second.yml")
files := map[string]string{
firstPath: "jobs:\n build:\n steps:\n - uses: $/.github/actions/local\n",
secondPath: "jobs:\n build:\n steps:\n - uses: actions/checkout@v4\n with: {ref: '" + ref + "'}\n - uses: $/.github/actions/local\n",
".github/actions/local/action.yml": "runs:\n using: composite\n steps:\n - uses: ./.github/actions/helper\n",
".github/actions/helper/action.yml": "runs:\n using: composite\n steps:\n - run: echo hello\n shell: bash\n",
filepath.Join(workflowsDir, "actions.lock"): "version: 'v0.0.3'\nworkflows: {}\ndependencies: {}\n",
}
for path, content := range files {
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
}
before := snapshotWorkflows(t)
transport := &requestCountingTransport{}

_, _, err := runCommandWithHTTP(t, transport, firstPath)

if tt.blocked {
require.Error(t, err)
assert.Contains(t, err.Error(), secondPath)
assert.Contains(t, err.Error(), `ref "other"`)
assert.Equal(t, before, snapshotWorkflows(t))
assert.Zero(t, transport.calls.Load(), "unsafe migration stops before resolving dependencies")
return
}
require.NoError(t, err)
content, err := os.ReadFile(".github/actions/local/action.yml")
require.NoError(t, err)
assert.Contains(t, string(content), "uses: $/.github/actions/helper")
caller, err := os.ReadFile(secondPath)
require.NoError(t, err)
assert.Equal(t, files[secondPath], string(caller))
})
}
}

// TestMigrateLocalActions_CompositeActionFiles verifies that migration
// rewrites `./…` not just in workflow files but also inside in-repo composite
// action definitions (action.yml), including ones nested a few directories deep.
// A `./…` whose target does not resolve to an in-repo action file is left
// untouched — that is the equivalence guard that keeps `$/` a safe replacement.
// untouched.
func TestMigrateLocalActions_CompositeActionFiles(t *testing.T) {
dir := t.TempDir()
write := func(rel, content string) {
Expand Down
38 changes: 33 additions & 5 deletions cmd/gh-actions-lock/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -634,16 +634,17 @@ func migrateLocalActions(paths []string) (int, func(), error) {
}
}
files := append([]string(nil), paths...)
var repoRoot string

// Also sweep in-repo composite action files. The repo root is derived from
// a workflow path (they live under .github/workflows/); onboarding only
// runs when workflows exist, so an empty paths list means nothing to do.
// Also sweep in-repo composite action files. Derive the repo root from a
// workflow path; an empty paths list means nothing to do.
if len(paths) > 0 {
root, err := workflowfile.FindRepoRoot(paths[0])
if err != nil {
return 0, undo, err
}
if root != "" {
repoRoot = root
actionFiles, err := workflowfile.DiscoverCompositeActionFiles(root)
if err != nil {
return 0, undo, err
Expand Down Expand Up @@ -691,8 +692,35 @@ func migrateLocalActions(paths []string) (int, func(), error) {
migrations = append(migrations, plannedMigration{path: path, original: original, content: content, changed: changed})
}

// Do not write anything until every candidate has passed structural and
// repository-boundary validation.
// Composite definitions are shared even when the command names only one
// workflow. Check all callers before writing changes to those definitions.
var migrationPaths []string
compositeChanged := false
for _, plan := range migrations {
migrationPaths = append(migrationPaths, plan.path)
name := filepath.Base(plan.path)
compositeChanged = compositeChanged || name == "action.yml" || name == "action.yaml"
}
if compositeChanged && repoRoot != "" {
workflowDirs := []string{filepath.Join(repoRoot, ".github", "workflows")}
if dir := os.Getenv("GH_ACTIONS_LOCK_WORKFLOWS_DIR"); dir != "" {
workflowDirs = append(workflowDirs, dir)
}
var workflowPaths []string
for _, dir := range workflowDirs {
callers, err := workflowfile.DiscoverWorkflowsIn(dir)
if err != nil {
return 0, undo, err
}
workflowPaths = append(workflowPaths, callers...)
}
if err := workflowfile.ValidateLocalActionMigrations(workflowPaths, migrationPaths); err != nil {
return 0, undo, err
}
}

// Do not write anything until every candidate and its callers have passed
// validation.
total := 0
for _, plan := range migrations {
written = append(written, plan)
Expand Down
Loading