workflowfile: guard local action migration after checkout overrides by kuma0128 · Pull Request #163 · github/gh-actions-lock · GitHub
Skip to content
Closed
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
35 changes: 32 additions & 3 deletions README.md
123 changes: 122 additions & 1 deletion cmd/gh-actions-lock/migrate_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"fmt"
"os"
"path/filepath"
"strings"
Expand All @@ -11,11 +12,131 @@ import (
"github.com/stretchr/testify/require"
)

func TestMigrateLocalActions_CustomCheckoutWritesNothing(t *testing.T) {
for _, input := range []struct {
name string
value string
}{
{name: "ref", value: "${{ env.TARGET_BRANCH }}"},
{name: "repository", value: "other/project"},
} {
t.Run(input.name, func(t *testing.T) {
for _, mode := range []struct {
name string
args []string
}{
{name: "full run leaves files unchanged"},
{name: "JSON run leaves files unchanged", args: []string{"--json"}},
{name: "scoped run checks unselected callers", args: []string{".github/workflows/first.yml"}},
} {
t.Run(mode.name, 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": fmt.Sprintf(`jobs:
refresh:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
%s: %s
- uses: $/.github/actions/local
`, input.name, input.value),
".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, mode.args...)

require.Error(t, err)
assert.Contains(t, err.Error(), "second.yml:7")
assert.Contains(t, err.Error(), fmt.Sprintf("%s %q", input.name, input.value))
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
Loading