diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 389480c9f2..d8de8b5ecd 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -42,6 +42,7 @@ on: - 'action.yml' - '.github/actions/check-e2e-authorization/**' - 'scripts/check-e2e-authorization.sh' + - 'scripts/redact-behaviour-artifacts.sh' pull_request_target: # labeled / ok-to-test is handled by e2e-ok-to-test.yml via workflow_call. types: [opened, synchronize, reopened] @@ -236,7 +237,7 @@ jobs: } fi # pkg/e2etest: shared pool/CLI/cleanup; pkg/behaviourtest: framework; e2e/admin: admin-only helpers - if echo "$FILES" | grep -qE '^e2e/behaviour/|^e2e/admin/|^pkg/e2etest/|^pkg/behaviourtest/|^internal/runtime/|^internal/sandbox/|^internal/config/|^internal/cli/|^internal/layers/|^internal/scaffold/fullsend-repo/|^internal/forge/|^internal/harness/|^internal/harnessdispatch/|^internal/normevent/|^internal/dispatch/|^internal/security/hooks/|^internal/mintclient/|^cmd/fullsend/|^go\.(mod|sum)$|^Makefile$|^\.github/scripts/redact-behaviour-artifacts\.sh$|^\.github/workflows/e2e\.yml$|^\.github/workflows/e2e-ok-to-test\.yml$|^\.github/workflows/reusable-dispatch\.yml$|^\.github/actions/check-e2e-authorization/|^scripts/check-e2e-authorization\.sh$'; then + if echo "$FILES" | grep -qE '^e2e/behaviour/|^e2e/admin/|^pkg/e2etest/|^pkg/behaviourtest/|^internal/runtime/|^internal/sandbox/|^internal/config/|^internal/cli/|^internal/layers/|^internal/scaffold/fullsend-repo/|^internal/forge/|^internal/harness/|^internal/harnessdispatch/|^internal/normevent/|^internal/dispatch/|^internal/security/hooks/|^internal/mintclient/|^cmd/fullsend/|^go\.(mod|sum)$|^Makefile$|^scripts/redact-behaviour-artifacts\.sh$|^\.github/workflows/e2e\.yml$|^\.github/workflows/e2e-ok-to-test\.yml$|^\.github/workflows/reusable-dispatch\.yml$|^\.github/actions/check-e2e-authorization/|^scripts/check-e2e-authorization\.sh$'; then echo "relevant=true" >> "$GITHUB_OUTPUT" else echo "::notice::No behaviour-relevant files changed — skipping behaviour tests" @@ -298,7 +299,7 @@ jobs: with: # Base-branch script only — PR head must not control artifact redaction (#5221). ref: ${{ github.sha }} - sparse-checkout: .github/scripts/redact-behaviour-artifacts.sh + sparse-checkout: scripts/redact-behaviour-artifacts.sh path: base-scripts persist-credentials: false @@ -341,7 +342,7 @@ jobs: E2E_GCP_PROJECT_ID="${E2E_GCP_PROJECT_ID}" \ E2E_GCP_WIF_PROVIDER="${E2E_GCP_WIF_PROVIDER}" \ E2E_GCP_SERVICE_ACCOUNT="${E2E_GCP_SERVICE_ACCOUNT}" \ - /usr/bin/bash "${{ github.workspace }}/base-scripts/.github/scripts/redact-behaviour-artifacts.sh" + /usr/bin/bash "${{ github.workspace }}/base-scripts/scripts/redact-behaviour-artifacts.sh" - name: Upload behaviour debug artifacts if: failure() && steps.changes.outputs.relevant != 'false' && steps.redact.outcome == 'success' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 14cf26e32f..bf869b46ef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,9 +54,25 @@ jobs: validate-agents: needs: release + # Permission contract of the called workflow. GitHub validates this at + # parse time, before any job runs or `if:` is evaluated — a called + # workflow may only downgrade the caller's grants, so every permission + # any of its jobs declares must be granted here even when that job is + # skipped for this event (missing grants fail the whole run as + # startup_failure; see #6512, run 32615313246): + # contents: read — checkouts + # id-token: write — GCP WIF auth in functional-tests + # pull-requests: write — gate job (pull_request_target only; skipped + # on tag pushes, but still validated) + # checks: read — functional-tests-complete roll-up on agents + # main (not used by the pinned gate yet; + # granted now so a pin bump cannot + # reintroduce the startup failure) permissions: contents: read id-token: write + pull-requests: write + checks: read uses: fullsend-ai/agents/.github/workflows/functional-tests.yml@a8566cd5305fe094b96588690118022967ad0061 # main with: fullsend_ref: ${{ github.ref_name }} @@ -68,12 +84,54 @@ jobs: # tests to read eval repos and record eval run results. EVAL_GH_TOKEN: ${{ secrets.EVAL_GH_TOKEN }} + resolve-agents: + # Resolve the agents tree to tag exactly once, when the release starts. + # tag-agents previously re-resolved agents main at tag time, so anything + # merged into agents while the gate ran was tagged unvalidated (#6512). + # Until the agents gate exposes the SHA it checked out as a + # workflow_call output, validate-agents still exercises the pinned + # gate's agents tree; this at least makes the tagged tree deterministic + # from the moment the release begins. + needs: release + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + outputs: + agents_sha: ${{ steps.resolve.outputs.sha }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Resolve agents main + id: resolve + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + SHA=$(gh api repos/fullsend-ai/agents/git/ref/heads/main --jq '.object.sha') + if [[ ! "${SHA}" =~ ^[a-f0-9]{40}$ ]]; then + echo "::error::Could not resolve fullsend-ai/agents main to a commit SHA" + exit 1 + fi + echo "Resolved fullsend-ai/agents main to ${SHA}" + echo "sha=${SHA}" >> "$GITHUB_OUTPUT" + + - name: Gate pin drift check (informational) + # Loud, not blocking: a stale pin means validate-agents exercises an + # older agents tree than the one being tagged. Bumping the pin is a + # maintainer decision (#6512), so this only annotates the run. + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash scripts/check-agents-gate-pin.sh + tag-agents: # Sync the version tag to fullsend-ai/agents. Runs for all tags # including pre-releases — agents' own release.yml handles # pre-release semantics. Only runs after agents functional tests - # pass against the release tag. - needs: [release, validate-agents] + # pass against the release tag. Tags the SHA resolve-agents captured + # at release start — never re-resolves main here (#6512). + needs: [release, validate-agents, resolve-agents] runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: @@ -90,8 +148,13 @@ jobs: - name: Push tag to fullsend-ai/agents env: GH_TOKEN: ${{ steps.agents-token.outputs.token }} + AGENTS_SHA: ${{ needs.resolve-agents.outputs.agents_sha }} run: | set -euo pipefail + if [[ ! "${AGENTS_SHA}" =~ ^[a-f0-9]{40}$ ]]; then + echo "::error::resolve-agents output is not a commit SHA: ${AGENTS_SHA//::/}" + exit 1 + fi TAG="${GITHUB_REF_NAME}" HTTP_CODE=$(gh api "repos/fullsend-ai/agents/git/ref/tags/${TAG}" \ @@ -104,18 +167,19 @@ jobs: exit 1 fi - AGENTS_SHA=$(gh api repos/fullsend-ai/agents/git/ref/heads/main --jq '.object.sha') gh api repos/fullsend-ai/agents/git/refs \ -f ref="refs/tags/${TAG}" \ -f sha="${AGENTS_SHA}" echo "Created tag ${TAG} on fullsend-ai/agents at ${AGENTS_SHA}" notify-agents-sync-failure: - needs: [release, validate-agents, tag-agents] + needs: [release, validate-agents, resolve-agents, tag-agents] if: >- always() && needs.release.result == 'success' - && (needs.validate-agents.result == 'failure' || needs.tag-agents.result == 'failure') + && (needs.validate-agents.result == 'failure' + || needs.resolve-agents.result == 'failure' + || needs.tag-agents.result == 'failure') runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: diff --git a/Makefile b/Makefile index 18cc242fd8..7d1d673285 100644 --- a/Makefile +++ b/Makefile @@ -189,8 +189,9 @@ endef script-test: $(call run-timed,bash scripts/check-e2e-authorization-test.sh) - $(call run-timed,bash .github/scripts/redact-behaviour-artifacts-test.sh) + $(call run-timed,bash scripts/redact-behaviour-artifacts-test.sh) $(call run-timed,bash .github/scripts/check-fix-eligibility-test.sh) + $(call run-timed,bash scripts/check-agents-gate-pin-test.sh) $(call run-timed,bash internal/scaffold/fullsend-repo/scripts/reconcile-repos-test.sh) $(call run-timed,bash internal/scaffold/fullsend-repo/scripts/pre-fetch-prior-review-test.sh) $(call run-timed,bash internal/scaffold/fullsend-repo/.github/scripts/setup-agent-env-test.sh) diff --git a/docs/contributing/ci-workflows.md b/docs/contributing/ci-workflows.md index 1d06256b7c..82e2858f96 100644 --- a/docs/contributing/ci-workflows.md +++ b/docs/contributing/ci-workflows.md @@ -180,7 +180,7 @@ When a PR adds or modifies secret references in a `pull_request_target` job, rev The behaviour job in `e2e.yml` uploads debug artifacts on failure. Because PR-head code populates that directory under `pull_request_target`, a malicious authorized PR could write job secrets into artifact files (GitHub masks logs but not uploaded artifact contents). -Before upload, the workflow checks out `.github/scripts/redact-behaviour-artifacts.sh` from the **base branch** (`github.sha` on `pull_request_target`; the merge-group head on `merge_group`) into a separate `base-scripts/` path. PR-head code cannot modify the checked-in script contents. The redaction step runs via `env -i` with a pinned `PATH` so earlier job steps cannot poison the interpreter search path or dynamic-linker hooks. +Before upload, the workflow checks out `scripts/redact-behaviour-artifacts.sh` from the **base branch** (`github.sha` on `pull_request_target`; the merge-group head on `merge_group`) into a separate `base-scripts/` path. PR-head code cannot modify the checked-in script contents. The redaction step runs via `env -i` with a pinned `PATH` so earlier job steps cannot poison the interpreter search path or dynamic-linker hooks. The behaviour test step tees job output to `behaviour-test.log` in that directory (with `shell: bash` so `pipefail` propagates `make behaviour-test` failures). Upload is gated on `steps.redact.outcome == 'success'`. diff --git a/internal/cli/admin.go b/internal/cli/admin.go index a0b7036e6a..8047e867fe 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -1140,7 +1140,7 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error { if buildErr != nil { return fmt.Errorf("building scaffold files for vendor: %w", buildErr) } - vendorFiles, _, vendorErr := appendVendorTreeFiles(printer, owner, repo, scaffoldFiles, vendor, fullsendBinary, fullsendSource) + vendorFiles, _, vendorErr := appendVendorTreeFiles(ctx, client, printer, owner, repo, scaffoldFiles, vendor, fullsendBinary, fullsendSource) if vendorErr != nil { return fmt.Errorf("collecting vendored assets: %w", vendorErr) } diff --git a/internal/cli/github.go b/internal/cli/github.go index efa893ae20..59a451c855 100644 --- a/internal/cli/github.go +++ b/internal/cli/github.go @@ -487,7 +487,7 @@ func runGitHubSetupPerRepo(ctx context.Context, client forge.Client, printer *ui if cfg.vendor { var vendorErr error - files, _, vendorErr = appendVendorTreeFiles(printer, owner, repo, files, cfg.vendor, cfg.fullsendBinary, cfg.fullsendSource) + files, _, vendorErr = appendVendorTreeFiles(ctx, client, printer, owner, repo, files, cfg.vendor, cfg.fullsendBinary, cfg.fullsendSource) if vendorErr != nil { return fmt.Errorf("collecting vendored assets: %w", vendorErr) } diff --git a/internal/cli/vendor.go b/internal/cli/vendor.go index 960c064ff4..4848f60e02 100644 --- a/internal/cli/vendor.go +++ b/internal/cli/vendor.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "strings" "github.com/spf13/cobra" @@ -62,8 +63,8 @@ func makeVendorFunc(fullsendBinary, fullsendSource string) layers.VendorFunc { // makeVendorCollectFunc returns a VendorCollectFunc for combined scaffold commits. func makeVendorCollectFunc(fullsendBinary, fullsendSource string) layers.VendorCollectFunc { - return func(ctx context.Context, printer *ui.Printer, owner, repo string) ([]forge.TreeFile, int, error) { - bundle, cleanup, err := prepareVendorFiles(printer, owner, repo, fullsendBinary, fullsendSource) + return func(ctx context.Context, client forge.Client, printer *ui.Printer, owner, repo string) ([]forge.TreeFile, int, error) { + bundle, cleanup, err := prepareVendorFiles(ctx, client, printer, owner, repo, fullsendBinary, fullsendSource) if err != nil { return nil, 0, err } @@ -79,11 +80,11 @@ func vendorStackArgs(vendor bool, fullsendBinary, fullsendSource string) (layers return makeVendorFunc(fullsendBinary, fullsendSource), makeVendorCollectFunc(fullsendBinary, fullsendSource) } -func appendVendorTreeFiles(printer *ui.Printer, owner, repo string, files []forge.TreeFile, vendor bool, fullsendBinary, fullsendSource string) ([]forge.TreeFile, int, error) { +func appendVendorTreeFiles(ctx context.Context, client forge.Client, printer *ui.Printer, owner, repo string, files []forge.TreeFile, vendor bool, fullsendBinary, fullsendSource string) ([]forge.TreeFile, int, error) { if !vendor { return files, 0, nil } - bundle, cleanup, err := prepareVendorFiles(printer, owner, repo, fullsendBinary, fullsendSource) + bundle, cleanup, err := prepareVendorFiles(ctx, client, printer, owner, repo, fullsendBinary, fullsendSource) if err != nil { return nil, 0, err } @@ -91,7 +92,7 @@ func appendVendorTreeFiles(printer *ui.Printer, owner, repo string, files []forg return append(files, bundle.files...), bundle.assetCount, nil } -func prepareVendorFiles(printer *ui.Printer, owner, repo, fullsendBinary, fullsendSource string) (vendorFileBundle, func(), error) { +func prepareVendorFiles(ctx context.Context, client forge.Client, printer *ui.Printer, owner, repo, fullsendBinary, fullsendSource string) (vendorFileBundle, func(), error) { perRepo := repo != forge.ConfigRepoName pathPrefix := "" if perRepo { @@ -194,11 +195,22 @@ func prepareVendorFiles(printer *ui.Printer, owner, repo, fullsendBinary, fullse Mode: "100644", }) + // Prune here, at the single point every --vendor commit path collects its + // tree: acquireAndVendor, the combined scaffold+vendor collect func, and + // appendVendorTreeFiles all receive the delete entries, so files that + // left the vendored set are removed from consumer repos instead of + // becoming orphans the replaced manifest no longer tracks. + files, err = appendStaleVendoredDeletes(ctx, client, printer, owner, repo, files) + if err != nil { + cleanup() + return vendorFileBundle{}, func() {}, err + } + return vendorFileBundle{files: files, assetCount: len(assets)}, cleanup, nil } func acquireAndVendor(ctx context.Context, client forge.Client, printer *ui.Printer, owner, repo, fullsendBinary, fullsendSource string) error { - bundle, cleanup, err := prepareVendorFiles(printer, owner, repo, fullsendBinary, fullsendSource) + bundle, cleanup, err := prepareVendorFiles(ctx, client, printer, owner, repo, fullsendBinary, fullsendSource) if err != nil { return err } @@ -220,6 +232,38 @@ func acquireAndVendor(ctx context.Context, client forge.Client, printer *ui.Prin return nil } +// appendStaleVendoredDeletes prunes files a previous vendor install +// committed that are no longer part of the vendored set — otherwise the +// new manifest stops tracking them and they persist in the consumer repo +// as untracked orphans that even uninstall cannot remove. +func appendStaleVendoredDeletes(ctx context.Context, client forge.Client, printer *ui.Printer, owner, repo string, files []forge.TreeFile) ([]forge.TreeFile, error) { + oldManifest, found, err := scaffold.ReadVendorManifest(ctx, client, owner, repo, vendorPathPrefix(owner, repo)) + if err != nil { + // A missing manifest (first install) is fine; a present-but-invalid + // one is not — proceeding would silently orphan every de-listed path. + return nil, fmt.Errorf("reading vendor manifest for pruning: %w", err) + } + if !found { + return files, nil + } + newPaths := make([]string, 0, len(files)) + for _, f := range files { + if f.Delete { + continue + } + newPaths = append(newPaths, f.Path) + } + stale := scaffold.StaleVendoredPaths(oldManifest, newPaths) + if len(stale) == 0 { + return files, nil + } + for _, p := range stale { + files = append(files, forge.TreeFile{Path: p, Delete: true}) + } + printer.StepInfo(fmt.Sprintf("Pruning %d vendored file(s) no longer shipped: %s", len(stale), strings.Join(stale, ", "))) + return files, nil +} + func vendorPathPrefix(owner, repo string) string { if repo != forge.ConfigRepoName { return ".fullsend/" diff --git a/internal/cli/vendor_prune_test.go b/internal/cli/vendor_prune_test.go new file mode 100644 index 0000000000..b833052791 --- /dev/null +++ b/internal/cli/vendor_prune_test.go @@ -0,0 +1,111 @@ +package cli + +import ( + "bytes" + "context" + "os" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/fullsend-ai/fullsend/internal/forge" + "github.com/fullsend-ai/fullsend/internal/ui" +) + +// appendStaleVendoredDeletes must turn manifest-recorded paths that left the +// vendored set into Delete tree entries, and pass through cleanly when no +// manifest exists (first vendor install) or the manifest is unreadable. +func TestAppendStaleVendoredDeletes(t *testing.T) { + ctx := context.Background() + printer := ui.New(&bytes.Buffer{}) + + newFiles := []forge.TreeFile{ + {Path: ".defaults/action.yml", Content: []byte("a"), Mode: "100644"}, + {Path: ".defaults/.github/scripts/check-fix-eligibility.sh", Content: []byte("b"), Mode: "100755"}, + } + + t.Run("prunes de-listed manifest paths", func(t *testing.T) { + client := forge.NewFakeClient() + client.FileContents["o/r/.fullsend/vendor-manifest.yaml"] = []byte( + "version: \"1\"\n" + + "binary_path: .defaults/bin/fullsend\n" + + "cli_version: v0.36.0\n" + + "paths:\n" + + " - .defaults/action.yml\n" + + " - .defaults/.github/scripts/check-fix-eligibility.sh\n" + + " - .defaults/.github/scripts/redact-behaviour-artifacts.sh\n" + + " - .defaults/.github/scripts/redact-behaviour-artifacts-test.sh\n") + + out, err := appendStaleVendoredDeletes(ctx, client, printer, "o", "r", newFiles) + assert.NoError(t, err) + + var deletes []string + for _, f := range out { + if f.Delete { + deletes = append(deletes, f.Path) + } + } + assert.Equal(t, []string{ + ".defaults/.github/scripts/redact-behaviour-artifacts-test.sh", + ".defaults/.github/scripts/redact-behaviour-artifacts.sh", + }, deletes) + assert.Len(t, out, len(newFiles)+2) + }) + + t.Run("no manifest is a clean pass-through", func(t *testing.T) { + client := forge.NewFakeClient() + out, err := appendStaleVendoredDeletes(ctx, client, printer, "o", "r", newFiles) + assert.NoError(t, err) + assert.Equal(t, newFiles, out) + }) + + t.Run("present-but-invalid manifest fails the vendor instead of orphaning", func(t *testing.T) { + client := forge.NewFakeClient() + client.FileContents["o/r/.fullsend/vendor-manifest.yaml"] = []byte("{not yaml") + _, err := appendStaleVendoredDeletes(ctx, client, printer, "o", "r", newFiles) + assert.Error(t, err) + }) +} + +// Pruning must fire on every vendor commit path, not just acquireAndVendor — +// prepareVendorFiles is the chokepoint, exercised here through +// appendVendorTreeFiles and the combined-commit collect func. +func TestVendorCommitPathsPruneStaleFiles(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("needs Linux ELF binary") + } + exe, err := os.Executable() + require.NoError(t, err) + ctx := context.Background() + + seed := func() *forge.FakeClient { + client := forge.NewFakeClient() + client.FileContents["org/my-repo/.fullsend/vendor-manifest.yaml"] = []byte( + "version: \"1\"\n" + + "binary_path: .fullsend/.defaults/bin/fullsend\n" + + "paths:\n" + + " - .defaults/.github/scripts/redact-behaviour-artifacts.sh\n") + return client + } + countDeletes := func(files []forge.TreeFile) int { + n := 0 + for _, f := range files { + if f.Delete { + n++ + } + } + return n + } + + out, _, err := appendVendorTreeFiles(ctx, seed(), ui.New(&strings.Builder{}), "org", "my-repo", nil, true, exe, "") + require.NoError(t, err) + assert.Equal(t, 1, countDeletes(out), "appendVendorTreeFiles must prune") + + fn := makeVendorCollectFunc(exe, "") + out, _, err = fn(ctx, seed(), ui.New(&strings.Builder{}), "org", "my-repo") + require.NoError(t, err) + assert.Equal(t, 1, countDeletes(out), "combined collect func must prune") +} diff --git a/internal/cli/vendor_test.go b/internal/cli/vendor_test.go index fd52120f93..9c210e2b97 100644 --- a/internal/cli/vendor_test.go +++ b/internal/cli/vendor_test.go @@ -62,7 +62,7 @@ func TestVendorDryRunMessage(t *testing.T) { func TestAppendVendorTreeFiles_Disabled(t *testing.T) { files := []forge.TreeFile{{Path: "shim.yaml", Content: []byte("x")}} - out, count, err := appendVendorTreeFiles(ui.New(nil), "org", "my-repo", files, false, "", "") + out, count, err := appendVendorTreeFiles(context.Background(), forge.NewFakeClient(), ui.New(nil), "org", "my-repo", files, false, "", "") require.NoError(t, err) assert.Equal(t, files, out) assert.Equal(t, 0, count) @@ -77,7 +77,7 @@ func TestAppendVendorTreeFiles_Enabled(t *testing.T) { files := []forge.TreeFile{{Path: "shim.yaml", Content: []byte("x")}} var buf strings.Builder - out, count, err := appendVendorTreeFiles(ui.New(&buf), "org", "my-repo", files, true, exe, "") + out, count, err := appendVendorTreeFiles(context.Background(), forge.NewFakeClient(), ui.New(&buf), "org", "my-repo", files, true, exe, "") require.NoError(t, err) assert.Greater(t, len(out), len(files)) assert.Greater(t, count, 0) @@ -93,7 +93,7 @@ func TestMakeVendorCollectFunc(t *testing.T) { var buf strings.Builder fn := makeVendorCollectFunc(exe, "") require.NotNil(t, fn) - files, count, err := fn(context.Background(), ui.New(&buf), "org", "my-repo") + files, count, err := fn(context.Background(), forge.NewFakeClient(), ui.New(&buf), "org", "my-repo") require.NoError(t, err) assert.NotEmpty(t, files) assert.Greater(t, count, 0) @@ -101,7 +101,7 @@ func TestMakeVendorCollectFunc(t *testing.T) { func TestMakeVendorCollectFunc_InvalidBinary(t *testing.T) { fn := makeVendorCollectFunc("/nonexistent/fullsend", "") - _, _, err := fn(context.Background(), ui.New(&strings.Builder{}), "org", "my-repo") + _, _, err := fn(context.Background(), forge.NewFakeClient(), ui.New(&strings.Builder{}), "org", "my-repo") require.Error(t, err) } @@ -195,7 +195,7 @@ func TestPrepareVendorFiles_ExplicitBinary(t *testing.T) { exe, err := os.Executable() require.NoError(t, err) - bundle, cleanup, err := prepareVendorFiles(ui.New(&strings.Builder{}), "org", "my-repo", exe, "") + bundle, cleanup, err := prepareVendorFiles(context.Background(), forge.NewFakeClient(), ui.New(&strings.Builder{}), "org", "my-repo", exe, "") require.NoError(t, err) t.Cleanup(cleanup) assert.Greater(t, bundle.assetCount, 0) @@ -203,7 +203,7 @@ func TestPrepareVendorFiles_ExplicitBinary(t *testing.T) { } func TestPrepareVendorFiles_InvalidExplicitBinary(t *testing.T) { - _, cleanup, err := prepareVendorFiles(ui.New(&strings.Builder{}), "org", "my-repo", "/nonexistent/fullsend", "") + _, cleanup, err := prepareVendorFiles(context.Background(), forge.NewFakeClient(), ui.New(&strings.Builder{}), "org", "my-repo", "/nonexistent/fullsend", "") require.Error(t, err) cleanup() assert.Contains(t, err.Error(), "validating --fullsend-binary") diff --git a/internal/layers/vendorbinary.go b/internal/layers/vendorbinary.go index 4d3ad7db7b..16278c9d2c 100644 --- a/internal/layers/vendorbinary.go +++ b/internal/layers/vendorbinary.go @@ -15,7 +15,7 @@ type VendorFunc func(ctx context.Context, client forge.Client, printer *ui.Print // VendorCollectFunc gathers vendored tree files without committing. // Used to combine scaffold and vendor assets in a single CommitFiles call. -type VendorCollectFunc func(ctx context.Context, printer *ui.Printer, owner, repo string) ([]forge.TreeFile, int, error) +type VendorCollectFunc func(ctx context.Context, client forge.Client, printer *ui.Printer, owner, repo string) ([]forge.TreeFile, int, error) // VendorBinaryLayer manages vendored binary and content assets. // The type name retains "Binary" from when the layer only uploaded the CLI diff --git a/internal/layers/workflows.go b/internal/layers/workflows.go index ccaef53cab..fc59337711 100644 --- a/internal/layers/workflows.go +++ b/internal/layers/workflows.go @@ -121,7 +121,7 @@ func (l *WorkflowsLayer) Install(ctx context.Context) error { // Vendored marker paths must stay aligned with reusable workflow hashFiles // checks (see .github workflows and scaffold.VendoredMarkerPath). if l.vendored && l.vendorCollect != nil { - vendorFiles, count, err := l.vendorCollect(ctx, l.ui, l.org, forge.ConfigRepoName) + vendorFiles, count, err := l.vendorCollect(ctx, l.client, l.ui, l.org, forge.ConfigRepoName) if err != nil { return fmt.Errorf("collecting vendored assets: %w", err) } diff --git a/internal/layers/workflows_test.go b/internal/layers/workflows_test.go index 1aacef08aa..aaa045fd87 100644 --- a/internal/layers/workflows_test.go +++ b/internal/layers/workflows_test.go @@ -176,7 +176,7 @@ func TestWorkflowsLayer_Install_TriageWorkflowContent(t *testing.T) { func TestWorkflowsLayer_Install_CombinedVendorCommit(t *testing.T) { client := forge.NewFakeClient() ensureFakeConfigRepo(client) - collectFn := func(_ context.Context, _ *ui.Printer, owner, repo string) ([]forge.TreeFile, int, error) { + collectFn := func(_ context.Context, _ forge.Client, _ *ui.Printer, owner, repo string) ([]forge.TreeFile, int, error) { assert.Equal(t, "test-org", owner) assert.Equal(t, forge.ConfigRepoName, repo) return []forge.TreeFile{ diff --git a/internal/scaffold/scaffold.go b/internal/scaffold/scaffold.go index c468051462..0bb6e7ddfa 100644 --- a/internal/scaffold/scaffold.go +++ b/internal/scaffold/scaffold.go @@ -125,6 +125,9 @@ func WalkLayeredContent(fn func(path string, content []byte) error) error { if !IsLayeredPath(path) && path != ".github/scripts/setup-agent-env.sh" { return nil } + if isLayeredRepoTestFile(path) { + return nil + } return fn(path, data) }) } diff --git a/internal/scaffold/vendorcontent.go b/internal/scaffold/vendorcontent.go index 7ce4911bf5..068f8dc0f5 100644 --- a/internal/scaffold/vendorcontent.go +++ b/internal/scaffold/vendorcontent.go @@ -136,6 +136,9 @@ func walkLayeredFromRoot(layeredRoot string, fn func(path string, content []byte if !IsLayeredPath(rel) && rel != ".github/scripts/setup-agent-env.sh" { return nil } + if isLayeredRepoTestFile(rel) { + return nil + } data, readErr := os.ReadFile(path) if readErr != nil { return fmt.Errorf("reading %s: %w", rel, readErr) @@ -152,17 +155,60 @@ func isVendoredReusableWorkflow(path string) bool { return strings.HasPrefix(base, "reusable-") && strings.HasSuffix(base, ".yml") } +// vendoredDefaultsScripts is the explicit allowlist of .github/scripts/ +// files that ship to consumer repos. Everything here is executed in user +// repos: check-fix-eligibility.sh directly by the vendored reusable +// workflows, the other three by the root composite action (action.yml, +// invoked as ./.defaults/ with GITHUB_ACTION_PATH resolving into the +// vendored tree). +// +// .github/scripts/ also hosts repo-local CI tooling and *-test.sh files +// that must NOT ship to consumers. The directory prefix alone is +// deliberately not sufficient for vendoring — add new user-facing scripts +// here explicitly. Scripts whose path is a cross-repo contract +// (openshell-version.sh and install-openshell.sh are read from a fullsend +// checkout by the agents functional-tests gate, hack/gitlab-runner-vm, +// and scripts/renovate/update-openshell-sha.sh) must stay at their +// current path regardless of whether they are listed. +var vendoredDefaultsScripts = map[string]bool{ + ".github/scripts/check-fix-eligibility.sh": true, + ".github/scripts/install-openshell.sh": true, + ".github/scripts/install-podman.sh": true, + ".github/scripts/openshell-version.sh": true, +} + +// vendoredDefaultsActions is the explicit allowlist of .github/actions/ +// directories that ship to consumer repos — each is executed from +// ./.defaults/ by the vendored reusable workflows. Like the scripts list, +// the directory prefix alone is deliberately not sufficient: +// check-e2e-authorization lives beside these but is repo-CI only (e2e and +// functional-tests) and runs scripts/check-e2e-authorization.sh, which +// does not ship — vendoring it gave consumers a broken, unused action. +var vendoredDefaultsActions = map[string]bool{ + ".github/actions/install-fullsend-cli/": true, + ".github/actions/mint-token/": true, + ".github/actions/prepare-workspace/": true, + ".github/actions/setup-gcp/": true, + ".github/actions/validate-enrollment/": true, +} + func isVendoredDefaultsInfra(path string) bool { if path == "action.yml" { return true } - if strings.HasPrefix(path, ".github/actions/") { - return true - } - if strings.HasPrefix(path, ".github/scripts/") { - return true + for prefix := range vendoredDefaultsActions { + if strings.HasPrefix(path, prefix) { + return true + } } - return false + return vendoredDefaultsScripts[path] +} + +// isLayeredRepoTestFile reports whether a layered-content path is a +// *-test.sh / *-test.py self-test. Those run in fullsend CI +// (make script-test) and must not ship to consumer repos with the layer. +func isLayeredRepoTestFile(path string) bool { + return strings.HasSuffix(path, "-test.sh") || strings.HasSuffix(path, "-test.py") } func vendoredInfraFileMode(path string) string { diff --git a/internal/scaffold/vendorcontent_test.go b/internal/scaffold/vendorcontent_test.go index 08762f942b..6380d44060 100644 --- a/internal/scaffold/vendorcontent_test.go +++ b/internal/scaffold/vendorcontent_test.go @@ -69,8 +69,18 @@ func TestIsVendoredReusableWorkflow(t *testing.T) { func TestIsVendoredDefaultsInfra(t *testing.T) { assert.True(t, isVendoredDefaultsInfra("action.yml")) - assert.True(t, isVendoredDefaultsInfra(".github/actions/foo/action.yml")) - assert.True(t, isVendoredDefaultsInfra(".github/scripts/run.sh")) + // Actions ship only via the explicit allowlist — an action that no + // vendored reusable workflow executes must NOT ship. + assert.True(t, isVendoredDefaultsInfra(".github/actions/mint-token/action.yml")) + assert.True(t, isVendoredDefaultsInfra(".github/actions/setup-gcp/action.yml")) + assert.False(t, isVendoredDefaultsInfra(".github/actions/check-e2e-authorization/action.yml")) + assert.False(t, isVendoredDefaultsInfra(".github/actions/foo/action.yml")) + // Scripts ship only via the explicit allowlist — an arbitrary file + // under .github/scripts/ must NOT be vendored to consumer repos. + assert.True(t, isVendoredDefaultsInfra(".github/scripts/check-fix-eligibility.sh")) + assert.True(t, isVendoredDefaultsInfra(".github/scripts/install-podman.sh")) + assert.False(t, isVendoredDefaultsInfra(".github/scripts/run.sh")) + assert.False(t, isVendoredDefaultsInfra(".github/scripts/check-fix-eligibility-test.sh")) assert.False(t, isVendoredDefaultsInfra(".github/workflows/reusable-triage.yml")) } @@ -89,3 +99,18 @@ func TestWalkVendoredUpstreamFromRoot_SkipsSymlink(t *testing.T) { require.NoError(t, err) assert.Empty(t, seen, "symlinks should be skipped") } + +// The layered scripts layer ships to consumer repos; its *-test.sh / +// *-test.py self-tests run in fullsend CI only and must stay out. +func TestWalkLayeredContent_ExcludesTestFiles(t *testing.T) { + var paths []string + require.NoError(t, WalkLayeredContent(func(path string, _ []byte) error { + paths = append(paths, path) + return nil + })) + assert.Contains(t, paths, "scripts/pre-fetch-prior-review.sh") + assert.Contains(t, paths, "scripts/reconcile-repos.sh") + for _, p := range paths { + assert.False(t, isLayeredRepoTestFile(p), "test file shipped in layered content: %s", p) + } +} diff --git a/internal/scaffold/vendormanifest.go b/internal/scaffold/vendormanifest.go index d9d8a2ebb9..c0acaa7c06 100644 --- a/internal/scaffold/vendormanifest.go +++ b/internal/scaffold/vendormanifest.go @@ -146,19 +146,15 @@ var vendoredReusableWorkflows = []string{ var vendoredDefaultsInfraPaths = []string{ "action.yml", - ".github/actions/check-e2e-authorization/action.yml", ".github/actions/install-fullsend-cli/action.yml", ".github/actions/mint-token/action.yml", ".github/actions/prepare-workspace/action.yml", ".github/actions/setup-gcp/action.yml", ".github/actions/validate-enrollment/action.yml", - ".github/scripts/check-fix-eligibility-test.sh", ".github/scripts/check-fix-eligibility.sh", ".github/scripts/install-openshell.sh", ".github/scripts/install-podman.sh", ".github/scripts/openshell-version.sh", - ".github/scripts/redact-behaviour-artifacts-test.sh", - ".github/scripts/redact-behaviour-artifacts.sh", } // enumerateVendoredPaths returns embed-derived paths for a current --vendor install layout. @@ -231,6 +227,34 @@ func enumerateLegacyFlatVendoredPaths(workflowPrefix string) ([]string, error) { return out, nil } +// StaleVendoredPaths returns manifest-recorded content paths that are not +// part of the new vendored set, so a re-vendor can delete them in the same +// commit instead of leaving untracked orphans in consumer repos (e.g. a +// script removed from the vendoredDefaultsScripts allowlist). Only safe +// vendored repo paths are returned; the binary and the manifest itself are +// tracked separately and never appear in manifest.Paths. +func StaleVendoredPaths(manifest *VendorManifest, current []string) []string { + if manifest == nil { + return nil + } + keep := make(map[string]struct{}, len(current)) + for _, p := range current { + keep[p] = struct{}{} + } + var stale []string + for _, p := range manifest.Paths { + if _, ok := keep[p]; ok { + continue + } + if !isSafeVendoredRepoPath(p) { + continue + } + stale = append(stale, p) + } + sort.Strings(stale) + return stale +} + // ReadVendorManifest loads the manifest from a repo when present. func ReadVendorManifest(ctx context.Context, client forge.Client, owner, repo, workflowPrefix string) (*VendorManifest, bool, error) { path := VendorManifestPath(workflowPrefix) diff --git a/internal/scaffold/vendormanifest_test.go b/internal/scaffold/vendormanifest_test.go index 276a3df053..e2271a8440 100644 --- a/internal/scaffold/vendormanifest_test.go +++ b/internal/scaffold/vendormanifest_test.go @@ -257,3 +257,25 @@ func TestVendorManifestPath(t *testing.T) { assert.Equal(t, "vendor-manifest.yaml", VendorManifestPath("")) assert.Equal(t, ".fullsend/vendor-manifest.yaml", VendorManifestPath(".fullsend/")) } + +func TestStaleVendoredPaths(t *testing.T) { + m := &VendorManifest{Paths: []string{ + ".defaults/.github/scripts/check-fix-eligibility.sh", + ".defaults/.github/scripts/redact-behaviour-artifacts.sh", + ".defaults/.github/scripts/redact-behaviour-artifacts-test.sh", + "../escape-attempt.sh", + }} + current := []string{ + ".defaults/.github/scripts/check-fix-eligibility.sh", + ".defaults/action.yml", + } + stale := StaleVendoredPaths(m, current) + // De-listed files are pruned; unsafe paths are never returned. + assert.Equal(t, []string{ + ".defaults/.github/scripts/redact-behaviour-artifacts-test.sh", + ".defaults/.github/scripts/redact-behaviour-artifacts.sh", + }, stale) + + assert.Nil(t, StaleVendoredPaths(nil, current)) + assert.Empty(t, StaleVendoredPaths(&VendorManifest{}, current)) +} diff --git a/scripts/check-agents-gate-pin-test.sh b/scripts/check-agents-gate-pin-test.sh new file mode 100755 index 0000000000..a049456e88 --- /dev/null +++ b/scripts/check-agents-gate-pin-test.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash +# check-agents-gate-pin-test.sh — Tests for check-agents-gate-pin.sh +# +# Run from the repo root: +# bash scripts/check-agents-gate-pin-test.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="${SCRIPT_DIR}/check-agents-gate-pin.sh" +FAILURES=0 + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "${TMPDIR}"' EXIT + +# build_release_yml creates a fake release.yml with a pinned SHA. +# $1 — SHA to pin (or "missing" to omit the pin line) +build_release_yml() { + local sha="$1" + local dir="${TMPDIR}/workflow" + rm -rf "${dir}" + mkdir -p "${dir}" + + if [[ "${sha}" == "missing" ]]; then + cat > "${dir}/release.yml" <<'EOF' +name: Release +on: + push: + tags: ["v*"] +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 +EOF + else + cat > "${dir}/release.yml" < "${mock_bin}/gh" <&2 + exit 1 + fi + echo '{"sha": "${main_sha}"}' | jq -r "\${jq_expr}" + ;; + repos/fullsend-ai/agents/compare/*) + echo '{"ahead_by": ${ahead_by}}' | jq -r "\${jq_expr}" + ;; + *) + echo "mock gh: unexpected endpoint: \${endpoint}" >&2 + exit 1 + ;; + esac + exit 0 +fi +echo "mock gh: unexpected command: \$*" >&2 +exit 1 +MOCKEOF + + chmod +x "${mock_bin}/gh" + echo "${mock_bin}" +} + +# run_test runs the drift-check script and asserts exit code and output. +# $1 — test name +# $2 — expected exit code +# $3 — pinned SHA in release.yml (or "missing") +# $4 — agents main SHA from mock +# $5 — ahead_by value (optional) +# $6 — expected output substring (optional) +# $7 — mock fail mode (optional, "fail" to simulate API error) +run_test() { + local name="$1" expected_exit="$2" pinned_sha="$3" main_sha="$4" + local ahead_by="${5:-0}" expected_output="${6:-}" fail_mode="${7:-}" + + local release_yml mock_bin + release_yml=$(build_release_yml "${pinned_sha}") + mock_bin=$(build_mock "${main_sha}" "${ahead_by}" "${fail_mode}") + + local actual_exit=0 output + output=$( + PATH="${mock_bin}:${PATH}" \ + RELEASE_YML="${release_yml}" \ + GH_TOKEN="fake" \ + bash "${SCRIPT}" 2>&1 + ) || actual_exit=$? + + if [[ "${actual_exit}" -ne "${expected_exit}" ]]; then + echo "FAIL: ${name} — expected exit ${expected_exit}, got ${actual_exit}" + echo " output: ${output}" + FAILURES=$((FAILURES + 1)) + return + fi + + if [[ -n "${expected_output}" ]] && [[ "${output}" != *"${expected_output}"* ]]; then + echo "FAIL: ${name} — expected '${expected_output}' not found in output" + echo " output: ${output}" + FAILURES=$((FAILURES + 1)) + return + fi + + echo "PASS: ${name}" +} + +echo "=== check-agents-gate-pin tests ===" + +# Pin matches agents main — exits 0 +run_test "pin is current" 0 \ + "abc123def456abc123def456abc123def456abcd" \ + "abc123def456abc123def456abc123def456abcd" \ + 0 "pin is current" + +# Pin is behind agents main — exits 1 +run_test "pin is stale" 1 \ + "aaa111bbb222ccc333ddd444eee555fff666aaa1" \ + "fff666eee555ddd444ccc333bbb222aaa111fff6" \ + 42 "42 commit(s) behind" + +# Pin line missing from release.yml — exits 1 +run_test "pin line missing" 1 \ + "missing" \ + "does-not-matter" \ + 0 "Could not find fullsend-ai/agents workflow pin" + +# gh API failure — exits 1 +run_test "gh api failure" 1 \ + "abc123def456abc123def456abc123def456abcd" \ + "does-not-matter" \ + 0 "Failed to fetch" "fail" + +# Multiple pins in release.yml — exits 1 (ambiguous) +run_test_multi_pin() { + local dir="${TMPDIR}/workflow" + rm -rf "${dir}" + mkdir -p "${dir}" + + cat > "${dir}/release.yml" <<'EOF' +name: Release +jobs: + validate-agents-a: + uses: fullsend-ai/agents/.github/workflows/functional-tests.yml@aaa111bbb222ccc333ddd444eee555fff666aaa1 + validate-agents-b: + uses: fullsend-ai/agents/.github/workflows/functional-tests.yml@fff666eee555ddd444ccc333bbb222aaa111fff6 +EOF + + local mock_bin + mock_bin=$(build_mock "does-not-matter" 0) + + local actual_exit=0 output + output=$( + PATH="${mock_bin}:${PATH}" \ + RELEASE_YML="${dir}/release.yml" \ + GH_TOKEN="fake" \ + bash "${SCRIPT}" 2>&1 + ) || actual_exit=$? + + if [[ "${actual_exit}" -ne 1 ]]; then + echo "FAIL: multi-pin ambiguity — expected exit 1, got ${actual_exit}" + FAILURES=$((FAILURES + 1)) + return + fi + + if [[ "${output}" != *"Ambiguous"* ]]; then + echo "FAIL: multi-pin ambiguity — expected 'Ambiguous' in output" + echo " output: ${output}" + FAILURES=$((FAILURES + 1)) + return + fi + + echo "PASS: multi-pin ambiguity" +} +run_test_multi_pin + +# release.yml does not exist — exits 1 +run_test_missing_file() { + local actual_exit=0 output + output=$( + RELEASE_YML="${TMPDIR}/nonexistent/release.yml" \ + GH_TOKEN="fake" \ + bash "${SCRIPT}" 2>&1 + ) || actual_exit=$? + + if [[ "${actual_exit}" -ne 1 ]]; then + echo "FAIL: missing release.yml — expected exit 1, got ${actual_exit}" + FAILURES=$((FAILURES + 1)) + return + fi + + if [[ "${output}" != *"Release workflow not found"* ]]; then + echo "FAIL: missing release.yml — expected 'Release workflow not found' in output" + echo " output: ${output}" + FAILURES=$((FAILURES + 1)) + return + fi + + echo "PASS: missing release.yml" +} +run_test_missing_file + +echo "" +if [[ "${FAILURES}" -gt 0 ]]; then + echo "${FAILURES} test(s) FAILED" + exit 1 +else + echo "All tests passed" +fi diff --git a/scripts/check-agents-gate-pin.sh b/scripts/check-agents-gate-pin.sh new file mode 100755 index 0000000000..5acc0addcb --- /dev/null +++ b/scripts/check-agents-gate-pin.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# check-agents-gate-pin.sh — Verify the validate-agents workflow pin in +# release.yml is current with fullsend-ai/agents main. +# +# Exits 0 if the pin matches agents main HEAD. +# Exits 1 if the pin is behind, unreachable, or missing. +# +# Inputs (env vars): +# RELEASE_YML — path to release.yml +# (default: .github/workflows/release.yml) +# +# Requires: gh CLI authenticated with read access to fullsend-ai/agents. + +set -euo pipefail + +RELEASE_YML="${RELEASE_YML:-.github/workflows/release.yml}" + +if [[ ! -f "${RELEASE_YML}" ]]; then + echo "::error::Release workflow not found: ${RELEASE_YML//::/}" + exit 1 +fi + +# Extract the pinned SHA from the uses: directive. +grep_rc=0 +PINNED_SHAS=$( + grep -oE \ + 'fullsend-ai/agents/\.github/workflows/functional-tests\.yml@[a-f0-9]{40}' \ + "${RELEASE_YML}" \ + | sed 's/.*@//' +) || grep_rc=$? + +# Exit code 1 = no match (handled by the empty-check below). +# Exit code ≥ 2 = file-read or internal grep error — surface it. +if [[ "${grep_rc}" -gt 1 ]]; then + echo "::error::Failed to read ${RELEASE_YML//::/} (grep exit code ${grep_rc})" + exit 1 +fi + +if [[ -z "${PINNED_SHAS}" ]]; then + echo "::error::Could not find fullsend-ai/agents workflow pin in ${RELEASE_YML//::/}" + exit 1 +fi + +# Reject ambiguous multi-pin configs. +SHA_COUNT=$(echo "${PINNED_SHAS}" | wc -l) +if [[ "${SHA_COUNT}" -gt 1 ]]; then + echo "::error::Ambiguous: found ${SHA_COUNT} fullsend-ai/agents workflow pins in ${RELEASE_YML//::/}" + exit 1 +fi +PINNED_SHA="${PINNED_SHAS}" + +# Fetch agents main HEAD SHA. +AGENTS_MAIN_SHA=$( + gh api repos/fullsend-ai/agents/commits/main --jq '.sha' +) || { + echo "::error::Failed to fetch fullsend-ai/agents main SHA" + exit 1 +} + +if [[ ! "${AGENTS_MAIN_SHA}" =~ ^[a-f0-9]{40}$ ]]; then + echo "::error::Unexpected SHA returned for fullsend-ai/agents main: ${AGENTS_MAIN_SHA//::/}" + exit 1 +fi + +if [[ "${PINNED_SHA}" == "${AGENTS_MAIN_SHA}" ]]; then + echo "::notice::validate-agents gate pin is current: ${PINNED_SHA}" + exit 0 +fi + +# Count how far behind the pin is. +BEHIND_COUNT=$( + gh api \ + "repos/fullsend-ai/agents/compare/${PINNED_SHA}...${AGENTS_MAIN_SHA}" \ + --jq '.ahead_by' +) || BEHIND_COUNT="unknown" + +echo "::error::validate-agents gate pin ${PINNED_SHA//::/} does not match agents main ${AGENTS_MAIN_SHA//::/} (pin is ${BEHIND_COUNT//::/} commit(s) behind; 0/unknown means the pin is not an ancestor of main)" +exit 1 diff --git a/.github/scripts/redact-behaviour-artifacts-test.sh b/scripts/redact-behaviour-artifacts-test.sh similarity index 99% rename from .github/scripts/redact-behaviour-artifacts-test.sh rename to scripts/redact-behaviour-artifacts-test.sh index 9a373c1506..8c72fc64f9 100755 --- a/.github/scripts/redact-behaviour-artifacts-test.sh +++ b/scripts/redact-behaviour-artifacts-test.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # redact-behaviour-artifacts-test.sh — Tests for redact-behaviour-artifacts.sh # -# Run from repo root: bash .github/scripts/redact-behaviour-artifacts-test.sh +# Run from repo root: bash scripts/redact-behaviour-artifacts-test.sh set -euo pipefail diff --git a/.github/scripts/redact-behaviour-artifacts.sh b/scripts/redact-behaviour-artifacts.sh similarity index 100% rename from .github/scripts/redact-behaviour-artifacts.sh rename to scripts/redact-behaviour-artifacts.sh