diff --git a/.github/workflows/aw-prelude.yml b/.github/workflows/aw-prelude.yml index 013f294..ee51f47 100644 --- a/.github/workflows/aw-prelude.yml +++ b/.github/workflows/aw-prelude.yml @@ -2,6 +2,7 @@ name: Agentic Workflow Prelude # Shared dashboard gating and optional allow-list loading for *-aw-* workflows. # Call this reusable workflow before agent-specific jobs in oblt-aw-* and docs-aw-* wrappers. +# APM asset resolution lives in aw-resolve-apm-assets.yml (per gh-aw-* invocation). on: workflow_call: inputs: @@ -77,6 +78,7 @@ jobs: timeout-minutes: 2 outputs: proceed: ${{ steps.gate.outputs.proceed }} + compound-workflow-id: ${{ steps.resolve.outputs.compound-workflow-id }} allowed-pr-authors-json: ${{ steps.pack.outputs.allowed-pr-authors-json }} allowed-pr-authors-csv: ${{ steps.pack.outputs.allowed-pr-authors-csv }} allowed-issue-authors-json: ${{ steps.pack.outputs.allowed-issue-authors-json }} diff --git a/.github/workflows/aw-resolve-apm-assets.yml b/.github/workflows/aw-resolve-apm-assets.yml new file mode 100644 index 0000000..4d53b31 --- /dev/null +++ b/.github/workflows/aw-resolve-apm-assets.yml @@ -0,0 +1,159 @@ +name: Resolve APM Agentic Assets + +# Resolves consumer apm.yml assets for one agentic workflow invocation. Call immediately +# before each job that uses a gh-aw-* reusable workflow (not from aw-prelude). +on: + workflow_call: + inputs: + control-plane-workflow: + description: >- + Basename of the calling wrapper under .github/workflows/ (for example + oblt-aw-automerge.yml). Used to resolve the registry workflow id for apm.yml. + required: true + type: string + platform-additional-instructions: + description: >- + Control-plane baseline additional-instructions for this agent invocation. + Merged with consumer apm.yml assets (platform first, then repo assets). + required: false + type: string + default: "" + platform-inputs-json: + description: >- + JSON object of platform workflow_call inputs to merge; repo apm.yml inputs + override per key when an asset block is selected. + required: false + type: string + default: "{}" + install-apm-packages: + description: Run `apm install` when apm.yml is present in the consumer repository + required: false + type: boolean + default: true + outputs: + apm-manifest-present: + description: True when the consumer repository contains apm.yml or apm.yaml + value: ${{ jobs.resolve.outputs.apm-manifest-present }} + apm-extension-present: + description: True when apm.yml contains an x-oblt-aw extension block + value: ${{ jobs.resolve.outputs.apm-extension-present }} + asset-source: + description: none, common, or workflow (APM asset block used for resolution) + value: ${{ jobs.resolve.outputs.asset-source }} + resolved-additional-instructions: + description: Platform baseline plus resolved repo additional-instructions + value: ${{ jobs.resolve.outputs.resolved-additional-instructions }} + resolved-inputs-json: + description: JSON object of merged platform and APM workflow inputs + value: ${{ jobs.resolve.outputs.resolved-inputs-json }} + resolved-setup-commands-json: + description: JSON array of setup shell commands from APM assets + value: ${{ jobs.resolve.outputs.resolved-setup-commands-json }} + +permissions: + contents: read + +jobs: + resolve: + permissions: + contents: read + runs-on: ubuntu-latest + timeout-minutes: 15 + outputs: + apm-manifest-present: ${{ steps.resolve.outputs.apm-manifest-present }} + apm-extension-present: ${{ steps.resolve.outputs.apm-extension-present }} + asset-source: ${{ steps.resolve.outputs.asset-source }} + resolved-additional-instructions: ${{ steps.resolve.outputs.resolved-additional-instructions }} + resolved-inputs-json: ${{ steps.resolve.outputs.resolved-inputs-json }} + resolved-setup-commands-json: ${{ steps.resolve.outputs.resolved-setup-commands-json }} + steps: + - name: Checkout consumer repository + uses: actions/checkout@v6 + + - name: Checkout oblt-aw resolver scripts + uses: actions/checkout@v6 + with: + repository: elastic/oblt-aw + ref: main + path: _oblt-aw + fetch-depth: 1 + token: ${{ github.token }} + sparse-checkout: | + scripts/apm_agentic_assets.py + scripts/resolve_apm_agentic_assets.py + scripts/resolve_control_plane_workflow_id.py + scripts/workflow_registry.py + scripts/common.py + config/ + requirements-runtime.txt + sparse-checkout-cone-mode: false + + - name: Resolve compound workflow id from registry + id: registry + env: + CONTROL_PLANE_WORKFLOW: ${{ inputs.control-plane-workflow }} + run: python _oblt-aw/scripts/resolve_control_plane_workflow_id.py "${CONTROL_PLANE_WORKFLOW}" --config-dir _oblt-aw/config + + - name: Detect apm manifest + id: detect + run: | + set -euo pipefail + if [ -f apm.yml ] || [ -f apm.yaml ]; then + echo "present=true" >> "${GITHUB_OUTPUT}" + else + echo "present=false" >> "${GITHUB_OUTPUT}" + fi + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: _oblt-aw/requirements-runtime.txt + + - name: Install Python dependencies for resolver + run: pip install -r _oblt-aw/requirements-runtime.txt + + - name: Install APM CLI + if: >- + inputs.install-apm-packages && + steps.detect.outputs.present == 'true' + env: + APM_VERSION: "v0.16.0" + run: | + set -euo pipefail + OS="$(uname -s | tr '[:upper:]' '[:lower:]')" + ARCH="$(uname -m)" + case "${ARCH}" in + x86_64) ARCH="x86_64" ;; + arm64|aarch64) ARCH="arm64" ;; + *) echo "Unsupported architecture: ${ARCH}"; exit 1 ;; + esac + TARBALL="apm-${OS}-${ARCH}.tar.gz" + BASE_URL="https://github.com/microsoft/apm/releases/download/${APM_VERSION}" + curl -fsSL "${BASE_URL}/${TARBALL}" -o "/tmp/${TARBALL}" + curl -fsSL "${BASE_URL}/${TARBALL}.sha256" -o "/tmp/${TARBALL}.sha256" + EXPECTED="$(awk '{print $1}' "/tmp/${TARBALL}.sha256")" + echo "${EXPECTED} /tmp/${TARBALL}" | sha256sum -c + mkdir -p "${HOME}/.local/bin" + tar -xzf "/tmp/${TARBALL}" -C /tmp "apm-${OS}-${ARCH}/apm" + install -m 0755 "/tmp/apm-${OS}-${ARCH}/apm" "${HOME}/.local/bin/apm" + echo "${HOME}/.local/bin" >> "${GITHUB_PATH}" + + - name: Install agent packages from apm.yml + if: >- + inputs.install-apm-packages && + steps.detect.outputs.present == 'true' + run: | + set -euo pipefail + apm install + + - name: Resolve agentic assets from apm.yml + id: resolve + env: + ENABLED_WORKFLOW_ID: ${{ steps.registry.outputs.compound-workflow-id }} + REPO_ROOT: ${{ github.workspace }} + CONTROL_PLANE_CONFIG_DIR: ${{ github.workspace }}/_oblt-aw/config + PLATFORM_ADDITIONAL_INSTRUCTIONS: ${{ inputs.platform-additional-instructions }} + PLATFORM_INPUTS_JSON: ${{ inputs.platform-inputs-json }} + run: python _oblt-aw/scripts/resolve_apm_agentic_assets.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ba27a4..4470ade 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,7 +45,9 @@ jobs: with: python-version: "3.14" cache: pip - cache-dependency-path: requirements-ci.txt + cache-dependency-path: | + requirements-ci.txt + requirements-runtime.txt - name: Install Python test dependencies run: pip install -r requirements-ci.txt @@ -56,6 +58,9 @@ jobs: - name: Validate *-aw-* workflows call aw-prelude run: python scripts/validate_aw_workflow_prelude.py + - name: Validate gh-aw-* workflows call resolve-apm-assets + run: python scripts/validate_aw_workflow_resolve_apm_assets.py + typescript-tests: name: TypeScript tests runs-on: ubuntu-latest diff --git a/.github/workflows/distribute-client-workflow.yml b/.github/workflows/distribute-client-workflow.yml index c642b81..e9a2e32 100644 --- a/.github/workflows/distribute-client-workflow.yml +++ b/.github/workflows/distribute-client-workflow.yml @@ -54,6 +54,8 @@ jobs: uses: actions/setup-python@v6 with: python-version: "3.14" + cache: pip + cache-dependency-path: requirements-runtime.txt - name: Build target operations matrix id: targets diff --git a/.github/workflows/docs-aw-ai-menu.yml b/.github/workflows/docs-aw-ai-menu.yml index 890c42f..3e2619d 100644 --- a/.github/workflows/docs-aw-ai-menu.yml +++ b/.github/workflows/docs-aw-ai-menu.yml @@ -132,23 +132,47 @@ jobs: const fn = require('./oblt-aw-scripts/scripts/docs/issue-menu/refresh-after-trigger.js') await fn({github, context, core}) + resolve-apm-assets-triage: + needs: [prelude, evaluate-trigger] + if: >- + needs.prelude.outputs.proceed == 'true' && + needs.evaluate-trigger.outputs.triage_triggered == 'true' + uses: ./.github/workflows/aw-resolve-apm-assets.yml + with: + control-plane-workflow: docs-aw-ai-menu.yml + run-docs-triage: name: Docs AI / triage - needs: [evaluate-trigger] - if: needs.evaluate-trigger.outputs.triage_triggered == 'true' + needs: [prelude, evaluate-trigger, resolve-apm-assets-triage] + if: >- + needs.prelude.outputs.proceed == 'true' && + needs.evaluate-trigger.outputs.triage_triggered == 'true' permissions: actions: read contents: read issues: write pull-requests: write uses: elastic/docs-actions/.github/workflows/gh-aw-issue-triage.lock.yml@v1 + with: + additional-instructions: ${{ needs.resolve-apm-assets-triage.outputs.resolved-additional-instructions }} secrets: COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + resolve-apm-assets-issue-scope: + needs: [prelude, evaluate-trigger] + if: >- + needs.prelude.outputs.proceed == 'true' && + needs.evaluate-trigger.outputs.issue_scope_triggered == 'true' + uses: ./.github/workflows/aw-resolve-apm-assets.yml + with: + control-plane-workflow: docs-aw-ai-menu.yml + run-docs-issue-scope: name: Docs AI / issue scope - needs: [evaluate-trigger] - if: needs.evaluate-trigger.outputs.issue_scope_triggered == 'true' + needs: [prelude, evaluate-trigger, resolve-apm-assets-issue-scope] + if: >- + needs.prelude.outputs.proceed == 'true' && + needs.evaluate-trigger.outputs.issue_scope_triggered == 'true' permissions: actions: read contents: read @@ -156,6 +180,8 @@ jobs: issues: write pull-requests: write uses: elastic/docs-actions/.github/workflows/gh-aw-docs-issue-scope.lock.yml@v1 + with: + additional-instructions: ${{ needs.resolve-apm-assets-issue-scope.outputs.resolved-additional-instructions }} secrets: COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} diff --git a/.github/workflows/docs-aw-pr-ai-menu.yml b/.github/workflows/docs-aw-pr-ai-menu.yml index 58780b9..387fc00 100644 --- a/.github/workflows/docs-aw-pr-ai-menu.yml +++ b/.github/workflows/docs-aw-pr-ai-menu.yml @@ -156,10 +156,24 @@ jobs: const fn = require('./oblt-aw-scripts/scripts/docs/pr-menu/refresh-after-trigger.js') await fn({github, context, core}) + resolve-apm-assets: + needs: [prelude, evaluate-trigger] + if: >- + needs.prelude.outputs.proceed == 'true' && + needs.evaluate-trigger.outputs.docs_review_triggered == 'true' + uses: ./.github/workflows/aw-resolve-apm-assets.yml + with: + control-plane-workflow: docs-aw-pr-ai-menu.yml + platform-additional-instructions: | + This repository stores documentation as markdown across the repository. + Prefer concise, high-signal review comments with exact replacement text when possible. + run-docs-review: name: Docs AI / docs review - needs: [evaluate-trigger] - if: needs.evaluate-trigger.outputs.docs_review_triggered == 'true' + needs: [prelude, evaluate-trigger, resolve-apm-assets] + if: >- + needs.prelude.outputs.proceed == 'true' && + needs.evaluate-trigger.outputs.docs_review_triggered == 'true' permissions: actions: read contents: read @@ -168,9 +182,7 @@ jobs: uses: elastic/docs-actions/.github/workflows/gh-aw-docs-review.lock.yml@v1 with: review-scope: repo-wide-markdown - additional-instructions: | - This repository stores documentation as markdown across the repository. - Prefer concise, high-signal review comments with exact replacement text when possible. + additional-instructions: ${{ needs.resolve-apm-assets.outputs.resolved-additional-instructions }} secrets: COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} diff --git a/.github/workflows/get-enabled-workflows.yml b/.github/workflows/get-enabled-workflows.yml index 8915d1e..4a8daff 100644 --- a/.github/workflows/get-enabled-workflows.yml +++ b/.github/workflows/get-enabled-workflows.yml @@ -41,12 +41,15 @@ jobs: sparse-checkout: | scripts/get_enabled_workflows.py scripts/common.py + requirements-runtime.txt sparse-checkout-cone-mode: false - name: Setup Python uses: actions/setup-python@v6 with: python-version: "3.14" + cache: pip + cache-dependency-path: _oblt-aw/requirements-runtime.txt - name: Fetch dashboard, parse, and normalize enabled workflows id: run diff --git a/.github/workflows/oblt-aw-agent-suggestions.yml b/.github/workflows/oblt-aw-agent-suggestions.yml index 8b9f11a..cea3c40 100644 --- a/.github/workflows/oblt-aw-agent-suggestions.yml +++ b/.github/workflows/oblt-aw-agent-suggestions.yml @@ -17,17 +17,13 @@ jobs: with: control-plane-workflow: oblt-aw-agent-suggestions.yml - agent-suggestions: + resolve-apm-assets: needs: prelude - permissions: - contents: read - issues: write - pull-requests: read if: needs.prelude.outputs.proceed == 'true' - uses: elastic/ai-github-actions/.github/workflows/gh-aw-agent-suggestions.lock.yml@main + uses: ./.github/workflows/aw-resolve-apm-assets.yml with: - title-prefix: "[oblt-aw][agent-suggestions]" - additional-instructions: | + control-plane-workflow: oblt-aw-agent-suggestions.yml + platform-additional-instructions: | Additional requirements for this repository: - If there are no net-new recommendations, or if recommendations only suggest workflows/features already in use in this repository, call `noop` and do not create any issue. @@ -42,4 +38,16 @@ jobs: When calling `create_issue`, ensure the output includes: - `labels`: contains `agentic-workflow` - `expires`: `24h` + + agent-suggestions: + needs: [prelude, resolve-apm-assets] + permissions: + contents: read + issues: write + pull-requests: read + if: needs.prelude.outputs.proceed == 'true' + uses: elastic/ai-github-actions/.github/workflows/gh-aw-agent-suggestions.lock.yml@main + with: + title-prefix: "[oblt-aw][agent-suggestions]" + additional-instructions: ${{ needs.resolve-apm-assets.outputs.resolved-additional-instructions }} secrets: inherit diff --git a/.github/workflows/oblt-aw-autodoc.yml b/.github/workflows/oblt-aw-autodoc.yml index 10ee4fa..ad0e88f 100644 --- a/.github/workflows/oblt-aw-autodoc.yml +++ b/.github/workflows/oblt-aw-autodoc.yml @@ -18,18 +18,13 @@ jobs: control-plane-workflow: oblt-aw-autodoc.yml # Step 1: Detect docs drift from recent code changes and create an issue with findings - audit: + resolve-apm-assets-audit: needs: prelude - permissions: - contents: read - issues: write - pull-requests: read if: needs.prelude.outputs.proceed == 'true' - uses: elastic/ai-github-actions/.github/workflows/gh-aw-docs-patrol.lock.yml@main + uses: ./.github/workflows/aw-resolve-apm-assets.yml with: - lookback-window: 1 day ago - title-prefix: "[oblt-aw][autodoc]" - additional-instructions: | + control-plane-workflow: oblt-aw-autodoc.yml + platform-additional-instructions: | Your task is to analyze ALL documentation in this repository, identify gaps and areas for improvement, and file an issue with concrete findings. Do NOT search for open issues. Instead, perform a direct documentation audit of this repository. @@ -56,22 +51,28 @@ jobs: **Issue format:** For each finding, include a clear, actionable checklist of specific documentation changes to make. Each item should reference file paths and describe the change needed. **Team notification (mandatory):** Always @mention @elastic/observablt-ci at the top of the issue body so the team receives notifications. Example: "cc @elastic/observablt-ci" or "Notify: @elastic/observablt-ci" at the start of the body. - secrets: inherit - # Step 2: Create a PR implementing the audit findings (only when an issue was created) - fix: - needs: audit + audit: + needs: [prelude, resolve-apm-assets-audit] permissions: - actions: read - contents: write + contents: read issues: write - pull-requests: write + pull-requests: read + if: needs.prelude.outputs.proceed == 'true' + uses: elastic/ai-github-actions/.github/workflows/gh-aw-docs-patrol.lock.yml@main + with: + lookback-window: 1 day ago + title-prefix: "[oblt-aw][autodoc]" + additional-instructions: ${{ needs.resolve-apm-assets-audit.outputs.resolved-additional-instructions }} + secrets: inherit + + resolve-apm-assets-fix: + needs: audit if: needs.audit.outputs.created_issue_number != '' - uses: elastic/ai-github-actions/.github/workflows/gh-aw-create-pr-from-issue.lock.yml@main + uses: ./.github/workflows/aw-resolve-apm-assets.yml with: - target-issue-number: ${{ needs.audit.outputs.created_issue_number }} - draft-prs: true - additional-instructions: | + control-plane-workflow: oblt-aw-autodoc.yml + platform-additional-instructions: | Your task is to implement the documentation improvements described in the issue. **Pull Request Requirements:** @@ -92,7 +93,7 @@ jobs: **Secret documentation:** When implementing any documentation changes related to secret definitions or usage, first consult the [`elastic/observability-github-secrets`](https://github.com/elastic/observability-github-secrets) repository and its root `README.md` as the Observability org source of truth for secret management. Do not add guidance that contradicts or extends beyond that source. - **Markdown tables:** Preserve deliberate cell content such as a leading `-` used as an icon or status placeholder; do not rewrite table rows into bullet lists or “clean up” punctuation unless it is clearly a mistake. + **Markdown tables:** Preserve deliberate cell content such as a leading `-` used as an icon or status placeholder; do not rewrite table rows into bullet lists or "clean up" punctuation unless it is clearly a mistake. **Markdown link fragments / anchors:** When editing markdown links with `#fragment`, first validate the fragment against the target document heading slug(s). Preserve the existing fragment unless verification proves it is incorrect. Do not remove a leading `-` when it is part of the valid computed slug (for example icon-prefixed headings): the `-` character is a valid replacement for a leading icon in heading text and therefore a valid part of the anchor slug. Rewriting `[Lab 01: Troubleshooting](01-installation-setup.md#-troubleshooting-quick-reference)` to `[Lab 01: Troubleshooting](01-installation-setup.md#troubleshooting-quick-reference)` is invalid when the verified heading slug is `#-troubleshooting-quick-reference`. @@ -103,6 +104,21 @@ jobs: **AI assets / agent configuration files:** Never modify AI-related assets in this workflow. AI assets, skills files, instruction/configuration files, and lock files (for example `*.lock*` and `*.lock.yml`) are always out of scope. **Helm chart internals:** Never modify Helm chart internal files. Template files (`helm-charts/**/templates/**`), notes files (`helm-charts/**/NOTES.txt`), and markdown files under `helm-charts/` are always out of scope. + + # Step 2: Create a PR implementing the audit findings (only when an issue was created) + fix: + needs: [audit, resolve-apm-assets-fix] + permissions: + actions: read + contents: write + issues: write + pull-requests: write + if: needs.audit.outputs.created_issue_number != '' + uses: elastic/ai-github-actions/.github/workflows/gh-aw-create-pr-from-issue.lock.yml@main + with: + target-issue-number: ${{ needs.audit.outputs.created_issue_number }} + draft-prs: true + additional-instructions: ${{ needs.resolve-apm-assets-fix.outputs.resolved-additional-instructions }} secrets: inherit # Step 3: Finalize autodoc PR — assign reviewer and apply labels diff --git a/.github/workflows/oblt-aw-automerge.yml b/.github/workflows/oblt-aw-automerge.yml index fae4d65..97cbad1 100644 --- a/.github/workflows/oblt-aw-automerge.yml +++ b/.github/workflows/oblt-aw-automerge.yml @@ -140,8 +140,19 @@ jobs: core.setOutput('allowed', allowed ? 'true' : 'false'); core.setOutput('collection-id', collectionId || ''); - approve: + resolve-apm-assets: needs: [prelude, verify, check-dependency-collection] + if: >- + needs.verify.outputs.proceed == 'true' && + needs.check-dependency-collection.outputs.allowed == 'true' + uses: ./.github/workflows/aw-resolve-apm-assets.yml + with: + control-plane-workflow: oblt-aw-automerge.yml + platform-additional-instructions: | + Target pull request number: ${{ github.event.pull_request.number }}. + + approve: + needs: [prelude, verify, check-dependency-collection, resolve-apm-assets] if: >- needs.verify.outputs.proceed == 'true' && needs.check-dependency-collection.outputs.allowed == 'true' @@ -154,8 +165,7 @@ jobs: uses: elastic/ai-github-actions/.github/workflows/gh-aw-mention-in-pr.lock.yml@main with: allowed-bot-users: ${{ needs.prelude.outputs.allowed-pr-authors-csv }} - additional-instructions: | - Target pull request number: ${{ github.event.pull_request.number }}. + additional-instructions: ${{ needs.resolve-apm-assets.outputs.resolved-additional-instructions }} prompt: | For pull request #${{ github.event.pull_request.number }}: evaluate automerge eligibility. diff --git a/.github/workflows/oblt-aw-dependency-review.yml b/.github/workflows/oblt-aw-dependency-review.yml index badd245..7464bb6 100644 --- a/.github/workflows/oblt-aw-dependency-review.yml +++ b/.github/workflows/oblt-aw-dependency-review.yml @@ -18,23 +18,17 @@ jobs: control-plane-workflow: oblt-aw-dependency-review.yml load-allowed-authors: true - dependency-review: + resolve-apm-assets: needs: prelude - permissions: - actions: read - contents: read - issues: write - pull-requests: write if: >- needs.prelude.outputs.proceed == 'true' && github.event_name == 'pull_request' && contains(fromJSON('["opened","synchronize","reopened"]'), github.event.action) && contains(fromJSON(needs.prelude.outputs.allowed-pr-authors-json), github.event.pull_request.user.login) - uses: elastic/ai-github-actions/.github/workflows/gh-aw-dependency-review.lock.yml@main + uses: ./.github/workflows/aw-resolve-apm-assets.yml with: - allowed-bot-users: ${{ needs.prelude.outputs.allowed-pr-authors-csv }} - classification-labels: "oblt-aw/ai/merge-ready" - additional-instructions: | + control-plane-workflow: oblt-aw-dependency-review.yml + platform-additional-instructions: | Noop when not applicable (mandatory): - If the PR has NO dependency updates to review (e.g. no version bumps in manifest files, no changes to lockfiles that indicate dependency updates, or changes that do not match any supported ecosystem), you MUST call `noop` — do NOT create any comment. - Use the format: {"noop": {"message": "No action needed: [brief explanation]"}} @@ -62,6 +56,24 @@ jobs: Label application (mandatory): - When ALL criteria for `oblt-aw/ai/merge-ready` are met, you MUST call `add_labels` with that label. Do not only recommend it in the comment; apply it via the add_labels tool. - The comment's "Labels Applied" section must reflect labels you actually applied via add_labels, not merely recommended. If you applied a label, say so; if you did not apply any, say "No labels applied." + + dependency-review: + needs: [prelude, resolve-apm-assets] + permissions: + actions: read + contents: read + issues: write + pull-requests: write + if: >- + needs.prelude.outputs.proceed == 'true' && + github.event_name == 'pull_request' && + contains(fromJSON('["opened","synchronize","reopened"]'), github.event.action) && + contains(fromJSON(needs.prelude.outputs.allowed-pr-authors-json), github.event.pull_request.user.login) + uses: elastic/ai-github-actions/.github/workflows/gh-aw-dependency-review.lock.yml@main + with: + allowed-bot-users: ${{ needs.prelude.outputs.allowed-pr-authors-csv }} + classification-labels: "oblt-aw/ai/merge-ready" + additional-instructions: ${{ needs.resolve-apm-assets.outputs.resolved-additional-instructions }} secrets: COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} diff --git a/.github/workflows/oblt-aw-duplicate-issue-detector.yml b/.github/workflows/oblt-aw-duplicate-issue-detector.yml index 526e173..3150835 100644 --- a/.github/workflows/oblt-aw-duplicate-issue-detector.yml +++ b/.github/workflows/oblt-aw-duplicate-issue-detector.yml @@ -18,8 +18,20 @@ jobs: with: control-plane-workflow: oblt-aw-duplicate-issue-detector.yml - duplicate-issue-detector: + resolve-apm-assets: needs: prelude + if: >- + needs.prelude.outputs.proceed == 'true' && + ( + (github.event_name == 'issues' && github.event.action == 'opened') || + github.event_name == 'workflow_dispatch' + ) + uses: ./.github/workflows/aw-resolve-apm-assets.yml + with: + control-plane-workflow: oblt-aw-duplicate-issue-detector.yml + + duplicate-issue-detector: + needs: [prelude, resolve-apm-assets] permissions: contents: read issues: write @@ -31,5 +43,7 @@ jobs: github.event_name == 'workflow_dispatch' ) uses: elastic/ai-github-actions/.github/workflows/gh-aw-duplicate-issue-detector.lock.yml@main + with: + additional-instructions: ${{ needs.resolve-apm-assets.outputs.resolved-additional-instructions }} secrets: COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} diff --git a/.github/workflows/oblt-aw-estc-pr-buildkite-detective.yml b/.github/workflows/oblt-aw-estc-pr-buildkite-detective.yml index ea2623a..582742b 100644 --- a/.github/workflows/oblt-aw-estc-pr-buildkite-detective.yml +++ b/.github/workflows/oblt-aw-estc-pr-buildkite-detective.yml @@ -20,8 +20,21 @@ jobs: with: control-plane-workflow: oblt-aw-estc-pr-buildkite-detective.yml - estc-pr-buildkite-detective: + resolve-apm-assets: needs: prelude + if: >- + needs.prelude.outputs.proceed == 'true' && + github.event_name == 'status' && + github.event.state == 'failure' && + contains(github.event.context, 'buildkite') + uses: ./.github/workflows/aw-resolve-apm-assets.yml + with: + control-plane-workflow: oblt-aw-estc-pr-buildkite-detective.yml + platform-additional-instructions: | + If a step fails, check if the failure is reported as a GitHub issue labeled `flaky-test`. Reference the GitHub issue if so. + + estc-pr-buildkite-detective: + needs: [prelude, resolve-apm-assets] permissions: actions: read contents: read @@ -34,8 +47,7 @@ jobs: contains(github.event.context, 'buildkite') uses: elastic/ai-github-actions/.github/workflows/gh-aw-estc-pr-buildkite-detective.lock.yml@copilot/reduce-comment-spamming with: - additional-instructions: | - If a step fails, check if the failure is reported as a GitHub issue labeled `flaky-test`. Reference the GitHub issue if so. + additional-instructions: ${{ needs.resolve-apm-assets.outputs.resolved-additional-instructions }} secrets: COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} BUILDKITE_API_TOKEN: ${{ secrets.BUILDKITE_API_TOKEN }} diff --git a/.github/workflows/oblt-aw-issue-fixer.yml b/.github/workflows/oblt-aw-issue-fixer.yml index 863b14d..48dffd8 100644 --- a/.github/workflows/oblt-aw-issue-fixer.yml +++ b/.github/workflows/oblt-aw-issue-fixer.yml @@ -14,14 +14,8 @@ jobs: with: control-plane-workflow: oblt-aw-issue-fixer.yml - run: + resolve-apm-assets: needs: prelude - permissions: - actions: read - contents: write - discussions: write - issues: write - pull-requests: write if: >- needs.prelude.outputs.proceed == 'true' && github.event.issue.pull_request == null && @@ -29,9 +23,10 @@ jobs: contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) && !contains(join(github.event.issue.labels.*.name, ','), 'oblt-aw/triage/security-') && !contains(join(github.event.issue.labels.*.name, ','), 'oblt-aw/triage/res-not-accessible-by-integration') - uses: elastic/ai-github-actions/.github/workflows/gh-aw-issue-fixer.lock.yml@main + uses: ./.github/workflows/aw-resolve-apm-assets.yml with: - additional-instructions: | + control-plane-workflow: oblt-aw-issue-fixer.yml + platform-additional-instructions: | Your task is to fix issues requested through `/ai implement` comments. **Execution Preconditions:** @@ -66,6 +61,25 @@ jobs: **Merge Policy:** - Do not merge automatically. - Do not perform merge operations in this workflow. + + run: + needs: [prelude, resolve-apm-assets] + permissions: + actions: read + contents: write + discussions: write + issues: write + pull-requests: write + if: >- + needs.prelude.outputs.proceed == 'true' && + github.event.issue.pull_request == null && + startsWith(github.event.comment.body, '/ai implement') && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) && + !contains(join(github.event.issue.labels.*.name, ','), 'oblt-aw/triage/security-') && + !contains(join(github.event.issue.labels.*.name, ','), 'oblt-aw/triage/res-not-accessible-by-integration') + uses: elastic/ai-github-actions/.github/workflows/gh-aw-issue-fixer.lock.yml@main + with: + additional-instructions: ${{ needs.resolve-apm-assets.outputs.resolved-additional-instructions }} secrets: inherit request-reviewers: diff --git a/.github/workflows/oblt-aw-issue-triage.yml b/.github/workflows/oblt-aw-issue-triage.yml index f6d5ecc..613e7ee 100644 --- a/.github/workflows/oblt-aw-issue-triage.yml +++ b/.github/workflows/oblt-aw-issue-triage.yml @@ -18,8 +18,18 @@ jobs: with: control-plane-workflow: oblt-aw-issue-triage.yml - issue-triage: + resolve-apm-assets: needs: prelude + if: >- + needs.prelude.outputs.proceed == 'true' && + github.event_name == 'issues' && + github.event.action == 'opened' + uses: ./.github/workflows/aw-resolve-apm-assets.yml + with: + control-plane-workflow: oblt-aw-issue-triage.yml + + issue-triage: + needs: [prelude, resolve-apm-assets] permissions: actions: read contents: read @@ -31,5 +41,7 @@ jobs: github.event_name == 'issues' && github.event.action == 'opened' uses: elastic/ai-github-actions/.github/workflows/gh-aw-issue-triage.lock.yml@main + with: + additional-instructions: ${{ needs.resolve-apm-assets.outputs.resolved-additional-instructions }} secrets: COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} diff --git a/.github/workflows/oblt-aw-mention-in-issue.yml b/.github/workflows/oblt-aw-mention-in-issue.yml index 35be983..aa9cb82 100644 --- a/.github/workflows/oblt-aw-mention-in-issue.yml +++ b/.github/workflows/oblt-aw-mention-in-issue.yml @@ -18,8 +18,22 @@ jobs: with: control-plane-workflow: oblt-aw-mention-in-issue.yml - mention-in-issue: + resolve-apm-assets: needs: prelude + if: >- + needs.prelude.outputs.proceed == 'true' && + github.event_name == 'issue_comment' && + github.event.action == 'created' && + github.event.issue.pull_request == null && + startsWith(github.event.comment.body, '/ai') && + !startsWith(github.event.comment.body, '/ai implement') && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) + uses: ./.github/workflows/aw-resolve-apm-assets.yml + with: + control-plane-workflow: oblt-aw-mention-in-issue.yml + + mention-in-issue: + needs: [prelude, resolve-apm-assets] permissions: actions: read contents: write @@ -35,5 +49,7 @@ jobs: !startsWith(github.event.comment.body, '/ai implement') && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) uses: elastic/ai-github-actions/.github/workflows/gh-aw-mention-in-issue.lock.yml@main + with: + additional-instructions: ${{ needs.resolve-apm-assets.outputs.resolved-additional-instructions }} secrets: COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} diff --git a/.github/workflows/oblt-aw-resource-not-accessible-by-integration-detector.yml b/.github/workflows/oblt-aw-resource-not-accessible-by-integration-detector.yml index 0a07365..cf41006 100644 --- a/.github/workflows/oblt-aw-resource-not-accessible-by-integration-detector.yml +++ b/.github/workflows/oblt-aw-resource-not-accessible-by-integration-detector.yml @@ -37,24 +37,13 @@ jobs: env: GH_TOKEN: ${{ github.token }} - search: - needs: discover - permissions: - actions: read - contents: read - issues: write + resolve-apm-assets: + needs: [prelude, discover] if: needs.discover.result == 'success' && needs.discover.outputs.workflows != '[]' - strategy: - matrix: - workflow: ${{ fromJSON(needs.discover.outputs.workflows) }} - uses: elastic/ai-github-actions/.github/workflows/gh-aw-log-searching-agent.lock.yml@copilot/log-searching-agent-preflight + uses: ./.github/workflows/aw-resolve-apm-assets.yml with: - workflow: ${{ matrix.workflow }} - search-terms: "Resource not accessible by integration" - days: 1 - conclusion: "any" - title-prefix: "[oblt-aw][resource-not-accessible-by-integration]" - additional-instructions: | + control-plane-workflow: oblt-aw-resource-not-accessible-by-integration-detector.yml + platform-additional-instructions: | **Error Context:** You are analyzing preflight search results for the exact phrase "Resource not accessible by integration" in GitHub Actions workflow logs. This error typically indicates permission or token scope issues when workflows access protected resources (e.g., secrets, environments, or repository settings). @@ -93,4 +82,23 @@ jobs: - When you create an issue to report findings, you MUST add the label `oblt-aw/detector/res-not-accessible-by-integration` so ingress can route the issue to the triage workflow. **Assignment:** When creating the issue, assign it to the elastic/observablt-ci GitHub team. If the platform does not support team assignees for issues, @mention the elastic/observablt-ci team in the issue body. + + search: + needs: [discover, resolve-apm-assets] + permissions: + actions: read + contents: read + issues: write + if: needs.discover.result == 'success' && needs.discover.outputs.workflows != '[]' + strategy: + matrix: + workflow: ${{ fromJSON(needs.discover.outputs.workflows) }} + uses: elastic/ai-github-actions/.github/workflows/gh-aw-log-searching-agent.lock.yml@copilot/log-searching-agent-preflight + with: + workflow: ${{ matrix.workflow }} + search-terms: "Resource not accessible by integration" + days: 1 + conclusion: "any" + title-prefix: "[oblt-aw][resource-not-accessible-by-integration]" + additional-instructions: ${{ needs.resolve-apm-assets.outputs.resolved-additional-instructions }} secrets: inherit diff --git a/.github/workflows/oblt-aw-resource-not-accessible-by-integration-fixer.yml b/.github/workflows/oblt-aw-resource-not-accessible-by-integration-fixer.yml index 8798532..ca7a250 100644 --- a/.github/workflows/oblt-aw-resource-not-accessible-by-integration-fixer.yml +++ b/.github/workflows/oblt-aw-resource-not-accessible-by-integration-fixer.yml @@ -15,24 +15,18 @@ jobs: control-plane-workflow: oblt-aw-resource-not-accessible-by-integration-fixer.yml load-allowed-authors: true - res-not-accessible-integration-fixer: + resolve-apm-assets: needs: prelude - permissions: - actions: read - contents: write - discussions: write - issues: write - pull-requests: write if: >- needs.prelude.outputs.proceed == 'true' && github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'oblt-aw/ai/fix-ready' && contains(join(github.event.issue.labels.*.name, ','), 'oblt-aw/triage/res-not-accessible-by-integration') - uses: elastic/ai-github-actions/.github/workflows/gh-aw-issue-fixer.lock.yml@main + uses: ./.github/workflows/aw-resolve-apm-assets.yml with: - allowed-bot-users: ${{ needs.prelude.outputs.allowed-issue-authors-csv }} - additional-instructions: | + control-plane-workflow: oblt-aw-resource-not-accessible-by-integration-fixer.yml + platform-additional-instructions: | Your task is to fix issues labeled for the "Resource not accessible by integration" problem. **Execution Preconditions:** @@ -68,6 +62,25 @@ jobs: **Merge Policy:** - Do not merge automatically. - Do not perform merge operations in this workflow. + + res-not-accessible-integration-fixer: + needs: [prelude, resolve-apm-assets] + permissions: + actions: read + contents: write + discussions: write + issues: write + pull-requests: write + if: >- + needs.prelude.outputs.proceed == 'true' && + github.event_name == 'issues' && + github.event.action == 'labeled' && + github.event.label.name == 'oblt-aw/ai/fix-ready' && + contains(join(github.event.issue.labels.*.name, ','), 'oblt-aw/triage/res-not-accessible-by-integration') + uses: elastic/ai-github-actions/.github/workflows/gh-aw-issue-fixer.lock.yml@main + with: + allowed-bot-users: ${{ needs.prelude.outputs.allowed-issue-authors-csv }} + additional-instructions: ${{ needs.resolve-apm-assets.outputs.resolved-additional-instructions }} secrets: inherit request-reviewers: diff --git a/.github/workflows/oblt-aw-resource-not-accessible-by-integration-triage.yml b/.github/workflows/oblt-aw-resource-not-accessible-by-integration-triage.yml index 0c0a480..9336d05 100644 --- a/.github/workflows/oblt-aw-resource-not-accessible-by-integration-triage.yml +++ b/.github/workflows/oblt-aw-resource-not-accessible-by-integration-triage.yml @@ -20,14 +20,8 @@ jobs: control-plane-workflow: oblt-aw-resource-not-accessible-by-integration-triage.yml load-allowed-authors: true - res-not-accessible-integration-triage: + resolve-apm-assets: needs: prelude - permissions: - actions: read - contents: read - discussions: write - issues: write - pull-requests: write if: >- needs.prelude.outputs.proceed == 'true' && github.event_name == 'issues' && @@ -35,11 +29,10 @@ jobs: (github.event.action == 'opened' && contains(join(github.event.issue.labels.*.name, ','), 'oblt-aw/detector/res-not-accessible-by-integration')) || (github.event.action == 'labeled' && github.event.label.name == 'oblt-aw/detector/res-not-accessible-by-integration') ) - uses: elastic/ai-github-actions/.github/workflows/gh-aw-issue-triage.lock.yml@main + uses: ./.github/workflows/aw-resolve-apm-assets.yml with: - allowed-bot-users: ${{ needs.prelude.outputs.allowed-issue-authors-csv }} - classification-labels: "oblt-aw/triage/res-not-accessible-by-integration,oblt-aw/triage/other,oblt-aw/triage/needs-info,oblt-aw/ai/fix-ready" - additional-instructions: | + control-plane-workflow: oblt-aw-resource-not-accessible-by-integration-triage.yml + platform-additional-instructions: | Your task is to triage issues that carry the detector label `oblt-aw/detector/res-not-accessible-by-integration` and determine if they are related to "Resource not accessible by integration" errors in GitHub Actions workflows. **Strict No-Output Rule (MUST be enforced first):** @@ -223,6 +216,27 @@ jobs: - [GitHub Actions Permissions](https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token) - [Workflow Permission Syntax](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions) - [Troubleshooting Permissions](https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token) + + res-not-accessible-integration-triage: + needs: [prelude, resolve-apm-assets] + permissions: + actions: read + contents: read + discussions: write + issues: write + pull-requests: write + if: >- + needs.prelude.outputs.proceed == 'true' && + github.event_name == 'issues' && + ( + (github.event.action == 'opened' && contains(join(github.event.issue.labels.*.name, ','), 'oblt-aw/detector/res-not-accessible-by-integration')) || + (github.event.action == 'labeled' && github.event.label.name == 'oblt-aw/detector/res-not-accessible-by-integration') + ) + uses: elastic/ai-github-actions/.github/workflows/gh-aw-issue-triage.lock.yml@main + with: + allowed-bot-users: ${{ needs.prelude.outputs.allowed-issue-authors-csv }} + classification-labels: "oblt-aw/triage/res-not-accessible-by-integration,oblt-aw/triage/other,oblt-aw/triage/needs-info,oblt-aw/ai/fix-ready" + additional-instructions: ${{ needs.resolve-apm-assets.outputs.resolved-additional-instructions }} secrets: COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} diff --git a/.github/workflows/oblt-aw-security-fixer.yml b/.github/workflows/oblt-aw-security-fixer.yml index e90bdc3..04f00cb 100644 --- a/.github/workflows/oblt-aw-security-fixer.yml +++ b/.github/workflows/oblt-aw-security-fixer.yml @@ -18,14 +18,8 @@ jobs: control-plane-workflow: oblt-aw-security-fixer.yml load-allowed-authors: true - security-issue-fixer: + resolve-apm-assets: needs: prelude - permissions: - actions: read - contents: write - discussions: write - issues: write - pull-requests: write if: >- needs.prelude.outputs.proceed == 'true' && github.event_name == 'issues' && @@ -34,10 +28,10 @@ jobs: (github.event.label.name == 'oblt-aw/ai/fix-ready' && contains(join(github.event.issue.labels.*.name, ','), 'oblt-aw/triage/security-')) || (startsWith(github.event.label.name, 'oblt-aw/triage/security-') && contains(join(github.event.issue.labels.*.name, ','), 'oblt-aw/ai/fix-ready')) ) - uses: elastic/ai-github-actions/.github/workflows/gh-aw-issue-fixer.lock.yml@main + uses: ./.github/workflows/aw-resolve-apm-assets.yml with: - allowed-bot-users: ${{ needs.prelude.outputs.allowed-issue-authors-csv }} - additional-instructions: | + control-plane-workflow: oblt-aw-security-fixer.yml + platform-additional-instructions: | Your task is to fix security issues labeled for remediation. This workflow uses agentic workflows from elastic/ai-github-actions. **Execution Preconditions:** @@ -74,6 +68,27 @@ jobs: **Merge Policy:** - Do not merge automatically. - Do not perform merge operations in this workflow. + + security-issue-fixer: + needs: [prelude, resolve-apm-assets] + permissions: + actions: read + contents: write + discussions: write + issues: write + pull-requests: write + if: >- + needs.prelude.outputs.proceed == 'true' && + github.event_name == 'issues' && + github.event.action == 'labeled' && + ( + (github.event.label.name == 'oblt-aw/ai/fix-ready' && contains(join(github.event.issue.labels.*.name, ','), 'oblt-aw/triage/security-')) || + (startsWith(github.event.label.name, 'oblt-aw/triage/security-') && contains(join(github.event.issue.labels.*.name, ','), 'oblt-aw/ai/fix-ready')) + ) + uses: elastic/ai-github-actions/.github/workflows/gh-aw-issue-fixer.lock.yml@main + with: + allowed-bot-users: ${{ needs.prelude.outputs.allowed-issue-authors-csv }} + additional-instructions: ${{ needs.resolve-apm-assets.outputs.resolved-additional-instructions }} secrets: inherit request-reviewers: diff --git a/.github/workflows/oblt-aw-security-triage.yml b/.github/workflows/oblt-aw-security-triage.yml index 0836eae..beef48b 100644 --- a/.github/workflows/oblt-aw-security-triage.yml +++ b/.github/workflows/oblt-aw-security-triage.yml @@ -19,14 +19,8 @@ jobs: control-plane-workflow: oblt-aw-security-triage.yml load-allowed-authors: true - security-issue-triage: + resolve-apm-assets: needs: prelude - permissions: - actions: read - contents: read - discussions: write - issues: write - pull-requests: write if: >- needs.prelude.outputs.proceed == 'true' && github.event_name == 'issues' && @@ -34,11 +28,10 @@ jobs: (github.event.action == 'opened' && contains(join(github.event.issue.labels.*.name, ','), 'oblt-aw/detector/security')) || (github.event.action == 'labeled' && github.event.label.name == 'oblt-aw/detector/security') ) - uses: elastic/ai-github-actions/.github/workflows/gh-aw-issue-triage.lock.yml@main + uses: ./.github/workflows/aw-resolve-apm-assets.yml with: - allowed-bot-users: ${{ needs.prelude.outputs.allowed-issue-authors-csv }} - classification-labels: "oblt-aw/triage/security-injection,oblt-aw/triage/security-secrets,oblt-aw/triage/security-supply-chain,oblt-aw/triage/security-least-privilege,oblt-aw/triage/other,oblt-aw/triage/needs-info,oblt-aw/ai/fix-ready" - additional-instructions: | + control-plane-workflow: oblt-aw-security-triage.yml + platform-additional-instructions: | Your task is to triage newly opened security-related issues in this repository. Security issues include vulnerabilities in GitHub Actions workflows and shell scripts: injection (expression, command, YAML), secret management (token exposure via CLI args, secrets in command strings), supply chain (action pinning, checksums), and least privilege (excessive permissions). **Strict No-Output Rule (MUST be enforced first):** @@ -112,6 +105,27 @@ jobs: ### Additional Resources - [GitHub Actions Security Hardening](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions) - [Workflow Permission Syntax](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions) + + security-issue-triage: + needs: [prelude, resolve-apm-assets] + permissions: + actions: read + contents: read + discussions: write + issues: write + pull-requests: write + if: >- + needs.prelude.outputs.proceed == 'true' && + github.event_name == 'issues' && + ( + (github.event.action == 'opened' && contains(join(github.event.issue.labels.*.name, ','), 'oblt-aw/detector/security')) || + (github.event.action == 'labeled' && github.event.label.name == 'oblt-aw/detector/security') + ) + uses: elastic/ai-github-actions/.github/workflows/gh-aw-issue-triage.lock.yml@main + with: + allowed-bot-users: ${{ needs.prelude.outputs.allowed-issue-authors-csv }} + classification-labels: "oblt-aw/triage/security-injection,oblt-aw/triage/security-secrets,oblt-aw/triage/security-supply-chain,oblt-aw/triage/security-least-privilege,oblt-aw/triage/other,oblt-aw/triage/needs-info,oblt-aw/ai/fix-ready" + additional-instructions: ${{ needs.resolve-apm-assets.outputs.resolved-additional-instructions }} secrets: COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} diff --git a/.github/workflows/sync-control-plane-dashboard.yml b/.github/workflows/sync-control-plane-dashboard.yml index 9132c27..402df40 100644 --- a/.github/workflows/sync-control-plane-dashboard.yml +++ b/.github/workflows/sync-control-plane-dashboard.yml @@ -33,6 +33,8 @@ jobs: uses: actions/setup-python@v6 with: python-version: "3.14" + cache: pip + cache-dependency-path: requirements-runtime.txt - name: Build repos matrix id: matrix @@ -74,6 +76,8 @@ jobs: uses: actions/setup-python@v6 with: python-version: "3.14" + cache: pip + cache-dependency-path: requirements-runtime.txt - name: Sync dashboard for ${{ matrix.repository }} env: diff --git a/AGENTS.md b/AGENTS.md index c840c91..e1dea6e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,6 @@ Do not reintroduce a monolithic `oblt-aw.yml` or `oblt-aw-ingress.yml`. ## Control-plane workflow naming - Shared prelude: `.github/workflows/aw-prelude.yml` (no org prefix). -- Observability reusables: `.github/workflows/oblt-aw-.yml` (each must call `aw-prelude` first; enforced by `scripts/validate_aw_workflow_prelude.py` in CI). +- Observability reusables: `.github/workflows/oblt-aw-.yml` (each must call `aw-prelude` first; enforced by `scripts/validate_aw_workflow_prelude.py` in CI). Workflows that invoke `gh-aw-*` must also call `aw-resolve-apm-assets.yml` per agent job (`scripts/validate_aw_workflow_resolve_apm_assets.py`). - Docs reusables: `.github/workflows/docs-aw-*.yml` (same prelude requirement). - Upstream lock files in `elastic/ai-github-actions` / `elastic/docs-actions` keep the `gh-aw-*` prefix. diff --git a/config/schema/apm-agentic-workflows.schema.json b/config/schema/apm-agentic-workflows.schema.json new file mode 100644 index 0000000..69d3605 --- /dev/null +++ b/config/schema/apm-agentic-workflows.schema.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/elastic/oblt-aw/config/schema/apm-agentic-workflows.schema.json", + "title": "OBLT AW agentic assets (apm.yml extension)", + "description": "Extension block x-oblt-aw in consumer apm.yml. Each org key (e.g. obs, docs) owns common and workflows; workflow ids match config//workflow-registry.json.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/orgBlock" + }, + "required": ["version"], + "properties": { + "version": { + "type": "integer", + "const": 1, + "description": "Schema version for x-oblt-aw." + } + }, + "$defs": { + "orgBlock": { + "type": "object", + "additionalProperties": false, + "required": ["common"], + "properties": { + "common": { + "$ref": "#/$defs/assetBlock", + "description": "Assets shared by all agentic workflows for this org when no workflow-specific block exists." + }, + "workflows": { + "type": "object", + "description": "Per-workflow assets for this org. When a workflow id is present, common is ignored entirely (override).", + "additionalProperties": { + "$ref": "#/$defs/assetBlock" + }, + "propertyNames": { + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + } + } + } + }, + "assetBlock": { + "type": "object", + "additionalProperties": false, + "properties": { + "setup-commands": { + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1 + } + ], + "description": "Inline shell commands (string or list). Multiline strings are split into one command per non-empty, non-comment line. List entries may be script paths or arbitrary shell." + }, + "setup-commands-file": { + "type": "string", + "minLength": 1, + "description": "Repo-relative path to a UTF-8 file with one shell command per line (same line splitting as setup-commands)." + }, + "inputs": { + "type": "object", + "description": "Overrides for agentic workflow inputs (kebab-case keys). Values may be scalars or *-file paths.", + "additionalProperties": true + } + } + } + } +} diff --git a/docs/architecture/apm-agentic-assets.md b/docs/architecture/apm-agentic-assets.md new file mode 100644 index 0000000..9658ea8 --- /dev/null +++ b/docs/architecture/apm-agentic-assets.md @@ -0,0 +1,112 @@ +# APM agentic assets (consumer repositories) + +Consumer repositories can declare **shared** and **per-workflow** agentic assets in [`apm.yml`](https://github.com/microsoft/apm) using the `x-oblt-aw` extension. The control plane resolves those assets in [`aw-resolve-apm-assets.yml`](../../.github/workflows/aw-resolve-apm-assets.yml) immediately before each upstream `gh-aw-*` invocation (not in [`aw-prelude.yml`](../../.github/workflows/aw-prelude.yml)). + +## Workflow identifiers + +Keys under `x-oblt-aw..workflows` must match the `id` field in that org’s [`workflow-registry.json`](../../config/obs/workflow-registry.json) (for example `agent-suggestions` under `obs`, `docs-pr-ai-menu` under `docs`). Ingress and dashboard gating continue to use compound ids `org-key:workflow-id` (for example `obs:agent-suggestions`). + +## Structure + +`x-oblt-aw` is nested by **org key** (same names as `config//` in the control plane, e.g. `obs`, `docs`): + +| Path | Requirement | Behavior | +|------|-------------|----------| +| `version` | Required | Schema version (`1`) | +| `` | Per org that configures assets | Container for that product line | +| `.common` | **Required** for each org block | Shared assets when no workflow override exists | +| `.workflows.` | Optional | **Override:** when present, `common` is ignored entirely for that run | + +Each asset block (`common` or `workflows.`) may include: + +| Field | Form | Behavior | +|-------|------|----------| +| `setup-commands` | Inline string or list of strings | Shell run before the agentic engine. Use a **list** for separate steps, a **single string** for one command, or a **multiline block** (`\|`) for several inline commands (one non-empty, non-`#` line per command). Entries may be repo script paths (for example `./scripts/bootstrap.sh`) or arbitrary inline shell (for example `npm ci`). | +| `setup-commands-file` | Repo-relative path | Optional. UTF-8 file with one command per line; appended after `setup-commands`. Same line rules as multiline inline text. | +| `inputs` | Mapping | Agentic workflow input overrides; `*-file` keys load repo file contents (see manifest example). | + +A repository in multiple org fleets may define separate `obs` and `docs` blocks with different `common` guidance. + +## Precedence (per run) + +| Layer | Behavior | +|-------|----------| +| **Platform** (`platform-additional-instructions` / `platform-inputs-json` on `resolve-apm-assets`) | Control-plane baseline for that agent invocation; always applied first for instructions (prepended). Input keys can be overridden by APM per key. | +| **`x-oblt-aw..common`** | Used when no `workflows.` entry exists for the running workflow in that org. | +| **`x-oblt-aw..workflows.`** | **Override:** when this key exists, that org’s `common` is ignored entirely for that run. | + +There is no merge between `common` and `workflows.` (no field-level fallback from common when a workflow block is present). + +If `x-oblt-aw` exists but the running org key is not configured, resolution returns platform-only assets (`asset-source: none`). + +## Manifest example + +```yaml +name: my-service +version: 1.0.0 + +dependencies: + apm: + - microsoft/apm-sample-package#v1.0.0 + +x-oblt-aw: + version: 1 + obs: + common: + setup-commands: + - ./scripts/ai-bootstrap.sh + - npm ci --ignore-scripts + inputs: + additional-instructions: | + Repository-wide agent guidance for observability agentic workflows. + workflows: + agent-suggestions: + setup-commands: | + export AGENT_CONTEXT=agent-suggestions + ./scripts/validate-agent-env.sh + inputs: + additional-instructions: | + Overrides obs.common entirely for agent-suggestions only. + autodoc: + inputs: + lookback-window: 3 days ago + additional-instructions-file: .github/ai/autodoc-extra.md + docs: + common: + inputs: + additional-instructions: | + Shared guidance for documentation agentic workflows. + workflows: + docs-pr-ai-menu: + inputs: + additional-instructions: | + Overrides docs.common for the PR AI menu workflow. +``` + +## Runtime behavior + +When the dashboard gate passes (`proceed == true`), each agent job’s preceding `resolve-apm-assets` call: + +1. Checks out the **consumer** repository (caller context). +2. Installs [`requirements-runtime.txt`](../../requirements-runtime.txt) with pip cache via `actions/setup-python`. +3. Runs [`apm install`](https://microsoft.github.io/apm/) when `apm.yml` is present (installs declared skills, plugins, MCP servers, and other APM dependencies). +4. Runs [`scripts/resolve_apm_agentic_assets.py`](../../scripts/resolve_apm_agentic_assets.py) with the compound workflow id (`org-key:workflow-id`) to select the org block and produce: + - `resolved-additional-instructions` + - `resolved-inputs-json` (merged platform + APM inputs) + - `resolved-setup-commands-json` + +Downstream `gh-aw-*` jobs should pass `additional-instructions: ${{ needs..outputs.resolved-additional-instructions }}` and may read other keys from `resolved-inputs-json` when needed. Use one resolve job per agent invocation when platform prompts differ (see `oblt-aw-autodoc.yml`). + +## Schema + +`x-oblt-aw` is a vendor extension on consumer `apm.yml`. APM preserves unknown top-level keys per the [APM manifest schema](https://microsoft.github.io/apm/reference/manifest-schema/). + +JSON Schema for this extension block: [`config/schema/apm-agentic-workflows.schema.json`](../../config/schema/apm-agentic-workflows.schema.json). + +## References + +- [APM (Agent Package Manager)](https://github.com/microsoft/apm) +- [APM manifest schema](https://microsoft.github.io/apm/reference/manifest-schema/) — official `apm.yml` format and vendor extension fields +- [Multi-org agentic workflows](./multi-org-agentic-workflows.md) +- [Resolve APM agentic assets](../../.github/workflows/aw-resolve-apm-assets.yml) +- [Agentic Workflow Prelude](../../.github/workflows/aw-prelude.yml) diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index a3519b6..ba54634 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -2,11 +2,11 @@ ## Overview -`oblt-aw` exposes reusable `oblt-aw-*` workflows. Each consumer installs one or more **`trigger-oblt-aw-*.yml`** client templates (narrow `on:` triggers) that call the matching control-plane workflow. Shared dashboard gating runs in [aw-prelude](../../.github/workflows/aw-prelude.yml) before agent-specific jobs. +`oblt-aw` exposes reusable `oblt-aw-*` workflows. Each consumer installs one or more **`trigger-oblt-aw-*.yml`** client templates (narrow `on:` triggers) that call the matching control-plane workflow. Shared dashboard gating and optional [APM agentic assets](./apm-agentic-assets.md) resolution run in [aw-prelude](../../.github/workflows/aw-prelude.yml) before agent-specific jobs. Platform workflows: -- [.github/workflows/aw-prelude.yml](../../.github/workflows/aw-prelude.yml) (dashboard + allow lists) +- [.github/workflows/aw-prelude.yml](../../.github/workflows/aw-prelude.yml) (dashboard, allow lists, APM asset resolution) - [.github/workflows/get-enabled-workflows.yml](../../.github/workflows/get-enabled-workflows.yml) (dashboard read; used by prelude) Specialized workflows: diff --git a/docs/development/contributing.md b/docs/development/contributing.md index e47f756..5ff13f3 100644 --- a/docs/development/contributing.md +++ b/docs/development/contributing.md @@ -30,7 +30,7 @@ This installs the hooks from [.pre-commit-config.yaml](../../.pre-commit-config. ### 3. Install Python dependencies ```bash -pip install -r requirements-ci.txt +pip install -r requirements-ci.txt # includes requirements-runtime.txt (PyYAML) and pytest ``` ### 4. Install Node.js dependencies diff --git a/docs/examples/consumer-apm.yml.example b/docs/examples/consumer-apm.yml.example new file mode 100644 index 0000000..06c6814 --- /dev/null +++ b/docs/examples/consumer-apm.yml.example @@ -0,0 +1,32 @@ +# Example consumer apm.yml (copy to repository root as apm.yml) +name: my-repository +version: 1.0.0 + +dependencies: + apm: [] + mcp: [] + +x-oblt-aw: + version: 1 + # Org keys match config// in elastic/oblt-aw (e.g. obs, docs). + # Each org block must define common; workflows. overrides common entirely. + obs: + common: + # Script paths and inline shell are both valid; use a list, a string, or a multiline block. + setup-commands: + - ./scripts/bootstrap-agent-context.sh + - pip install -r requirements-dev.txt + inputs: + additional-instructions: | + Shared guidance for every observability agentic workflow in this repository. + workflows: + # Registry id from config/obs/workflow-registry.json — overrides obs.common. + agent-suggestions: + inputs: + additional-instructions: | + Additional agent-suggestions-only instructions. + docs: + common: + inputs: + additional-instructions: | + Shared guidance for documentation agentic workflows (when this repo is in docs fleet). diff --git a/docs/workflows/aw-prelude.md b/docs/workflows/aw-prelude.md index 7cb6399..0cc2395 100644 --- a/docs/workflows/aw-prelude.md +++ b/docs/workflows/aw-prelude.md @@ -8,6 +8,8 @@ Shared reusable prelude for agentic workflows (dashboard gating and optional all Every control-plane `*-aw-*` wrapper (`oblt-aw-*`, `docs-aw-*`) invokes this prelude as its first job before running agent-specific steps. CI enforces this via [scripts/validate_aw_workflow_prelude.py](../../scripts/validate_aw_workflow_prelude.py). +APM asset resolution (`apm install`, `apm.yml` merge) is **not** part of the prelude. Call [aw-resolve-apm-assets.yml](aw-resolve-apm-assets.md) once per `gh-aw-*` agent invocation instead. + ## Contract ### Inputs @@ -38,4 +40,5 @@ Same as ingress historically used: - [get-enabled-workflows.md](get-enabled-workflows.md) - [load-allowed-authors.md](load-allowed-authors.md) +- [aw-resolve-apm-assets.md](aw-resolve-apm-assets.md) - [oblt-aw-client-template.md](oblt-aw-client-template.md) diff --git a/docs/workflows/aw-resolve-apm-assets.md b/docs/workflows/aw-resolve-apm-assets.md new file mode 100644 index 0000000..14298d9 --- /dev/null +++ b/docs/workflows/aw-resolve-apm-assets.md @@ -0,0 +1,76 @@ +# Workflow: `aw-resolve-apm-assets.yml` + +## Overview + +Source file: [.github/workflows/aw-resolve-apm-assets.yml](../../.github/workflows/aw-resolve-apm-assets.yml) + +Resolves consumer [`apm.yml`](https://github.com/microsoft/apm) agentic assets for **one** `gh-aw-*` invocation. Call this reusable immediately before each job that `uses` an upstream agentic workflow lock file. + +CI enforces the contract via [scripts/validate_aw_workflow_resolve_apm_assets.py](../../scripts/validate_aw_workflow_resolve_apm_assets.py): every local `*-aw-*` workflow with at least one `gh-aw-*` call must invoke `aw-resolve-apm-assets.yml` at least once per agent job (for example `oblt-aw-autodoc.yml` uses two resolve jobs for audit and fix). + +Wrappers that only gate or run scripts (for example `oblt-aw-security-detector.yml`) do not call this workflow. + +## Contract + +### Inputs + +| Input | Type | Default | Purpose | +|-------|------|---------|---------| +| `control-plane-workflow` | string | (required) | Basename of the calling wrapper; used to resolve org key and registry workflow id for `x-oblt-aw..workflows.` selection | +| `platform-additional-instructions` | string | `""` | Control-plane baseline text for this agent invocation (prepended before repo APM instructions) | +| `platform-inputs-json` | string | `"{}"` | JSON object of platform inputs; APM `inputs` override per key | +| `install-apm-packages` | boolean | `true` | Run `apm install` when `apm.yml` is present | + +### Outputs + +| Output | Description | +|--------|-------------| +| `apm-manifest-present` | Consumer has `apm.yml` / `apm.yaml` | +| `apm-extension-present` | Manifest contains `x-oblt-aw` | +| `asset-source` | `none`, `common`, or `workflow` | +| `resolved-additional-instructions` | Merged platform + APM instructions | +| `resolved-inputs-json` | Merged platform + APM inputs | +| `resolved-setup-commands-json` | JSON array of shell commands from the selected asset block (`setup-commands` inline string/list and optional `setup-commands-file`) | + +### Typical caller pattern + +Place each `resolve-apm-assets` job **immediately before** the `gh-aw-*` job it feeds, after any prerequisite gates (verify, discover, evaluate-trigger, etc.). Use the **same `if` expression** on resolve and agent so APM install/resolution runs only when the agent will. + +```yaml +jobs: + prelude: + uses: ./.github/workflows/aw-prelude.yml + with: + control-plane-workflow: oblt-aw-example.yml + + # ... optional intermediate jobs (verify, discover, menu scripts, etc.) ... + + resolve-apm-assets: + needs: [prelude] # plus any jobs the agent also needs + if: >- + needs.prelude.outputs.proceed == 'true' && + + uses: ./.github/workflows/aw-resolve-apm-assets.yml + with: + control-plane-workflow: oblt-aw-example.yml + platform-additional-instructions: | + Platform prompt for this agent invocation. + + agent: + needs: [prelude, resolve-apm-assets] # resolve must be a direct dependency + if: >- + needs.prelude.outputs.proceed == 'true' && + + uses: elastic/ai-github-actions/.github/workflows/gh-aw-example.lock.yml@main + with: + additional-instructions: ${{ needs.resolve-apm-assets.outputs.resolved-additional-instructions }} +``` + +Examples with upstream gates: [oblt-aw-automerge.yml](../../.github/workflows/oblt-aw-automerge.yml) (resolve after verify + dependency collection), [oblt-aw-resource-not-accessible-by-integration-detector.yml](../../.github/workflows/oblt-aw-resource-not-accessible-by-integration-detector.yml) (resolve after discover). Multi-agent wrappers use one resolve job per `gh-aw-*` invocation — see [oblt-aw-autodoc.yml](../../.github/workflows/oblt-aw-autodoc.yml). + +## References + +- [APM manifest schema](https://microsoft.github.io/apm/reference/manifest-schema/) — official `apm.yml` format and vendor extension fields +- [APM agentic assets architecture](../architecture/apm-agentic-assets.md) +- [Agentic Workflow Prelude](aw-prelude.md) +- [scripts/resolve_apm_agentic_assets.py](../../scripts/resolve_apm_agentic_assets.py) diff --git a/docs/workflows/ci.md b/docs/workflows/ci.md index 56e06bb..e90e1be 100644 --- a/docs/workflows/ci.md +++ b/docs/workflows/ci.md @@ -37,9 +37,9 @@ On PRs, pre-commit runs only on changed files (`--from-ref` / `--to-ref`). ## Python Tests - Python 3.14 -- Dependencies: `requirements-ci.txt` (pytest 9.0.2) +- Dependencies: `requirements-ci.txt` (includes `requirements-runtime.txt` and pytest) - Command: `pytest tests/ -v --tb=short` -- Pip cache via `actions/setup-python` (`cache: pip`), keyed by `requirements-ci.txt` +- Pip cache via `actions/setup-python` (`cache: pip`), keyed by `requirements-ci.txt` and `requirements-runtime.txt` ## TypeScript Tests diff --git a/requirements-ci.txt b/requirements-ci.txt index 5b240e0..8b29e8e 100644 --- a/requirements-ci.txt +++ b/requirements-ci.txt @@ -1 +1,2 @@ +-r requirements-runtime.txt pytest==9.0.2 diff --git a/requirements-runtime.txt b/requirements-runtime.txt new file mode 100644 index 0000000..5a96a76 --- /dev/null +++ b/requirements-runtime.txt @@ -0,0 +1,2 @@ +# Python dependencies for control-plane scripts invoked from GitHub Actions workflows. +PyYAML==6.0.2 diff --git a/scripts/apm_agentic_assets.py b/scripts/apm_agentic_assets.py new file mode 100644 index 0000000..a06c973 --- /dev/null +++ b/scripts/apm_agentic_assets.py @@ -0,0 +1,391 @@ +#!/usr/bin/env python3 +# Copyright 2026-2027 Elasticsearch B.V. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +""" +Resolve agentic-workflow assets from a consumer repository ``apm.yml``. + +Extension block: ``x-oblt-aw`` (see ``config/schema/apm-agentic-workflows.schema.json``). + +Structure (non-negotiable): +- Top-level ``version`` plus one mapping per org key (e.g. ``obs``, ``docs``). +- Each org block **must** include ``common`` and may include ``workflows``. + +Precedence within the org block for the running ``org-key``: +- If ``workflows.`` is present, use that block only (ignore ``common``). +- Otherwise use ``common``. +- Platform / control-plane inputs are merged per-key on top: APM ``inputs`` override + platform keys; ``additional-instructions`` from APM are appended after platform text. + +``setup-commands`` accepts inline shell (string, list, or multiline block) and optional +``setup-commands-file`` (one command per line). Entries may be script paths or shell. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import yaml # type: ignore[import-untyped] + +OBLT_AW_EXTENSION_KEY = "x-oblt-aw" +APM_MANIFEST_NAMES = ("apm.yml", "apm.yaml") +# Keys whose values are repo-relative file paths to load as UTF-8 text. +FILE_INPUT_SUFFIX = "-file" + +KNOWN_REGISTRY_IDS: dict[str, frozenset[str]] | None = None + + +def parse_compound_workflow_id(compound: str) -> tuple[str, str]: + """Split ``org-key:workflow-id`` into components.""" + compound = compound.strip() + if ":" not in compound: + raise ValueError( + f"enabled-workflow-id must be org-key:workflow-id, got {compound!r}" + ) + org_key, workflow_id = compound.split(":", 1) + if not org_key or not workflow_id: + raise ValueError( + f"enabled-workflow-id must be org-key:workflow-id, got {compound!r}" + ) + return org_key, workflow_id + + +def load_apm_manifest(repo_root: Path) -> tuple[dict[str, Any] | None, bool]: + """Return (parsed manifest, manifest_file_present).""" + for name in APM_MANIFEST_NAMES: + path = repo_root / name + if path.is_file(): + data = yaml.safe_load(path.read_text(encoding="utf-8")) + if data is None: + return {}, True + if not isinstance(data, dict): + raise ValueError(f"{name} must be a YAML mapping at the top level") + return data, True + return None, False + + +def load_registry_workflow_ids(config_dir: Path, org_key: str) -> frozenset[str]: + """Load workflow ids from ``config//workflow-registry.json``.""" + global KNOWN_REGISTRY_IDS + if KNOWN_REGISTRY_IDS is None: + KNOWN_REGISTRY_IDS = {} + + cache_key = f"{config_dir.resolve()}:{org_key}" + cached = KNOWN_REGISTRY_IDS.get(cache_key) + if cached is not None: + return cached + + path = config_dir / org_key / "workflow-registry.json" + if not path.is_file(): + known: frozenset[str] = frozenset() + else: + data = json.loads(path.read_text(encoding="utf-8")) + workflows = data.get("workflows", []) + if not isinstance(workflows, list): + known = frozenset() + else: + ids: set[str] = set() + for entry in workflows: + if isinstance(entry, dict) and isinstance(entry.get("id"), str): + ids.add(entry["id"]) + known = frozenset(ids) + + KNOWN_REGISTRY_IDS[cache_key] = known + return known + + +def validate_workflow_id( + workflow_id: str, + org_key: str, + *, + config_dir: Path | None, +) -> None: + if config_dir is None: + return + known = load_registry_workflow_ids(config_dir, org_key) + if known and workflow_id not in known: + raise ValueError( + f"workflow-id {workflow_id!r} is not listed in " + f"{config_dir / org_key / 'workflow-registry.json'}" + ) + + +def reject_legacy_flat_extension(extension: dict[str, Any]) -> None: + """Reject pre–multi-org flat ``common`` / ``workflows`` at the extension root.""" + if "common" in extension or "workflows" in extension: + raise ValueError( + f"{OBLT_AW_EXTENSION_KEY} must nest assets under org keys (e.g. obs, docs); " + "top-level common/workflows are no longer supported" + ) + + +def extract_org_extension( + extension: dict[str, Any], org_key: str +) -> dict[str, Any] | None: + """Return the org-scoped block (``common`` + optional ``workflows``) or None.""" + reject_legacy_flat_extension(extension) + org_block = extension.get(org_key) + if org_block is None: + return None + if not isinstance(org_block, dict): + raise ValueError( + f"{OBLT_AW_EXTENSION_KEY}.{org_key} must be a mapping, " + f"got {type(org_block).__name__}" + ) + return org_block + + +def select_asset_block( + org_extension: dict[str, Any], workflow_id: str, *, org_key: str +) -> dict[str, Any] | None: + """ + Pick common or workflow-specific assets for one org. + + Workflow block wins entirely when the key exists (override semantics). + """ + prefix = f"{OBLT_AW_EXTENSION_KEY}.{org_key}" + workflows = org_extension.get("workflows") + if isinstance(workflows, dict) and workflow_id in workflows: + block = workflows[workflow_id] + if block is None: + return {} + if not isinstance(block, dict): + raise ValueError( + f"{prefix}.workflows.{workflow_id} must be a mapping, " + f"got {type(block).__name__}" + ) + return block + + common = org_extension.get("common") + if common is None: + return None + if not isinstance(common, dict): + raise ValueError(f"{prefix}.common must be a mapping") + return common + + +def read_file_input(repo_root: Path, relative_path: str) -> str: + path = (repo_root / relative_path).resolve() + root = repo_root.resolve() + if root not in path.parents and path != root: + raise ValueError(f"Refusing path outside repository: {relative_path}") + if not path.is_file(): + raise FileNotFoundError(f"Asset file not found: {relative_path}") + return path.read_text(encoding="utf-8") + + +def materialize_inputs( + repo_root: Path, + raw_inputs: Any, +) -> dict[str, Any]: + if raw_inputs is None: + return {} + if not isinstance(raw_inputs, dict): + raise ValueError("inputs must be a mapping of workflow input names to values") + + out: dict[str, Any] = {} + for key, value in raw_inputs.items(): + if not isinstance(key, str): + raise ValueError("input keys must be strings") + if key.endswith(FILE_INPUT_SUFFIX) and isinstance(value, str): + text_key = key[: -len(FILE_INPUT_SUFFIX)] + out[text_key] = read_file_input(repo_root, value) + else: + out[key] = value + return out + + +def _expand_setup_command_lines(text: str) -> list[str]: + """Split multiline inline setup text into individual shell commands.""" + lines: list[str] = [] + for line in text.splitlines(): + stripped = line.strip() + if stripped and not stripped.startswith("#"): + lines.append(stripped) + return lines + + +def _normalize_setup_commands_value(value: Any, *, field: str) -> list[str]: + if value is None: + return [] + if isinstance(value, str): + if not value.strip(): + raise ValueError(f"{field} must be a non-empty string") + return _expand_setup_command_lines(value) + if isinstance(value, list): + normalized: list[str] = [] + for item in value: + if not isinstance(item, str) or not item.strip(): + raise ValueError(f"{field} entries must be non-empty strings") + normalized.extend(_expand_setup_command_lines(item)) + return normalized + raise ValueError(f"{field} must be a string or a list of strings") + + +def extract_setup_commands(block: dict[str, Any], *, repo_root: Path) -> list[str]: + """ + Resolve setup shell commands from an asset block. + + Supports: + - ``setup-commands``: inline shell (string or list of strings; multiline strings + split into one command per non-empty, non-comment line) + - ``setup-commands-file``: repo-relative path to a file with one command per line + """ + commands = _normalize_setup_commands_value( + block.get("setup-commands"), field="setup-commands" + ) + + commands_file = block.get("setup-commands-file") + if commands_file is None: + return commands + if not isinstance(commands_file, str) or not commands_file.strip(): + raise ValueError("setup-commands-file must be a non-empty string path") + file_text = read_file_input(repo_root, commands_file.strip()) + commands.extend(_expand_setup_command_lines(file_text)) + return commands + + +def compose_additional_instructions( + platform_text: str, + apm_inputs: dict[str, Any], +) -> str: + parts: list[str] = [] + platform = platform_text.strip() + if platform: + parts.append(platform) + + apm_text = apm_inputs.get("additional-instructions") + if isinstance(apm_text, str) and apm_text.strip(): + parts.append(apm_text.strip()) + + return "\n\n".join(parts) + + +def merge_platform_and_apm_inputs( + platform_inputs: dict[str, Any], + apm_inputs: dict[str, Any], +) -> dict[str, Any]: + """Per-key override: APM inputs replace platform keys.""" + merged = dict(platform_inputs) + for key, value in apm_inputs.items(): + if key == "additional-instructions": + continue + merged[key] = value + return merged + + +def resolve_agentic_assets( + *, + repo_root: Path, + workflow_id: str, + org_key: str, + platform_additional_instructions: str = "", + platform_inputs: dict[str, Any] | None = None, + config_dir: Path | None = None, +) -> dict[str, Any]: + """ + Resolve assets for one agentic workflow run. + + Returns a dict with keys: + - apm_manifest_present (bool) + - apm_extension_present (bool) + - asset_source (str): none | common | workflow + - additional_instructions (str) + - inputs (dict) + - setup_commands (list[str]) + """ + validate_workflow_id(workflow_id, org_key, config_dir=config_dir) + platform_inputs = platform_inputs or {} + + manifest, manifest_present = load_apm_manifest(repo_root) + if not manifest_present or manifest is None: + return { + "apm_manifest_present": False, + "apm_extension_present": False, + "asset_source": "none", + "additional_instructions": compose_additional_instructions( + platform_additional_instructions, {} + ), + "inputs": dict(platform_inputs), + "setup_commands": [], + } + + extension = manifest.get(OBLT_AW_EXTENSION_KEY) + if extension is None: + return { + "apm_manifest_present": True, + "apm_extension_present": False, + "asset_source": "none", + "additional_instructions": compose_additional_instructions( + platform_additional_instructions, {} + ), + "inputs": dict(platform_inputs), + "setup_commands": [], + } + + if not isinstance(extension, dict): + raise ValueError(f"{OBLT_AW_EXTENSION_KEY} must be a mapping") + + org_extension = extract_org_extension(extension, org_key) + if org_extension is None: + return { + "apm_manifest_present": True, + "apm_extension_present": True, + "asset_source": "none", + "additional_instructions": compose_additional_instructions( + platform_additional_instructions, {} + ), + "inputs": dict(platform_inputs), + "setup_commands": [], + } + + block = select_asset_block(org_extension, workflow_id, org_key=org_key) + if block is None: + return { + "apm_manifest_present": True, + "apm_extension_present": True, + "asset_source": "none", + "additional_instructions": compose_additional_instructions( + platform_additional_instructions, {} + ), + "inputs": dict(platform_inputs), + "setup_commands": [], + } + + workflows = org_extension.get("workflows") + asset_source = ( + "workflow" + if isinstance(workflows, dict) and workflow_id in workflows + else "common" + ) + + apm_inputs = materialize_inputs(repo_root, block.get("inputs")) + setup_commands = extract_setup_commands(block, repo_root=repo_root) + + merged_inputs = merge_platform_and_apm_inputs(platform_inputs, apm_inputs) + additional = compose_additional_instructions( + platform_additional_instructions, apm_inputs + ) + + return { + "apm_manifest_present": True, + "apm_extension_present": True, + "asset_source": asset_source, + "additional_instructions": additional, + "inputs": merged_inputs, + "setup_commands": setup_commands, + } diff --git a/scripts/resolve_apm_agentic_assets.py b/scripts/resolve_apm_agentic_assets.py new file mode 100644 index 0000000..544f419 --- /dev/null +++ b/scripts/resolve_apm_agentic_assets.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +# Copyright 2026-2027 Elasticsearch B.V. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +""" +CLI entrypoint for GitHub Actions: resolve agentic assets from apm.yml. + +Environment: + ENABLED_WORKFLOW_ID Compound org-key:workflow-id (preferred; from registry resolution) + WORKFLOW_ID Registry workflow id when ORG_KEY is set separately + ORG_KEY Org key from config// (default: obs) + REPO_ROOT Repository root (default: cwd) + PLATFORM_ADDITIONAL_INSTRUCTIONS Multiline platform baseline text + PLATFORM_INPUTS_JSON JSON object of platform workflow_call inputs + CONTROL_PLANE_CONFIG_DIR Optional path to config/ for registry validation +""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path +from typing import Any, cast + +from apm_agentic_assets import resolve_agentic_assets +from common import append_multiline_github_output, write_outputs + + +def main() -> int: + compound = os.environ.get("ENABLED_WORKFLOW_ID", "").strip() + workflow_id = os.environ.get("WORKFLOW_ID", "").strip() + org_key = os.environ.get("ORG_KEY", "obs").strip() or "obs" + + if compound: + from apm_agentic_assets import parse_compound_workflow_id + + org_key, workflow_id = parse_compound_workflow_id(compound) + + if not workflow_id: + print("WORKFLOW_ID or ENABLED_WORKFLOW_ID is required", file=sys.stderr) + return 1 + repo_root = Path(os.environ.get("REPO_ROOT", ".")).resolve() + platform_text = os.environ.get("PLATFORM_ADDITIONAL_INSTRUCTIONS", "") + + platform_inputs_raw = os.environ.get("PLATFORM_INPUTS_JSON", "{}").strip() + try: + platform_inputs = json.loads(platform_inputs_raw or "{}") + except json.JSONDecodeError as exc: + print(f"PLATFORM_INPUTS_JSON is invalid JSON: {exc}", file=sys.stderr) + return 1 + if not isinstance(platform_inputs, dict): + print("PLATFORM_INPUTS_JSON must be a JSON object", file=sys.stderr) + return 1 + if not all(isinstance(k, str) for k in platform_inputs): + print("PLATFORM_INPUTS_JSON keys must all be strings", file=sys.stderr) + return 1 + platform_inputs_typed: dict[str, Any] = cast(dict[str, Any], platform_inputs) + + config_dir: Path | None = None + config_env = os.environ.get("CONTROL_PLANE_CONFIG_DIR", "").strip() + if config_env: + config_dir = Path(config_env).resolve() + + try: + resolved = resolve_agentic_assets( + repo_root=repo_root, + workflow_id=workflow_id, + org_key=org_key, + platform_additional_instructions=platform_text, + platform_inputs=platform_inputs_typed, + config_dir=config_dir, + ) + except (OSError, ValueError, FileNotFoundError) as exc: + print(f"resolve_apm_agentic_assets failed: {exc}", file=sys.stderr) + return 1 + + additional = resolved["additional_instructions"] + inputs_json = json.dumps(resolved["inputs"], ensure_ascii=False) + setup_json = json.dumps(resolved["setup_commands"], ensure_ascii=False) + + write_outputs( + { + "apm-manifest-present": "true" + if resolved["apm_manifest_present"] + else "false", + "apm-extension-present": ( + "true" if resolved["apm_extension_present"] else "false" + ), + "asset-source": str(resolved["asset_source"]), + "resolved-inputs-json": inputs_json, + "resolved-setup-commands-json": setup_json, + } + ) + append_multiline_github_output("resolved-additional-instructions", additional) + + print( + "Resolved agentic assets: " + f"manifest={resolved['apm_manifest_present']} " + f"extension={resolved['apm_extension_present']} " + f"source={resolved['asset_source']}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate_aw_workflow_resolve_apm_assets.py b/scripts/validate_aw_workflow_resolve_apm_assets.py new file mode 100644 index 0000000..fd2f598 --- /dev/null +++ b/scripts/validate_aw_workflow_resolve_apm_assets.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +# Copyright 2026-2027 Elasticsearch B.V. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +""" +Validate that every local *-aw-* workflow invoking a gh-aw-* reusable also calls +aw-resolve-apm-assets.yml once per agent invocation. + +Excludes aw-resolve-apm-assets.yml and aw-prelude.yml, and distributed trg-* / +trigger-* client entrypoints. +""" + +from __future__ import annotations + +import pathlib +import re +import sys + +from validate_aw_workflow_prelude import list_subject_workflows + +GH_AW_USES = re.compile( + r"^\s+uses:\s*\S+/gh-aw-.+\.ya?ml", + re.MULTILINE, +) +RESOLVE_APM_USES = re.compile( + r"uses:\s*\./\.github/workflows/aw-resolve-apm-assets\.ya?ml\b", + re.MULTILINE, +) +PRELUDE_RESOLVED_INSTRUCTIONS = re.compile( + r"needs\.prelude\.outputs\.resolved-(?:additional-instructions|inputs-json|setup-commands-json)", +) + + +def validate_workflow(path: pathlib.Path) -> list[str]: + text = path.read_text(encoding="utf-8") + errors: list[str] = [] + + agent_calls = len(GH_AW_USES.findall(text)) + if agent_calls == 0: + return errors + + resolve_calls = len(RESOLVE_APM_USES.findall(text)) + if resolve_calls < agent_calls: + errors.append( + f"{path}: found {agent_calls} gh-aw-* reusable call(s) but only " + f"{resolve_calls} aw-resolve-apm-assets.yml call(s); add one resolve job " + "per agent invocation" + ) + + if PRELUDE_RESOLVED_INSTRUCTIONS.search(text): + errors.append( + f"{path}: must use resolve-apm-assets outputs for APM resolution, " + "not needs.prelude.outputs.resolved-*" + ) + + return errors + + +def main() -> int: + errors: list[str] = [] + subjects = list_subject_workflows() + if not subjects: + print("No *-aw-* workflows found to validate.", file=sys.stderr) + return 1 + + for path in subjects: + errors.extend(validate_workflow(path)) + + if errors: + print("resolve-apm-assets enforcement failed:", file=sys.stderr) + for err in errors: + print(f" - {err}", file=sys.stderr) + return 1 + + agent_workflows = sum( + 1 for path in subjects if GH_AW_USES.search(path.read_text(encoding="utf-8")) + ) + print( + f"Validated {len(subjects)} *-aw-* workflow(s); " + f"{agent_workflows} invoke gh-aw-* and call aw-resolve-apm-assets.yml." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_apm_agentic_assets.py b/tests/test_apm_agentic_assets.py new file mode 100644 index 0000000..cb83f12 --- /dev/null +++ b/tests/test_apm_agentic_assets.py @@ -0,0 +1,329 @@ +""" +Unit tests for scripts/apm_agentic_assets.py +""" + +from __future__ import annotations + +import pathlib +import sys + +import pytest + +_root = pathlib.Path(__file__).parent.parent +sys.path.insert(0, str(_root / "scripts")) + +import apm_agentic_assets as aaa # noqa: E402 + + +@pytest.fixture +def repo(tmp_path: pathlib.Path) -> pathlib.Path: + return tmp_path + + +def _obs_org( + *, + common: dict | None = None, + workflows: dict | None = None, +) -> dict: + block: dict = { + "common": common + if common is not None + else {"inputs": {"additional-instructions": "obs common"}}, + } + if workflows is not None: + block["workflows"] = workflows + return {"version": 1, "obs": block} + + +class TestSelectAssetBlock: + def test_workflow_override_ignores_common(self) -> None: + org = _obs_org( + common={"inputs": {"additional-instructions": "common text"}}, + workflows={ + "agent-suggestions": { + "inputs": {"additional-instructions": "specific text"} + } + }, + ) + block = aaa.select_asset_block(org["obs"], "agent-suggestions", org_key="obs") + assert block is not None + assert block["inputs"]["additional-instructions"] == "specific text" + + def test_falls_back_to_common(self) -> None: + org = _obs_org( + common={"inputs": {"additional-instructions": "shared"}}, + workflows={"other": {"inputs": {"additional-instructions": "x"}}}, + ) + block = aaa.select_asset_block(org["obs"], "agent-suggestions", org_key="obs") + assert block == org["obs"]["common"] + + def test_workflow_empty_block_still_overrides(self) -> None: + org = _obs_org( + common={"inputs": {"additional-instructions": "common"}}, + workflows={"agent-suggestions": {}}, + ) + block = aaa.select_asset_block(org["obs"], "agent-suggestions", org_key="obs") + assert block == {} + + +class TestExtractOrgExtension: + def test_missing_org_returns_none(self) -> None: + ext = {"version": 1, "obs": _obs_org()["obs"]} + assert aaa.extract_org_extension(ext, "docs") is None + + def test_rejects_legacy_flat_layout(self) -> None: + ext = { + "version": 1, + "common": {"inputs": {"additional-instructions": "flat"}}, + } + with pytest.raises(ValueError, match="nest assets under org keys"): + aaa.extract_org_extension(ext, "obs") + + +class TestExtractSetupCommands: + def test_inline_string(self) -> None: + assert aaa.extract_setup_commands( + {"setup-commands": "npm ci"}, + repo_root=pathlib.Path("/tmp"), + ) == ["npm ci"] + + def test_multiline_block_string(self) -> None: + block = { + "setup-commands": "export FOO=1\n\n# skipped\n./scripts/run.sh\n", + } + assert aaa.extract_setup_commands(block, repo_root=pathlib.Path("/tmp")) == [ + "export FOO=1", + "./scripts/run.sh", + ] + + def test_setup_commands_file(self, repo: pathlib.Path) -> None: + scripts = repo / "scripts" + scripts.mkdir() + (scripts / "setup.txt").write_text( + "echo from-file\n\n# comment\n./scripts/extra.sh\n", + encoding="utf-8", + ) + assert aaa.extract_setup_commands( + { + "setup-commands": ["echo inline"], + "setup-commands-file": "scripts/setup.txt", + }, + repo_root=repo, + ) == ["echo inline", "echo from-file", "./scripts/extra.sh"] + + +class TestResolveAgenticAssets: + def test_no_manifest_platform_only(self, repo: pathlib.Path) -> None: + out = aaa.resolve_agentic_assets( + repo_root=repo, + workflow_id="agent-suggestions", + org_key="obs", + platform_additional_instructions="platform rules", + ) + assert out["apm_manifest_present"] is False + assert out["asset_source"] == "none" + assert out["additional_instructions"] == "platform rules" + assert out["setup_commands"] == [] + + def test_common_assets(self, repo: pathlib.Path) -> None: + (repo / "apm.yml").write_text( + """ +x-oblt-aw: + version: 1 + obs: + common: + setup-commands: + - echo bootstrap + inputs: + additional-instructions: | + repo-wide guidance +""", + encoding="utf-8", + ) + out = aaa.resolve_agentic_assets( + repo_root=repo, + workflow_id="issue-triage", + org_key="obs", + platform_additional_instructions="cp", + ) + assert out["asset_source"] == "common" + assert "cp" in out["additional_instructions"] + assert "repo-wide guidance" in out["additional_instructions"] + assert out["setup_commands"] == ["echo bootstrap"] + + def test_workflow_override(self, repo: pathlib.Path) -> None: + (repo / "apm.yml").write_text( + """ +x-oblt-aw: + version: 1 + obs: + common: + setup-commands: + - echo common + inputs: + additional-instructions: common-only + workflows: + agent-suggestions: + setup-commands: + - echo specific + inputs: + additional-instructions: specific-only +""", + encoding="utf-8", + ) + out = aaa.resolve_agentic_assets( + repo_root=repo, + workflow_id="agent-suggestions", + org_key="obs", + platform_additional_instructions="", + ) + assert out["asset_source"] == "workflow" + assert out["setup_commands"] == ["echo specific"] + assert "common-only" not in out["additional_instructions"] + assert "specific-only" in out["additional_instructions"] + + def test_multiline_inline_setup_commands(self, repo: pathlib.Path) -> None: + (repo / "apm.yml").write_text( + """ +x-oblt-aw: + version: 1 + obs: + common: + setup-commands: | + export REPO_BOOTSTRAP=1 + ./scripts/bootstrap.sh + inputs: + additional-instructions: bootstrapped +""", + encoding="utf-8", + ) + out = aaa.resolve_agentic_assets( + repo_root=repo, + workflow_id="autodoc", + org_key="obs", + ) + assert out["setup_commands"] == [ + "export REPO_BOOTSTRAP=1", + "./scripts/bootstrap.sh", + ] + + def test_file_input(self, repo: pathlib.Path) -> None: + ai_dir = repo / ".github" / "ai" + ai_dir.mkdir(parents=True) + (ai_dir / "extra.md").write_text("from file\n", encoding="utf-8") + (repo / "apm.yml").write_text( + """ +x-oblt-aw: + version: 1 + obs: + common: + inputs: + additional-instructions: fallback + workflows: + security: + inputs: + additional-instructions-file: .github/ai/extra.md +""", + encoding="utf-8", + ) + out = aaa.resolve_agentic_assets( + repo_root=repo, + workflow_id="security", + org_key="obs", + ) + assert "from file" in out["additional_instructions"] + + def test_platform_inputs_overridden_by_apm(self, repo: pathlib.Path) -> None: + (repo / "apm.yml").write_text( + """ +x-oblt-aw: + version: 1 + obs: + common: + inputs: + lookback-window: 3 days ago +""", + encoding="utf-8", + ) + out = aaa.resolve_agentic_assets( + repo_root=repo, + workflow_id="autodoc", + org_key="obs", + platform_inputs={"lookback-window": "1 day ago"}, + ) + assert out["inputs"]["lookback-window"] == "3 days ago" + + def test_multi_org_isolated_common(self, repo: pathlib.Path) -> None: + (repo / "apm.yml").write_text( + """ +x-oblt-aw: + version: 1 + obs: + common: + inputs: + additional-instructions: obs guidance + docs: + common: + inputs: + additional-instructions: docs guidance +""", + encoding="utf-8", + ) + obs_out = aaa.resolve_agentic_assets( + repo_root=repo, + workflow_id="agent-suggestions", + org_key="obs", + ) + docs_out = aaa.resolve_agentic_assets( + repo_root=repo, + workflow_id="docs-pr-ai-menu", + org_key="docs", + ) + assert obs_out["asset_source"] == "common" + assert "obs guidance" in obs_out["additional_instructions"] + assert "docs guidance" not in obs_out["additional_instructions"] + assert docs_out["asset_source"] == "common" + assert "docs guidance" in docs_out["additional_instructions"] + + def test_missing_org_block_extension_present_no_assets( + self, repo: pathlib.Path + ) -> None: + (repo / "apm.yml").write_text( + """ +x-oblt-aw: + version: 1 + obs: + common: + inputs: + additional-instructions: obs only +""", + encoding="utf-8", + ) + out = aaa.resolve_agentic_assets( + repo_root=repo, + workflow_id="docs-pr-ai-menu", + org_key="docs", + platform_additional_instructions="platform", + ) + assert out["apm_extension_present"] is True + assert out["asset_source"] == "none" + assert out["additional_instructions"] == "platform" + assert out["setup_commands"] == [] + + def test_rejects_legacy_flat_manifest(self, repo: pathlib.Path) -> None: + (repo / "apm.yml").write_text( + """ +x-oblt-aw: + version: 1 + common: + inputs: + additional-instructions: legacy +""", + encoding="utf-8", + ) + with pytest.raises(ValueError, match="nest assets under org keys"): + aaa.resolve_agentic_assets( + repo_root=repo, + workflow_id="agent-suggestions", + org_key="obs", + ) diff --git a/tests/test_validate_aw_workflow_resolve_apm_assets.py b/tests/test_validate_aw_workflow_resolve_apm_assets.py new file mode 100644 index 0000000..afcef6d --- /dev/null +++ b/tests/test_validate_aw_workflow_resolve_apm_assets.py @@ -0,0 +1,91 @@ +"""Tests for scripts/validate_aw_workflow_resolve_apm_assets.py.""" + +from __future__ import annotations + +import pathlib +import sys + +import pytest + +sys.path.insert(0, str(pathlib.Path(__file__).parent.parent / "scripts")) + +import validate_aw_workflow_resolve_apm_assets as validator # noqa: E402 + + +def test_validate_workflow_skips_non_agent_wrappers( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workflows = tmp_path / ".github" / "workflows" + workflows.mkdir(parents=True) + no_agent = workflows / "oblt-aw-security-detector.yml" + no_agent.write_text( + "name: Test\non:\n workflow_call:\njobs:\n" + " prelude:\n uses: ./.github/workflows/aw-prelude.yml\n" + " with:\n control-plane-workflow: oblt-aw-security-detector.yml\n" + " scan:\n needs: prelude\n runs-on: ubuntu-latest\n steps:\n" + " - run: echo scan\n", + encoding="utf-8", + ) + monkeypatch.setattr(validator, "list_subject_workflows", lambda: [no_agent]) + assert validator.validate_workflow(no_agent) == [] + + +def test_validate_workflow_rejects_gh_aw_without_resolve( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workflows = tmp_path / ".github" / "workflows" + workflows.mkdir(parents=True) + bad = workflows / "oblt-aw-test.yml" + bad.write_text( + "name: Test\non:\n workflow_call:\njobs:\n" + " prelude:\n uses: ./.github/workflows/aw-prelude.yml\n" + " with:\n control-plane-workflow: oblt-aw-test.yml\n" + " agent:\n needs: prelude\n" + " uses: elastic/ai-github-actions/.github/workflows/gh-aw-issue-triage.lock.yml@main\n", + encoding="utf-8", + ) + monkeypatch.setattr(validator, "list_subject_workflows", lambda: [bad]) + errors = validator.validate_workflow(bad) + assert any("resolve-apm-assets" in err for err in errors) + + +def test_validate_workflow_rejects_prelude_apm_outputs( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workflows = tmp_path / ".github" / "workflows" + workflows.mkdir(parents=True) + bad = workflows / "oblt-aw-test.yml" + bad.write_text( + "name: Test\non:\n workflow_call:\njobs:\n" + " prelude:\n uses: ./.github/workflows/aw-prelude.yml\n" + " resolve-apm-assets:\n uses: ./.github/workflows/aw-resolve-apm-assets.yml\n" + " agent:\n uses: elastic/ai-github-actions/.github/workflows/gh-aw-issue-triage.lock.yml@main\n" + " with:\n" + " additional-instructions: ${{ needs.prelude.outputs.resolved-additional-instructions }}\n", + encoding="utf-8", + ) + monkeypatch.setattr(validator, "list_subject_workflows", lambda: [bad]) + errors = validator.validate_workflow(bad) + assert any("prelude.outputs.resolved" in err for err in errors) + + +def test_validate_workflow_accepts_resolve_per_agent_call( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workflows = tmp_path / ".github" / "workflows" + workflows.mkdir(parents=True) + good = workflows / "oblt-aw-test.yml" + good.write_text( + "name: Test\non:\n workflow_call:\njobs:\n" + " prelude:\n uses: ./.github/workflows/aw-prelude.yml\n" + " with:\n control-plane-workflow: oblt-aw-test.yml\n" + " resolve-apm-assets:\n uses: ./.github/workflows/aw-resolve-apm-assets.yml\n" + " with:\n control-plane-workflow: oblt-aw-test.yml\n" + " agent:\n needs: [prelude, resolve-apm-assets]\n" + " uses: elastic/ai-github-actions/.github/workflows/gh-aw-issue-triage.lock.yml@main\n" + " with:\n" + " additional-instructions: ${{ needs.resolve-apm-assets.outputs.resolved-additional-instructions }}\n", + encoding="utf-8", + ) + monkeypatch.setattr(validator, "list_subject_workflows", lambda: [good]) + assert validator.validate_workflow(good) == []