Skip to content
Merged
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
7 changes: 4 additions & 3 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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'
Expand Down
74 changes: 69 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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:
Expand All @@ -90,8 +148,13 @@ jobs:
- name: Push tag to fullsend-ai/agents
Comment thread
waynesun09 marked this conversation as resolved.
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}" \
Expand All @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion docs/contributing/ci-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'`.

Expand Down
2 changes: 1 addition & 1 deletion internal/cli/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
56 changes: 50 additions & 6 deletions internal/cli/vendor.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
"strings"

"github.com/spf13/cobra"

Expand Down Expand Up @@ -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
}
Expand All @@ -79,19 +80,19 @@ 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
}
defer cleanup()
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 {
Expand Down Expand Up @@ -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
}
Expand All @@ -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))
Comment thread
waynesun09 marked this conversation as resolved.
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/"
Expand Down
Loading
Loading