# CI Workflows

Conventions for GitHub Actions workflows under `.github/workflows/`. Follow these conventions when adding or modifying workflows.

## Concurrency groups

- Use `${{ github.workflow }}` as the workflow identifier — never duplicate the workflow name as a hardcoded string prefix (see [exception for `workflow_call`-only workflows](#reusable-workflow-concurrency) below).
- Standard pattern for branch/PR workflows:
  ```yaml
  concurrency:
    group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
    cancel-in-progress: true
  ```
- For `pull_request_target` workflows, use a PR-number-scoped group for that event and fall back to the standard pattern for other triggers:
  ```yaml
  concurrency:
    group: >-
      ${{ github.event_name == 'pull_request_target'
          && format('{0}-{1}', github.workflow, github.event.pull_request.number)
          || format('{0}-{1}', github.workflow, github.ref) }}
  ```
- <a id="reusable-workflow-concurrency"></a>**Exception — `workflow_call`-only workflows:** workflows whose *only* trigger is `workflow_call` must use a hardcoded role-specific prefix with `inputs.*`-based suffixes instead of `${{ github.workflow }}`, because in `workflow_call` context `github.workflow` resolves to the *caller's* workflow name, not the reusable workflow's own name. Using it would produce incorrect concurrency scoping and could cancel the caller's runs. The suffix should derive from `inputs.*` (not `github.event.*`), since the caller's event context may not match the underlying PR/issue. See `reusable-dispatch.yml` for the canonical pattern:
  ```yaml
  concurrency:
    group: fullsend-code-agent-${{ inputs.source_repo }}-${{ fromJSON(inputs.event_payload).issue.number || fromJSON(inputs.event_payload).pull_request.number }}
  ```
  Hybrid workflows that combine `workflow_call` with direct triggers like `pull_request` or `push` (e.g., `e2e.yml`) may still use `${{ github.workflow }}` in the branch of their concurrency expression that handles direct triggers — the `workflow_call` invocations in these cases come from a thin caller that shares the same concurrency intent.
- Never cancel in-progress runs on the default branch (`refs/heads/main`). Gate `cancel-in-progress` when the workflow triggers on `push` to `main` (see [exception for the site build and deploy workflows](#site-workflows-cancel-in-progress) below).
- <a id="site-workflows-cancel-in-progress"></a>**Exception — `site-build.yml` and `site-deploy.yml`:**
  these two files intentionally use unconditional `cancel-in-progress: true`,
  including on `main` (accepted in PR #7437 to keep fork-PR site previews).
  Do not flag it as a violation of the rule above. Revisit if cold-cache
  starvation shows up on a busy `main`.

**Why:** A hardcoded prefix like `my-workflow-${{ github.workflow }}` is redundant — `github.workflow` already resolves to the workflow `name:` field. The duplication creates a confusing group key and wastes characters. The reusable-workflow exception exists because GitHub resolves `github.workflow` from the caller's context, so a reusable workflow using it would share a concurrency group with its caller.

## Context-variable scoping

GitHub Actions contexts describe different parts of an invocation. Do not treat similarly named properties as interchangeable when moving logic between inline workflow steps, reusable workflows, and composite actions. See the [GitHub contexts reference](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts) for the complete availability matrix.

| Need | Use |
|---|---|
| Repository that triggered the workflow | `github.repository` |
| Repository containing the action currently being executed | `github.action_repository` |
| Ref used to invoke the action currently being executed | `github.action_ref` |
| Repository containing the workflow file that defines the current job on GitHub.com | `job.workflow_repository` |
| Commit containing the workflow file that defines the current job on GitHub.com | `job.workflow_sha` |

In a reusable workflow, the caller-scoped subset of `github.*` — including
`github.repository`, `github.sha`, `github.ref`, `github.workflow`, and
`github.token` — remains associated with the caller workflow. The
`github.action_*` properties are an exception: they identify the action
currently executing, not the caller workflow. In a composite action, expose
these action-identity values through the `env` context when using them in a
`run` step.

For remote actions invoked with `owner/repo@ref`, `github.action_repository`
and `github.action_ref` identify the referenced action. For local composite
actions invoked with `uses: ./...`, those two properties are empty; pass an
explicit input or environment value when the action needs its repository or
revision identity.

On GitHub.com, `job.workflow_repository` and `job.workflow_sha` identify the
repository and commit containing the workflow file that defines the current
job. In a reusable workflow invoked through `workflow_call`, they identify the
reusable workflow rather than the caller workflow. They are workflow-definition
context, not action-execution context, and must never substitute for
`github.action_repository` or `github.action_ref`. They are unavailable on
GitHub Enterprise Server, so they cannot serve as a documented fallback across
platforms.

When refactoring between action types, audit every `job.*` and `github.*` reference for its execution context. Add an explicit input or fallback only when the action supports invocation modes where the preferred context can be absent, and validate that the fallback refers to the same repository and revision intended by the operation.

## Timeout policy

- Every non-reusable workflow job must set `timeout-minutes`.
- Use the minimum reasonable value for the job's workload.
- Add a comment explaining the choice when it is not obvious:
  ```yaml
  jobs:
    build:
      runs-on: ubuntu-24.04
      # Stub is near-instant; headroom for the real test suite.
      timeout-minutes: 15
  ```
- Reusable workflows (`on: workflow_call`) should document timeout expectations in a comment but leave `timeout-minutes` to the caller, since the caller controls the runner and workload context.

**Why:** GitHub Actions defaults to a 6-hour timeout. A runaway job without `timeout-minutes` consumes runner capacity silently. Explicit timeouts make resource usage visible and catch hangs early.

## Trigger completeness

- Path-filtered workflows (`on.push.paths` or `on.pull_request.paths`) must include `merge_group:` as a trigger.
- Since `merge_group` does not support `on.paths`, add a path-relevance guard step that skips the job when no relevant files changed:
  ```yaml
  on:
    push:
      branches: [main]
      paths:
        - "src/**"
        - ".github/workflows/my-workflow.yml"
    pull_request:
      paths:
        - "src/**"
        - ".github/workflows/my-workflow.yml"
    merge_group:

  jobs:
    build:
      steps:
        - name: Check for relevant changes
          id: changes
          if: github.event_name == 'merge_group'
          env:
            GH_TOKEN: ${{ github.token }}
            REPO: ${{ github.repository }}
            MERGE_GROUP_BASE: ${{ github.event.merge_group.base_sha }}
            MERGE_GROUP_HEAD: ${{ github.event.merge_group.head_sha }}
          # SYNC-WITH: push.paths / pull_request.paths filters above
          run: |
            FILES=$(gh api "repos/${REPO}/compare/${MERGE_GROUP_BASE}...${MERGE_GROUP_HEAD}" \
              --jq '.files[].filename') || {
              echo "::warning::Failed to fetch merge group files — running as a precaution"
              echo "relevant=true" >> "$GITHUB_OUTPUT"
              exit 0
            }
            FILE_COUNT=$(echo "$FILES" | wc -l)
            if [ "$FILE_COUNT" -ge 300 ]; then
              echo "::warning::Compare API returned $FILE_COUNT files (possible truncation) — running as a precaution"
              echo "relevant=true" >> "$GITHUB_OUTPUT"
              exit 0
            fi
            if echo "$FILES" | grep -qE '^src/|^\.github/workflows/my-workflow\.yml$'; then
              echo "relevant=true" >> "$GITHUB_OUTPUT"
            else
              echo "::notice::No relevant files changed — skipping"
              echo "relevant=false" >> "$GITHUB_OUTPUT"
            fi

        - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
          if: steps.changes.outputs.relevant != 'false'
  ```
- Mark the path-relevance grep with `# SYNC-WITH: push.paths` so reviewers can verify the path list stays in sync with the `on:` filter.
- On API failure or file-count truncation (>= 300), default to running the job — false positives are cheaper than false negatives.

**Why:** The merge queue creates temporary merge commits that do not match `on.push` or `on.pull_request` triggers. Without `merge_group:`, a path-filtered workflow is skipped entirely in the merge queue, which can block merges if the workflow is a required status check.

## Checkout pin

- Pin `actions/checkout` to a full SHA, not a tag.
- Use the repo-standard version and SHA. Current pin:
  ```yaml
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
  ```
- When bumping the pin, update all workflow files in the same PR. Use `pinact` (available in CI) to verify SHA-to-tag consistency.
- Apply the same SHA-pinning convention to all third-party actions (e.g., `actions/setup-go`, `actions/upload-artifact`). Include a `# vX.Y.Z` comment after the SHA for human readability.

**Why:** Tag-only references are mutable — a compromised or force-pushed tag can inject arbitrary code into every workflow that references it. SHA pins are immutable and auditable. The version comment preserves discoverability for Dependabot/Renovate.

## Reusable workflow contracts

- Document required `inputs` and `secrets` at the top of the reusable workflow file in a comment block.
- Use descriptive `description:` fields on each input — callers read these when wiring up `workflow_call`.
- Specify `type:` (`string`, `boolean`, `number`) and `required:` on every input.
- Document expected `outputs` and their semantics when the reusable workflow produces values consumed by the caller.
- When changing a reusable workflow's inputs or outputs, check all callers. Search for the workflow filename across the repo:
  ```bash
  grep -r "uses:.*reusable-<name>.yml" .github/workflows/
  ```

**Why:** Reusable workflows are implicit contracts. Undocumented inputs lead to misconfigured callers, broken dispatches, and silent failures. Treating them as documented APIs prevents drift.

## Permissions

- Set `permissions: {}` at the workflow level and grant only the permissions each job needs at the job level.
- Never use `permissions: write-all` or omit permissions (which defaults to the repo's broad default token permissions).
- Separate jobs that need elevated permissions (e.g., `pull-requests: write`) from jobs that check out untrusted code.

## Secrets in pull_request_target jobs

Jobs triggered by `pull_request_target` that check out and execute PR-head code can expose secrets to untrusted authors. This is the well-documented "pwn request" vulnerability class. Five components form the attack chain:

1. **Event type** — `pull_request_target` runs with base-branch secrets and permissions, unlike `pull_request` which sandboxes fork PRs.
2. **Checkout of PR head** — `ref: github.event.pull_request.head.sha` or `allow-unsafe-pr-checkout: true` brings untrusted code onto the runner.
3. **Code execution** — a `run:` step (e.g., `make behaviour-test`, `make playback-test`) executes that untrusted code.
4. **Env access** — secrets wired into the step's `env:` block are readable by any code the step runs.
5. **Credential type** — the blast radius of exfiltration depends on what was exposed.

When all five components are present, any code the PR author controls can read and exfiltrate every secret in that step's environment.

**ADR-0009 and the shim distinction:** ADR-0009 documents why `pull_request_target` is safe for the shim workflow — the shim never checks out PR code, so components 2–3 are absent. This safety reasoning does **not** transfer to jobs that check out and execute PR-head code (e.g., the behaviour and playback jobs in `e2e.yml`).

### Credential blast radius

Not all credentials carry the same risk on exfiltration:

| Category | Examples | Blast radius |
|---|---|---|
| Short-lived, narrowly scoped | GitHub App installation tokens (`${{ github.token }}`), GCP WIF tokens | Expire in minutes/hours; scoped to specific repos or resources. Attacker window is small. |
| Long-lived, broadly scoped | Classic PATs, GitHub App PEM keys, Cloudflare API tokens | Valid until manually rotated; often grant access beyond the repo that exposed them. Attacker window is large. |

Prefer short-lived narrowly-scoped credentials whenever possible. When long-lived credentials are unavoidable (e.g., PEM keys for GitHub App impersonation during test setup), the authorization gate and code review become the primary defense.

### Gate job mitigation

The `check-e2e-authorization` gate job (`gate` in `e2e.yml`) mitigates the risk by requiring authorization before the behaviour and playback jobs check out PR-head code. `e2e.yml` and `e2e-ok-to-test.yml` no longer use `pull_request_target` ([#9245](https://github.com/fullsend-ai/fullsend/issues/9245)): same-repository PRs run on `pull_request`, and fork PRs run only after a `/ok-to-test <sha>` comment (`issue_comment`):

- **Same-repository PRs** — the gate authorizes authors with write access (collaborator permission API) and trusted bots. Fork `pull_request` runs get no secrets, so the suites skip them.
- **Fork PRs** — require a user with write access (Write role or above) to review the PR diff and comment `/ok-to-test <sha>`, naming the reviewed commit, including when the fork author has write access. The gate reads the comment from the API, checks the commenter's permission, and authorizes the PR head only when it is the named sha. Another head is refused (`stale_ok_to_test`), and a comment with no sha is refused (`missing_sha`). The gate uses no timing. The `ok-to-test` label no longer starts e2e.
- **Separation of concerns** — the gate (authorize/report) job runs on the base-branch checkout and is the only job with `pull-requests: write` and `checks: write`; it reports the `behaviour` and `playback` check runs on the authorized sha. The behaviour/playback jobs run on the PR-head checkout with `contents: read` and `id-token: write` only.

**Limitations of the gate:**

- The gate authorizes *authors* and *commenters*, not *code*. A trusted account that is compromised bypasses the gate.
- The gate does not inspect the PR diff — it trusts that the commenter reviewed the named commit before commenting `/ok-to-test <sha>`.
- The suites run the sha the gate authorized, so a push after authorization does not reach a credentialed run. A push between the comment and the gate run moves the head away from the named sha, so the gate refuses it. The command takes only the full 40-character sha: the author controls the reviewed commit and could prepare another commit sharing a short prefix.

**Validating gate changes:** A same-repository `pull_request` run takes `e2e.yml` and the gate action from the PR merge ref, so a gate-modifying PR exercises its own gate for same-repository authors. The `/ok-to-test` path runs on `issue_comment`, which takes its workflow and the gate from `main`; a PR that changes that path still runs `main`'s gate. Validate gate logic with unit tests pre-merge (e.g., `scripts/check-e2e-authorization-test.sh`) and include a post-merge validation checklist in the PR description.

### Review checklist for secrets in behaviour/playback jobs

When a PR adds or modifies secret references in a `pull_request_target` job, reviewers must verify:

- [ ] **Trace the execution path.** Identify which `run:` steps execute after checkout of PR-head code. Confirm the secret is wired into one of those steps (all five attack-chain components are present).
- [ ] **Assess blast radius.** Determine whether the credential is short-lived and narrowly scoped or long-lived and broadly scoped. Document the blast radius in a PR comment when adding long-lived credentials.
- [ ] **Prefer short-lived credentials.** Use WIF tokens or GitHub App installation tokens over classic PATs or PEM keys when the test infrastructure supports it.
- [ ] **Do not wire secrets before consumption code exists.** Adding a secret to `env:` in a PR that does not yet contain the code that uses it means the secret is exposed to whatever code does run — with no benefit.
- [ ] **Verify the gate job covers the new job.** If the PR adds a new job that checks out PR-head code with secrets, confirm that job has `needs: gate` and the appropriate `if:` condition gating on `needs.gate.outputs.authorized`.

### Behaviour debug artifact redaction

The behaviour job in `e2e.yml` uploads debug artifacts after every relevant run, whether the tests succeed or fail. Because PR-head code populates that directory on authorized PR runs (`pull_request` and `/ok-to-test`), 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 `scripts/redact-behaviour-artifacts.sh` from the **base branch** (`github.event.pull_request.base.sha` on `pull_request`, whose `github.sha` is the PR merge commit; the default-branch head on `/ok-to-test` runs; 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'`.

Redaction covers:

- Plain text artifacts (JSON, JSONL, logs, feature output) — literal env secrets (including multi-line PEM lines), common token patterns, and PEM blocks
- Nested archives (zip, tar.gz, gzip) — extract, redact, re-pack with the same size limits as behaviour artifact downloads
- Encrypted blobs (`.gpg`, `.age`, `.enc`) — replaced with a stub (cannot scan ciphertext)
- Binary and media files (images, video, PDF, opaque blobs, NUL-containing `.log` files) — replaced with a stub
- Symlinks under the artifact directory — replaced with a stub before upload

The job-scoped trace collector's bearer token ([Job-scoped trace collection](../guides/dev/behaviour-testing.md#job-scoped-trace-collection)) is generated inside the behaviour test process. It is not a job secret, so the redaction script's literal secret list cannot include it. The install driver never prints it (so it never reaches the tee'd job log) and scrubs literal occurrences of it from every file under the artifact directory when the collector stops, before the redaction step runs. The scrub matches the token's raw bytes only, so a token inside a compressed or archived file (for example gzip or ZIP) is not found. Collector output is plain-text JSONL and log files, so this is not expected. The collected trace files are then redacted like any other artifact.

**Residual limitations:** content scanning cannot catch every encoding or obfuscation of a secret in a text-classified file (base64, hex, split tokens). Same-job PR-head code could theoretically race the upload step after redaction; isolating redaction in a separate job would narrow that window further.

## Scaffold-sync dispatch recursion

`notify-scaffold-sync` fires on every `push` to `main`. Its job generates a GitHub App installation token for `fullsend-ai-sync[bot]` and dispatches `fullsend-updated` to `fullsend-ai/.fullsend`, which runs `sync-scaffold` to converge per-repo variables and scaffold files.

Because the sync App authenticates with an App installation token (not `GITHUB_TOKEN`), GitHub's workflow-suppression rule does not apply — a sync commit pushed to `main` re-triggers `notify-scaffold-sync`, which dispatches again. Each scaffold-touching merge therefore costs ≥2 dispatch rounds: the first sync converges files, the second re-enters and converges any state that depends on the first sync's output. The chain terminates when a sync round produces no diff.

This recursion is by design but interacts with the convergence non-idempotence tracked in #6553. See also [Bot Identities § App-token push recursion](bot-identities.md#app-token-push-recursion) for the observed dispatch chain and the security-relevant distinction between the coder token (no `workflows` permission) and the sync App (has `workflows` permission plus `bypass_mode: always` on the `main` ruleset).

## Additional conventions

- Always include the workflow file itself in its own `paths:` filter so changes to the workflow trigger its own CI.
- Use `ubuntu-24.04` (specific version), not `ubuntu-latest`, for reproducible builds.
- Environment variables that hold secrets must use `${{ secrets.NAME }}` — never hardcode sensitive values or use environment variables with defaults for secrets.

### Semgrep security scan

`scripts/semgrep-scan` is the shared entry point for the pinned Semgrep engine
and commit-pinned Community Edition rules from `semgrep/semgrep-rules`. The
source-file allowlist in `scripts/semgrep-ruleset-files.txt` limits the scan to
files represented in the curated `p/security-audit` config snapshot whose
SHA-256 is recorded in `scripts/semgrep-security-audit.sha256`. It includes all
225 Community Edition rules from the snapshot in 216 source files available in
the pinned public rules repository.
The source commit is recorded in `scripts/semgrep-ruleset-commit`; the downloaded
archive is checked against the SHA-256 in `scripts/semgrep-ruleset.sha256` before
extraction. The p/security-audit config snapshot hash is recorded in
`scripts/semgrep-security-audit.sha256`; the drift workflow checks it so changes
to pack membership are reported alongside changes to selected rule files.
Upstream rule test fixtures are excluded. The dedicated Semgrep job
sets `SEMGREP_REQUIRED=1`, so it fails closed if the pinned source or locked
Semgrep environment cannot be prepared.
Scans fail closed by default; the local pre-commit hook explicitly sets
`SEMGREP_REQUIRED=0` to allow a skip when prerequisites are unavailable. A
completed scan still fails on findings. The scheduled `Semgrep Ruleset Drift`
workflow compares allowlisted rule-file contents at the pinned commit and
upstream `develop`, and checks the config hash so pack membership changes are
reported too. Unrelated upstream commits do not create drift. Upstream changes
do not alter scans until the pins are updated. Renovate PEP 621
updates keep the Semgrep version and `uv.lock` together; weekly lock
maintenance refreshes transitive dependencies, and Semgrep updates stay out of
automerge.

The pre-commit hook scans changed files of all types, except paths matched by
its `exclude:` expression in `.pre-commit-config.yaml`. Semgrep's full-directory
scan in CI honors `.semgrepignore`, which excludes Go, Python, JavaScript, and
TypeScript test and spec files plus synthetic fixtures. Explicit file arguments
bypass it, so keep the hook expression aligned with that file. CI scans in a
separate job, leaving
lint and test results visible if Semgrep fails, and retains a SARIF artifact
for 30 days. Both scans use `--strict` so parse errors and rule timeouts fail
the scan instead of passing with partial analysis. The job reports failures
independently; a failing check blocks merges only when the branch ruleset
requires it. The main ruleset will require Semgrep after this workflow merges.
For an offline local run, use `SKIP=semgrep make test` or
`SKIP=semgrep pre-commit run --all-files`.
