From 0decbbf1d3a95891a71807e5fa7e5ed530dd84c2 Mon Sep 17 00:00:00 2001 From: Zahin Mohammad Date: Wed, 28 Oct 2026 13:59:08 -0400 Subject: [PATCH 1/2] fix(root): publish validated iyarc-prune pull requests Publish signed dependency updates from the exact validated checkout. Require master dispatches and provision the pinned vulnerability scanner. Ticket: WAL-2232 Co-authored-by: Ralph CLI --- .claude/agents/iyarc-prune.md | 112 +++++++++++++++------------ .github/workflows/iyarc-prune.yml | 124 ++++++++++++++++++++++++++++-- 2 files changed, 177 insertions(+), 59 deletions(-) diff --git a/.claude/agents/iyarc-prune.md b/.claude/agents/iyarc-prune.md index 61c1057d3b7..200c5b9582c 100644 --- a/.claude/agents/iyarc-prune.md +++ b/.claude/agents/iyarc-prune.md @@ -22,14 +22,27 @@ over an unsafe or unverified bump. ## Environment notes +- For a manual run of [the workflow](../../.github/workflows/iyarc-prune.yml#L26-L38), + select `master` in **Use workflow from**. The AWS login uses the + [workflow's branch identity](https://docs.github.com/en/actions/reference/security/oidc#filtering-for-a-specific-branch), + not the checkout ref. Merge workflow changes before a live run; local agent + invocations are unaffected. - This is a Lerna + Yarn (v1, `1.22.22`) workspaces monorepo with ~116 packages under `modules/`. Node and Yarn are already provisioned in this runner. -- The release audit gate is: +- The workflow installs `osv-scanner` v2.3.8, matching the release audit. + It writes JSON results: ``` - osv-scanner --config=osv-scanner.toml --severity=HIGH --severity=CRITICAL ./ + osv-scanner --config=osv-scanner.toml --format=json --output-file="$RUNNER_TEMP/osv-results.json" ./ ``` - This is the EXACT command the release pipeline runs, so it is your source of - truth for "fixed". + For a local run, substitute a temporary directory for `$RUNNER_TEMP`. + The scanner has no `--severity` flag. Exit code 1 means findings, not + necessarily a failed release gate. Evaluate the JSON using the shell step in + `.github/actions/osv-severity-gate/action.yml`, with `SCAN_OUTCOME=success` + for exit code 0 or `SCAN_OUTCOME=failure` for exit code 1, `RESULTS_FILE` set + to that output path, and `CVSS_THRESHOLD=7.0`. CVSS (Common Vulnerability + Scoring System) scores of 7.0 or above block release. Exit codes above 1 + are scanner errors and MUST fail validation. Read the current release steps + in `.github/workflows/npmjs-release.yml` before running the audit. - IMPORTANT: nearly every entry in `osv-scanner.toml` is a TRANSITIVE dependency (e.g. tar, minimatch, ws, form-data, protobufjs, tmp, sjcl, sanitize-html, esbuild), pinned in the root `package.json` `resolutions` block — NOT a direct @@ -52,7 +65,7 @@ behind, still exists. Run this check and decide on the parsed state, never on "a PR row exists": - gh pr list --state open --json number,state,headRefName --jq '.[] | select(.headRefName | startswith("osv-scanner-prune/"))' + gh pr list --state open --limit 1000 --json number,state,headRefName --jq '.[] | select(.headRefName | startswith("osv-scanner-prune/"))' - Non-empty output: an open prune PR exists — stop and report — do not open a second. Name the open PR number(s) and head branch(es) in the report. @@ -65,6 +78,7 @@ from merged prune PRs linger until deleted and must not trigger this early exit. ## Read context first Before changing anything, read: + 1. `osv-scanner.toml` — the full ignore list and every justification comment. 2. The root `package.json` `resolutions` block. 3. `scripts/upgrade-workspace-dependency.ts` (the `yarn upgrade-dep` tool). @@ -103,8 +117,9 @@ For each `[[IgnoredVulns]]` entry in `osv-scanner.toml`: After each attempted fix, run the SAME gates the release pipeline runs, in this order: -1. `osv-scanner --config=osv-scanner.toml --severity=HIGH --severity=CRITICAL ./` - It MUST pass with the exclusion removed. Capture the output. + +1. Run the pinned scanner and severity gate described above. The gate MUST + pass with the exclusion removed. Capture the scanner and gate output. 2. `yarn check-deps`. It MUST pass — a `resolutions` change can break cross-workspace version consistency. This is both a release-job step (it runs immediately after audit in the release workflow) and a PR-CI gate, so a @@ -115,58 +130,53 @@ order: 4. If ANY step fails — no compatible fix, audit still flags the advisory, check-deps fails, build breaks, or tests fail — revert that dependency's changes and restore its exclusion in `osv-scanner.toml`. Never open a PR with - a red feedback loop. The full test suite still runs in PR CI as a backstop. - -## Commit and pull request (only if at least one exclusion was removed with a -fully green feedback loop) - -How you finish depends on where you are running: - -- **If running in CI** (a `osv-scanner-prune/*` branch exists and the - `mcp__github_file_ops__commit_files` tool is available): commit and open the PR - as described below. -- **If a developer is running you locally:** make the edits, run the full - feedback loop, print the summary table and the "Still blocked" section, and - STOP. Do not commit or open a PR — let the developer review and commit. - -CI commit/PR rules: - -- Commit message: conventional (commitlint extends `@commitlint/config-conventional`; - `deps` and `root` are valid scopes), e.g.: - `chore(deps): bump to , drop from osv-scanner.toml`. - commitlint enforces `references-empty: never`, so the message MUST carry an - issue reference: include `Ticket: HSM-429` in the footer. -- SIGNED COMMIT (important — `master` requires signed commits): this workflow - runs with commit signing enabled. Make your commit using the - `mcp__github_file_ops__commit_files` tool — NOT `git commit`/`git push` — - passing every changed path (`osv-scanner.toml`, `package.json`, `yarn.lock`). - That tool commits through GitHub's API, so the commit is Verified (signed). - Commits made with raw `git` will be UNSIGNED and cannot be merged. - - FALLBACK: if the signing tool fails, commit with `git` anyway and add this - line to the PR body: "⚠️ Commits are unsigned — a maintainer must re-sign - before merge." -- The workflow creates the working branch automatically (prefix `osv-scanner-prune/`); - commit your changes to it, then open a single NON-draft PR against `master` - with `gh pr create`. -- Labels: ensure `automated`, `dependencies`, and `security` exist (create any - missing one with `gh label create --force`), then apply all three. -- Assign the PR to `gokulhost` so it does not get lost: - `gh pr edit --add-assignee gokulhost`. CODEOWNERS reviewers are - assigned automatically and separately. -- PR body must contain: + a red feedback loop. The workflow publishes with GitHub's repository-scoped + workflow token (`GITHUB_TOKEN`). A maintainer may need to approve workflow + runs before continuous integration (CI) starts on the resulting pull request. + Do not assume those checks ran; they must pass before merging. + +## Commit and pull request (only after validated exclusion removals) + +The workflow owns branch creation, signed commits, and pull request creation. +Do not create a branch, commit, push, or open a pull request yourself. + +- **When `IYARC_PRUNE_PR_BODY` is set:** you are running under the publication + workflow, even when the checked-out branch is `master`. Leave the validated + edits uncommitted and write the pull request body to the absolute path in + `IYARC_PRUNE_PR_BODY`. Create this file ONLY after at least one exclusion has + been removed and the entire feedback loop has passed. If an open prune pull + request exists or nothing can be safely removed, do not create this file. + If validation fails, revert the failed changes before finishing. +- **When `IYARC_PRUNE_PR_BODY` is unset:** this is a local run. Make the edits, + run the feedback loop, print the summary table and the "Still blocked" + section, and stop so the developer can review and commit. +- The workflow creates a signed commit with the `Ticket: HSM-429` footer and + a non-draft pull request against `master`, using an `osv-scanner-prune/*` + branch. It applies `automated`, `dependencies`, and `security` labels and + assigns `gokulhost`. +- The [publication checks](../../.github/workflows/iyarc-prune.yml#L183-L230) + inspect both working-tree and staged paths, then clear the Git staging area + before committing the validated working-tree contents. The published commit + keeps the original checkout as its parent; it is not rebased onto newer + `master` changes. Required pull request checks must validate the merge. +- The pull request body must contain: - A table of each removed exclusion: GHSA id, package, old -> new version, the advisory it resolves. - - The pasted `osv-scanner --config=osv-scanner.toml --severity=HIGH --severity=CRITICAL ./` - and `yarn check-deps` output showing they now pass. + - The pasted scanner, severity-gate, and `yarn check-deps` output showing + they now pass. - Build/test results for the affected module(s). + - A prominent note asking a maintainer to approve pending workflow runs and + verify required checks pass before merging. - A "Still blocked" section listing every exclusion that could NOT be removed and the reason (no upstream fix / incompatible parent pin). - - Only if you hit the signing fallback above: the unsigned-commits note. ## Output rules - If nothing is safely prunable this run, open no PR and report "no exclusions prunable this run" in the job summary, including the "Still blocked" breakdown so the result is auditable. -- Only ever modify `osv-scanner.toml`, dependency manifests (`package.json`), and - `yarn.lock`. Do not modify product/source code. +- Only ever modify `osv-scanner.toml`, `package.json`, `yarn.lock`, and direct + workspace manifests at `modules//package.json` in the repository. + Nested test/example manifests are not allowed. The report at + `IYARC_PRUNE_PR_BODY` and temporary validation output belong outside the + repository. Do not modify product/source code. diff --git a/.github/workflows/iyarc-prune.yml b/.github/workflows/iyarc-prune.yml index 60fbd2d6768..3e08319ae35 100644 --- a/.github/workflows/iyarc-prune.yml +++ b/.github/workflows/iyarc-prune.yml @@ -23,9 +23,19 @@ jobs: env: AWS_REGION: us-west-2 steps: + - name: Require master workflow ref + run: | + set -euo pipefail + if [ "$GITHUB_REF" != 'refs/heads/master' ]; then + echo '::error::Run iyarc-prune from master. The AWS role trusts the workflow ref, not the checkout ref. Merge workflow changes before dispatching them.' + exit 1 + fi + - name: Checkout repository + id: checkout uses: actions/checkout@v6 with: + ref: ${{ github.sha }} fetch-depth: 0 - name: Setup Node @@ -36,6 +46,21 @@ jobs: - name: Enable Corepack (yarn 1.22.22) run: corepack enable + - name: Install release audit scanner + env: + OSV_SCANNER_SHA256: bc98e15319ed0d515e3f9235287ba53cdc5535d576d24fd573978ecfe9ab92dc + run: | + set -euo pipefail + SCANNER_DIR="$RUNNER_TEMP/osv-scanner" + mkdir -p "$SCANNER_DIR" + curl --fail --silent --show-error --location --retry 3 \ + https://github.com/google/osv-scanner/releases/download/v2.3.8/osv-scanner_linux_amd64 \ + --output "$SCANNER_DIR/osv-scanner" + printf '%s %s\n' "$OSV_SCANNER_SHA256" "$SCANNER_DIR/osv-scanner" | sha256sum --check --strict + chmod +x "$SCANNER_DIR/osv-scanner" + "$SCANNER_DIR/osv-scanner" --version + echo "$SCANNER_DIR" >> "$GITHUB_PATH" + - name: Configure AWS Credentials (OIDC) uses: aws-actions/configure-aws-credentials@v6 with: @@ -73,12 +98,11 @@ jobs: uses: anthropics/claude-code-action@8d70a0dd265122bd65b8e09d3d959e65373ae2a1 # v1.0.260 with: prompt: ${{ steps.agent.outputs.content }} - use_commit_signing: 'true' - branch_prefix: 'osv-scanner-prune/' - claude_args: --allowed-tools 'Edit,MultiEdit,Write,Read,Glob,Grep,LS,Bash,mcp__github_file_ops__commit_files,mcp__github_file_ops__delete_files' + claude_args: --allowed-tools 'Edit,MultiEdit,Write,Read,Glob,Grep,LS,Bash' use_bedrock: 'true' github_token: ${{ secrets.GITHUB_TOKEN }} env: + IYARC_PRUNE_PR_BODY: ${{ runner.temp }}/iyarc-prune-pr.md ANTHROPIC_MODEL: arn:aws:bedrock:us-west-2:168000258654:inference-profile/global.anthropic.claude-sonnet-5-5 # Bedrock: replaying a tool-call turn requires a leading thinking block; disable to avoid 400. MAX_THINKING_TOKENS: '0' @@ -92,7 +116,7 @@ jobs: # including the Still blocked breakdown so the result is auditable"), so # render the agent's own report there instead of leaving it log-only. - name: Write agent report to job summary - if: always() + if: always() && steps.claude.outcome != 'skipped' env: EXECUTION_FILE: ${{ steps.claude.outputs.execution_file }} run: | @@ -137,8 +161,8 @@ jobs: fi } >> "$GITHUB_STEP_SUMMARY" - - name: Surface Claude error and fail on is_error - if: always() + - name: Require successful Claude completion + if: always() && steps.claude.outcome != 'skipped' env: EXECUTION_FILE: ${{ steps.claude.outputs.execution_file }} run: | @@ -152,6 +176,90 @@ jobs: echo "=== assistant text turns (no tool output) ===" echo "$EVENTS" | jq -r '.[] | select(.type=="assistant") | .message.content[]? | select(.type=="text") | .text' RES="$(echo "$EVENTS" | jq -c 'map(select(.type=="result"))[-1] // empty')" - if [ "$(echo "$RES" | jq -r '.is_error // false')" = "true" ]; then - echo "::error::Claude returned is_error=true"; exit 1 + if ! echo "$RES" | jq -e '.is_error == false and .subtype == "success"' >/dev/null; then + echo "::error::Claude did not produce a successful result"; exit 1 + fi + + - name: Check changes are ready for a pull request + id: changes + env: + BASE_SHA: ${{ steps.checkout.outputs.commit }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + IYARC_PRUNE_PR_BODY: ${{ runner.temp }}/iyarc-prune-pr.md + run: | + set -euo pipefail + if [ "$(git rev-parse HEAD)" != "$BASE_SHA" ]; then + echo "::error::Agent changed HEAD; validated edits must remain uncommitted"; exit 1 + fi + CHANGED_FILES="$(git diff --name-only "$BASE_SHA"; git diff --cached --name-only "$BASE_SHA"; git ls-files --others --exclude-standard)" + if [ -z "$CHANGED_FILES" ]; then + echo "No dependency changes to publish." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + while IFS= read -r path; do + case "$path" in + osv-scanner.toml|package.json|yarn.lock) continue ;; + esac + if [[ ! "$path" =~ ^modules/[^/]+/package\.json$ ]]; then + echo "::error::Unexpected changed file: $path"; exit 1 + fi + done <<< "$CHANGED_FILES" + if git diff --quiet "$BASE_SHA" -- osv-scanner.toml; then + echo "::error::Dependency changes exist without an osv-scanner.toml change"; exit 1 + fi + if [ ! -s "$IYARC_PRUNE_PR_BODY" ]; then + echo "::error::Changes exist but the agent did not produce a validated pull request report"; exit 1 + fi + OPEN_PRS="$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --limit 1000 \ + --json number,headRefName --jq '.[] | select(.headRefName | startswith("osv-scanner-prune/"))')" + if [ -n "$OPEN_PRS" ]; then + echo "An open prune pull request already exists: $OPEN_PRS" >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + LABELS="$(gh label list --repo "$GITHUB_REPOSITORY" --limit 1000 --json name)" + for label in automated dependencies security; do + if ! jq -e --arg name "$label" 'any(.[]; (.name | ascii_downcase) == $name)' <<< "$LABELS" >/dev/null; then + gh label create "$label" --repo "$GITHUB_REPOSITORY" + fi + done + # Detached HEAD makes the publisher rebase onto newer master. Keep its + # local base refs at the validated commit and discard index-only state. + git reset --mixed "$BASE_SHA" + git checkout -B master "$BASE_SHA" + git update-ref refs/remotes/origin/master "$BASE_SHA" + echo "ready=true" >> "$GITHUB_OUTPUT" + + - name: Create signed dependency pull request + if: steps.changes.outputs.ready == 'true' + id: pull-request + uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7 + with: + token: ${{ secrets.GITHUB_TOKEN }} + sign-commits: true + branch: osv-scanner-prune/${{ github.run_id }} + base: master + add-paths: | + osv-scanner.toml + package.json + yarn.lock + :(glob)modules/*/package.json + commit-message: | + chore(deps): prune resolved vulnerability exclusions + + Ticket: HSM-429 + title: 'chore(deps): prune resolved vulnerability exclusions' + body-path: ${{ runner.temp }}/iyarc-prune-pr.md + labels: automated,dependencies,security + assignees: gokulhost + draft: false + + - name: Report pull request + if: steps.changes.outputs.ready == 'true' + env: + PR_URL: ${{ steps.pull-request.outputs.pull-request-url }} + run: | + if [ -z "$PR_URL" ]; then + echo "::error::Validated dependency changes did not produce a pull request"; exit 1 fi + echo "Pull request: $PR_URL" >> "$GITHUB_STEP_SUMMARY" + echo '> **Action required:** Check the pull request for an **Approve workflows to run** banner. A maintainer must approve pending workflow runs and verify required checks pass before merging.' >> "$GITHUB_STEP_SUMMARY" From 03788c5302b0a174b63d2c93b8d6b2f3d8b71e3d Mon Sep 17 00:00:00 2001 From: Zahin Mohammad Date: Fri, 30 Oct 2026 12:51:24 -0400 Subject: [PATCH 2/2] refactor(root): extract iyarc-prune workflow operations Ticket: WAL-2232 Co-authored-by: Ralph CLI Ralph: 18.12.3-bitgo.6 openrouter/openai/gpt-6.1-sol@default:high openrouter/z-ai/glm-5.3-flash@smol:low+task:medium openrouter/z-ai/glm-5.3@slow:max --- .claude/agents/iyarc-prune.md | 11 +- .github/workflows/iyarc-prune.yml | 169 +++------------- scripts/iyarc-prune.js | 262 ++++++++++++++++++++++++ scripts/tests/iyarc-prune.test.js | 326 ++++++++++++++++++++++++++++++ 4 files changed, 622 insertions(+), 146 deletions(-) create mode 100644 scripts/iyarc-prune.js create mode 100644 scripts/tests/iyarc-prune.test.js diff --git a/.claude/agents/iyarc-prune.md b/.claude/agents/iyarc-prune.md index 200c5b9582c..59c14fe95a7 100644 --- a/.claude/agents/iyarc-prune.md +++ b/.claude/agents/iyarc-prune.md @@ -22,13 +22,17 @@ over an unsafe or unverified bump. ## Environment notes -- For a manual run of [the workflow](../../.github/workflows/iyarc-prune.yml#L26-L38), +- For a manual run of [the workflow](../../.github/workflows/iyarc-prune.yml#L29-L42), select `master` in **Use workflow from**. The AWS login uses the [workflow's branch identity](https://docs.github.com/en/actions/reference/security/oidc#filtering-for-a-specific-branch), not the checkout ref. Merge workflow changes before a live run; local agent invocations are unaffected. - This is a Lerna + Yarn (v1, `1.22.22`) workspaces monorepo with ~116 packages under `modules/`. Node and Yarn are already provisioned in this runner. +- Workflow operations live in the dependency-free + [JavaScript helper](../../scripts/iyarc-prune.js), not inline shell programs. + Run their [regression tests](../../scripts/tests/iyarc-prune.test.js) with + `yarn test:scripts`; the tests use isolated temporary Git repositories. - The workflow installs `osv-scanner` v2.3.8, matching the release audit. It writes JSON results: ``` @@ -154,11 +158,14 @@ Do not create a branch, commit, push, or open a pull request yourself. a non-draft pull request against `master`, using an `osv-scanner-prune/*` branch. It applies `automated`, `dependencies`, and `security` labels and assigns `gokulhost`. -- The [publication checks](../../.github/workflows/iyarc-prune.yml#L183-L230) +- The [publication checks](../../scripts/iyarc-prune.js) inspect both working-tree and staged paths, then clear the Git staging area before committing the validated working-tree contents. The published commit keeps the original checkout as its parent; it is not rebased onto newer `master` changes. Required pull request checks must validate the merge. + After the agent runs, the workflow loads its helper from the original + checkout commit into the runner's temporary directory. Later steps run this + trusted copy so edits to the working copy cannot change those checks. - The pull request body must contain: - A table of each removed exclusion: GHSA id, package, old -> new version, the advisory it resolves. diff --git a/.github/workflows/iyarc-prune.yml b/.github/workflows/iyarc-prune.yml index 3e08319ae35..89ec168d179 100644 --- a/.github/workflows/iyarc-prune.yml +++ b/.github/workflows/iyarc-prune.yml @@ -20,6 +20,9 @@ jobs: prune: runs-on: ubuntu-latest timeout-minutes: 60 + defaults: + run: + shell: bash env: AWS_REGION: us-west-2 steps: @@ -49,51 +52,23 @@ jobs: - name: Install release audit scanner env: OSV_SCANNER_SHA256: bc98e15319ed0d515e3f9235287ba53cdc5535d576d24fd573978ecfe9ab92dc - run: | - set -euo pipefail - SCANNER_DIR="$RUNNER_TEMP/osv-scanner" - mkdir -p "$SCANNER_DIR" - curl --fail --silent --show-error --location --retry 3 \ - https://github.com/google/osv-scanner/releases/download/v2.3.8/osv-scanner_linux_amd64 \ - --output "$SCANNER_DIR/osv-scanner" - printf '%s %s\n' "$OSV_SCANNER_SHA256" "$SCANNER_DIR/osv-scanner" | sha256sum --check --strict - chmod +x "$SCANNER_DIR/osv-scanner" - "$SCANNER_DIR/osv-scanner" --version - echo "$SCANNER_DIR" >> "$GITHUB_PATH" + run: node scripts/iyarc-prune.js install-scanner - - name: Configure AWS Credentials (OIDC) + - name: Authenticate workflow with AWS uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: arn:aws:iam::199765120567:role/${{ github.event.repository.name }}-iam-protected aws-region: us-west-2 - - name: Assume Bedrock inference role + - name: Obtain credentials for the Bedrock model service id: inference-role - run: | - CREDS="$(aws sts assume-role \ - --role-arn arn:aws:iam::168000258654:role/BedrockInferenceRole \ - --role-session-name iyarc-prune-session \ - --query 'Credentials' \ - --output json)" - - AWS_ACCESS_KEY_ID="$(echo "$CREDS" | jq -r '.AccessKeyId')" - AWS_SECRET_ACCESS_KEY="$(echo "$CREDS" | jq -r '.SecretAccessKey')" - AWS_SESSION_TOKEN="$(echo "$CREDS" | jq -r '.SessionToken')" - - echo "::add-mask::$AWS_SECRET_ACCESS_KEY" - echo "::add-mask::$AWS_SESSION_TOKEN" - { echo "aws-access-key-id=$AWS_ACCESS_KEY_ID"; echo "aws-secret-access-key=$AWS_SECRET_ACCESS_KEY"; echo "aws-session-token=$AWS_SESSION_TOKEN"; } >> "$GITHUB_OUTPUT" + run: node scripts/iyarc-prune.js assume-inference-role - name: Load iyarc-prune agent instructions id: agent - run: | - { - echo "content<> "$GITHUB_OUTPUT" + run: node scripts/iyarc-prune.js load-instructions - - name: Run Claude Code (Bedrock) + - name: Run agent to validate dependency edits and write pull request body id: claude uses: anthropics/claude-code-action@8d70a0dd265122bd65b8e09d3d959e65373ae2a1 # v1.0.260 with: @@ -112,125 +87,36 @@ jobs: AWS_SESSION_TOKEN: ${{ steps.inference-role.outputs.aws-session-token }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # The agent prompt promises a job summary ("report ... in the job summary, - # including the Still blocked breakdown so the result is auditable"), so - # render the agent's own report there instead of leaving it log-only. + - name: Load trusted workflow helper from the original checkout + id: trusted-helper + if: always() && steps.claude.outcome != 'skipped' + env: + BASE_SHA: ${{ steps.checkout.outputs.commit }} + # Loading after the agent finishes prevents its working-copy edits from changing publication checks. + run: git show "$BASE_SHA:scripts/iyarc-prune.js" > "$RUNNER_TEMP/iyarc-prune-workflow.js" + - name: Write agent report to job summary if: always() && steps.claude.outcome != 'skipped' env: EXECUTION_FILE: ${{ steps.claude.outputs.execution_file }} - run: | - # Deliberately NOT `set -e`: this step is reporting only and must never - # be the reason the job goes red. Failures degrade to a note instead. - set -uo pipefail - EVENTS='' - if [ -n "${EXECUTION_FILE:-}" ] && [ -f "$EXECUTION_FILE" ]; then - EVENTS="$(jq -c 'if type=="array" then . else [.] end' "$EXECUTION_FILE" 2>/dev/null)" - fi - REPORT='' - META='' - if [ -n "$EVENTS" ]; then - REPORT="$(printf '%s' "$EVENTS" | jq -r '.[] | select(.type=="assistant") | .message.content[]? | select(.type=="text") | .text' 2>/dev/null)" - # Allowlist, not del(.result): the execution-file schema is upstream and - # may grow fields we have not audited, so name what we publish. - META="$(printf '%s' "$EVENTS" | jq -S 'map(select(.type=="result"))[-1] // empty - | {is_error, subtype, terminal_reason, stop_reason, num_turns, - duration_ms, total_cost_usd, session_id, - output_tokens: .usage.output_tokens} - | with_entries(select(.value != null))' 2>/dev/null)" - fi - { - echo "## osv-scanner-prune report" - echo - if [ -z "$EVENTS" ]; then - echo "> No readable Claude execution file was produced — see the agent step logs." - elif [ -n "$REPORT" ]; then - echo "$REPORT" - else - echo "> The agent produced no text output — see the agent step logs." - fi - if [ -n "$META" ]; then - echo - echo "
Run metadata" - echo - echo '```json' - echo "$META" - echo '```' - echo - echo "
" - fi - } >> "$GITHUB_STEP_SUMMARY" + run: node "$RUNNER_TEMP/iyarc-prune-workflow.js" write-agent-summary - name: Require successful Claude completion if: always() && steps.claude.outcome != 'skipped' env: EXECUTION_FILE: ${{ steps.claude.outputs.execution_file }} - run: | - set -euo pipefail - if [ -z "${EXECUTION_FILE:-}" ] || [ ! -f "$EXECUTION_FILE" ]; then - echo "::error::no Claude execution file produced"; exit 1 - fi - EVENTS="$(jq -c 'if type=="array" then . else [.] end' "$EXECUTION_FILE")" - echo "=== result event (metadata only) ===" - echo "$EVENTS" | jq -c 'map(select(.type=="result"))[-1] // empty | del(.result)' - echo "=== assistant text turns (no tool output) ===" - echo "$EVENTS" | jq -r '.[] | select(.type=="assistant") | .message.content[]? | select(.type=="text") | .text' - RES="$(echo "$EVENTS" | jq -c 'map(select(.type=="result"))[-1] // empty')" - if ! echo "$RES" | jq -e '.is_error == false and .subtype == "success"' >/dev/null; then - echo "::error::Claude did not produce a successful result"; exit 1 - fi + run: node "$RUNNER_TEMP/iyarc-prune-workflow.js" require-successful-completion - - name: Check changes are ready for a pull request - id: changes + - name: Check allowed edits and prepare Git state for publication + id: publication env: BASE_SHA: ${{ steps.checkout.outputs.commit }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} IYARC_PRUNE_PR_BODY: ${{ runner.temp }}/iyarc-prune-pr.md - run: | - set -euo pipefail - if [ "$(git rev-parse HEAD)" != "$BASE_SHA" ]; then - echo "::error::Agent changed HEAD; validated edits must remain uncommitted"; exit 1 - fi - CHANGED_FILES="$(git diff --name-only "$BASE_SHA"; git diff --cached --name-only "$BASE_SHA"; git ls-files --others --exclude-standard)" - if [ -z "$CHANGED_FILES" ]; then - echo "No dependency changes to publish." >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - while IFS= read -r path; do - case "$path" in - osv-scanner.toml|package.json|yarn.lock) continue ;; - esac - if [[ ! "$path" =~ ^modules/[^/]+/package\.json$ ]]; then - echo "::error::Unexpected changed file: $path"; exit 1 - fi - done <<< "$CHANGED_FILES" - if git diff --quiet "$BASE_SHA" -- osv-scanner.toml; then - echo "::error::Dependency changes exist without an osv-scanner.toml change"; exit 1 - fi - if [ ! -s "$IYARC_PRUNE_PR_BODY" ]; then - echo "::error::Changes exist but the agent did not produce a validated pull request report"; exit 1 - fi - OPEN_PRS="$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --limit 1000 \ - --json number,headRefName --jq '.[] | select(.headRefName | startswith("osv-scanner-prune/"))')" - if [ -n "$OPEN_PRS" ]; then - echo "An open prune pull request already exists: $OPEN_PRS" >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - LABELS="$(gh label list --repo "$GITHUB_REPOSITORY" --limit 1000 --json name)" - for label in automated dependencies security; do - if ! jq -e --arg name "$label" 'any(.[]; (.name | ascii_downcase) == $name)' <<< "$LABELS" >/dev/null; then - gh label create "$label" --repo "$GITHUB_REPOSITORY" - fi - done - # Detached HEAD makes the publisher rebase onto newer master. Keep its - # local base refs at the validated commit and discard index-only state. - git reset --mixed "$BASE_SHA" - git checkout -B master "$BASE_SHA" - git update-ref refs/remotes/origin/master "$BASE_SHA" - echo "ready=true" >> "$GITHUB_OUTPUT" + run: node "$RUNNER_TEMP/iyarc-prune-workflow.js" prepare-pull-request - name: Create signed dependency pull request - if: steps.changes.outputs.ready == 'true' + if: steps.publication.outputs.ready == 'true' id: pull-request uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7 with: @@ -254,12 +140,7 @@ jobs: draft: false - name: Report pull request - if: steps.changes.outputs.ready == 'true' + if: steps.publication.outputs.ready == 'true' env: PR_URL: ${{ steps.pull-request.outputs.pull-request-url }} - run: | - if [ -z "$PR_URL" ]; then - echo "::error::Validated dependency changes did not produce a pull request"; exit 1 - fi - echo "Pull request: $PR_URL" >> "$GITHUB_STEP_SUMMARY" - echo '> **Action required:** Check the pull request for an **Approve workflows to run** banner. A maintainer must approve pending workflow runs and verify required checks pass before merging.' >> "$GITHUB_STEP_SUMMARY" + run: node "$RUNNER_TEMP/iyarc-prune-workflow.js" report-pull-request diff --git a/scripts/iyarc-prune.js b/scripts/iyarc-prune.js new file mode 100644 index 00000000000..dc66898a5fd --- /dev/null +++ b/scripts/iyarc-prune.js @@ -0,0 +1,262 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { createHash, randomUUID } = require('node:crypto'); +const { execFileSync } = require('node:child_process'); + +const env = process.env; +const labels = ['automated', 'dependencies', 'security']; + +function run(command, args) { + return execFileSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] }).replace(/\n+$/, ''); +} + +function setOutput(name, value) { + if (String(value).includes('\n')) { + const delimiter = `iyarc-${randomUUID()}`; + fs.appendFileSync(env.GITHUB_OUTPUT, `${name}<<${delimiter}\n${value}\n${delimiter}\n`); + } else { + fs.appendFileSync(env.GITHUB_OUTPUT, `${name}=${value}\n`); + } +} + +function writeSummary(text) { + fs.appendFileSync(env.GITHUB_STEP_SUMMARY, `${text}\n`); +} + +function installScanner() { + const directory = path.join(env.RUNNER_TEMP, 'osv-scanner'); + const executable = path.join(directory, 'osv-scanner'); + fs.mkdirSync(directory, { recursive: true }); + run('curl', [ + '--fail', + '--silent', + '--show-error', + '--location', + '--retry', + '3', + 'https://github.com/google/osv-scanner/releases/download/v2.3.8/osv-scanner_linux_amd64', + '--output', + executable, + ]); + const digest = createHash('sha256').update(fs.readFileSync(executable)).digest('hex'); + if (digest !== env.OSV_SCANNER_SHA256) { + throw new Error('Downloaded osv-scanner checksum does not match OSV_SCANNER_SHA256'); + } + fs.chmodSync(executable, 0o755); + console.log(run(executable, ['--version'])); + fs.appendFileSync(env.GITHUB_PATH, `${directory}\n`); +} + +function assumeInferenceRole() { + const credentials = JSON.parse( + run('aws', [ + 'sts', + 'assume-role', + '--role-arn', + 'arn:aws:iam::168000258654:role/BedrockInferenceRole', + '--role-session-name', + 'iyarc-prune-session', + '--query', + 'Credentials', + '--output', + 'json', + ]) + ); + console.log(`::add-mask::${credentials.SecretAccessKey}`); + console.log(`::add-mask::${credentials.SessionToken}`); + setOutput('aws-access-key-id', credentials.AccessKeyId); + setOutput('aws-secret-access-key', credentials.SecretAccessKey); + setOutput('aws-session-token', credentials.SessionToken); +} + +function loadInstructions() { + const instructions = fs.readFileSync('.claude/agents/iyarc-prune.md', 'utf8'); + const endOfFrontmatter = instructions.indexOf('\n---\n'); + setOutput('content', instructions.slice(endOfFrontmatter + '\n---\n'.length)); +} + +function readExecution() { + if (!env.EXECUTION_FILE || !fs.existsSync(env.EXECUTION_FILE)) { + throw new Error('no Claude execution file produced'); + } + const execution = JSON.parse(fs.readFileSync(env.EXECUTION_FILE, 'utf8')); + return Array.isArray(execution) ? execution : [execution]; +} + +function writeAgentSummary() { + try { + let events; + try { + events = readExecution(); + } catch { + writeSummary( + '## osv-scanner-prune report\n\n> No readable Claude execution file was produced — see the agent step logs.' + ); + return; + } + const report = events + .filter((event) => event?.type === 'assistant') + .flatMap((event) => event.message?.content ?? []) + .filter((content) => content?.type === 'text') + .map((content) => content.text) + .join('\n'); + const result = events.findLast((event) => event?.type === 'result'); + const parts = [ + '## osv-scanner-prune report', + report || '> The agent produced no text output — see the agent step logs.', + ]; + if (result) { + // Upstream can add fields; publish only metadata we have audited. + const metadata = Object.fromEntries( + Object.entries({ + is_error: result.is_error, + subtype: result.subtype, + terminal_reason: result.terminal_reason, + stop_reason: result.stop_reason, + num_turns: result.num_turns, + duration_ms: result.duration_ms, + total_cost_usd: result.total_cost_usd, + session_id: result.session_id, + output_tokens: result.usage?.output_tokens, + }).filter(([, value]) => value != null) + ); + parts.push( + `
Run metadata\n\n\`\`\`json\n${JSON.stringify( + metadata, + null, + 2 + )}\n\`\`\`\n\n
` + ); + } + writeSummary(parts.join('\n\n')); + } catch (error) { + // Reporting must not turn an otherwise successful agent run into a failure. + console.warn(`Could not write agent summary: ${error.message}`); + } +} + +function requireSuccessfulCompletion() { + const result = readExecution().findLast((event) => event?.type === 'result'); + if (result?.is_error !== false || result?.subtype !== 'success') { + throw new Error('Claude did not produce a successful result'); + } +} + +function requireAllowedChangesAndReport(checkoutCommit, changedFiles) { + for (const file of changedFiles) { + if ( + !['osv-scanner.toml', 'package.json', 'yarn.lock'].includes(file) && + !/^modules\/[^/]+\/package\.json$/.test(file) + ) { + throw new Error(`Unexpected changed file: ${file}`); + } + } + if (!run('git', ['diff', '--name-only', checkoutCommit, '--', 'osv-scanner.toml'])) { + throw new Error('Dependency changes exist without an osv-scanner.toml change'); + } + if ( + !env.IYARC_PRUNE_PR_BODY || + !fs.existsSync(env.IYARC_PRUNE_PR_BODY) || + fs.statSync(env.IYARC_PRUNE_PR_BODY).size === 0 + ) { + throw new Error('Changes exist but the agent did not produce a validated pull request report'); + } +} + +function ensureLabels() { + const existingLabels = JSON.parse( + run('gh', ['label', 'list', '--repo', env.GITHUB_REPOSITORY, '--limit', '1000', '--json', 'name']) + ).map((label) => label.name.toLowerCase()); + for (const label of labels) { + if (!existingLabels.includes(label)) { + run('gh', ['label', 'create', label, '--repo', env.GITHUB_REPOSITORY]); + } + } +} + +function collectChangedFiles(checkoutCommit) { + return new Set( + [ + ...run('git', ['diff', '--name-only', checkoutCommit]).split('\n'), + ...run('git', ['diff', '--cached', '--name-only', checkoutCommit]).split('\n'), + ...run('git', ['ls-files', '--others', '--exclude-standard']).split('\n'), + ].filter(Boolean) + ); +} + +function findOpenPrunePullRequests() { + return JSON.parse( + run('gh', [ + 'pr', + 'list', + '--repo', + env.GITHUB_REPOSITORY, + '--state', + 'open', + '--limit', + '1000', + '--json', + 'number,headRefName', + ]) + ).filter((pullRequest) => pullRequest.headRefName.startsWith('osv-scanner-prune/')); +} + +function preserveValidatedCheckout(checkoutCommit) { + // The pinned publisher rebases detached HEAD onto newer master. Keep its local + // base at the validated parent and stage only the validated working-tree bytes. + run('git', ['reset', '--mixed', checkoutCommit]); + run('git', ['checkout', '-B', 'master', checkoutCommit]); + run('git', ['update-ref', 'refs/remotes/origin/master', checkoutCommit]); +} + +function preparePullRequest() { + const checkoutCommit = env.BASE_SHA; + if (!checkoutCommit || run('git', ['rev-parse', 'HEAD']) !== checkoutCommit) { + throw new Error('Agent changed HEAD; validated edits must remain uncommitted'); + } + const changedFiles = collectChangedFiles(checkoutCommit); + if (changedFiles.size === 0) { + writeSummary('No dependency changes to publish.'); + return; + } + requireAllowedChangesAndReport(checkoutCommit, changedFiles); + const openPullRequests = findOpenPrunePullRequests(); + if (openPullRequests.length > 0) { + writeSummary(`An open prune pull request already exists: ${JSON.stringify(openPullRequests)}`); + return; + } + ensureLabels(); + preserveValidatedCheckout(checkoutCommit); + setOutput('ready', 'true'); +} + +function reportPullRequest() { + if (!env.PR_URL) { + throw new Error('Validated dependency changes did not produce a pull request'); + } + writeSummary(`Pull request: ${env.PR_URL}`); + writeSummary( + '> **Action required:** Check the pull request for an **Approve workflows to run** banner. A maintainer must approve pending workflow runs and verify required checks pass before merging.' + ); +} + +const operations = { + 'install-scanner': installScanner, + 'assume-inference-role': assumeInferenceRole, + 'load-instructions': loadInstructions, + 'write-agent-summary': writeAgentSummary, + 'require-successful-completion': requireSuccessfulCompletion, + 'prepare-pull-request': preparePullRequest, + 'report-pull-request': reportPullRequest, +}; + +try { + const operation = operations[process.argv[2]]; + if (!Object.hasOwn(operations, process.argv[2])) { + throw new Error(`Unknown iyarc-prune operation: ${process.argv[2]}`); + } + operation(); +} catch (error) { + console.error(`::error::${error.message}`); + process.exitCode = 1; +} diff --git a/scripts/tests/iyarc-prune.test.js b/scripts/tests/iyarc-prune.test.js new file mode 100644 index 00000000000..370b9d0e5ac --- /dev/null +++ b/scripts/tests/iyarc-prune.test.js @@ -0,0 +1,326 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { execFileSync, spawnSync } = require('node:child_process'); +const yaml = require('js-yaml'); + +const helper = path.resolve(__dirname, '../iyarc-prune.js'); +const workflow = yaml.load(fs.readFileSync(path.resolve(__dirname, '../../.github/workflows/iyarc-prune.yml'), 'utf8')); + +describe('iyarc-prune workflow operations', function () { + this.timeout(30000); + let directory; + let repository; + let base; + let env; + + function git(...args) { + return execFileSync('git', args, { cwd: repository, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + } + + function execute(operation, overrides = {}) { + return spawnSync(process.execPath, [helper, operation], { + cwd: repository, + env: { ...env, ...overrides }, + encoding: 'utf8', + }); + } + + function executeWorkflowStep(id) { + const step = workflow.jobs.prune.steps.find((step) => step.id === id); + return spawnSync('bash', ['-e', '-o', 'pipefail', '-c', step.run], { + cwd: repository, + env, + encoding: 'utf8', + }); + } + + function validatedChanges() { + fs.writeFileSync(path.join(repository, 'osv-scanner.toml'), 'validated exclusion removal\n'); + fs.writeFileSync(env.IYARC_PRUNE_PR_BODY, 'Validated dependency changes\n'); + } + + beforeEach(function () { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'iyarc-prune-test-')); + repository = path.join(directory, 'repository'); + fs.mkdirSync(repository); + git('init', '--quiet', '-b', 'master'); + git('config', 'user.name', 'Workflow Test'); + git('config', 'user.email', 'workflow-test@example.invalid'); + git('config', 'commit.gpgsign', 'false'); + git('config', 'core.hooksPath', '/dev/null'); + for (const file of ['osv-scanner.toml', 'package.json', 'yarn.lock', 'source.ts', 'modules/sample/package.json']) { + fs.mkdirSync(path.dirname(path.join(repository, file)), { recursive: true }); + fs.writeFileSync(path.join(repository, file), 'baseline\n'); + } + fs.mkdirSync(path.join(repository, 'scripts')); + fs.copyFileSync(helper, path.join(repository, 'scripts/iyarc-prune.js')); + git('add', '.'); + git('commit', '--quiet', '-m', 'fixture'); + base = git('rev-parse', 'HEAD'); + git('checkout', '--quiet', '--detach', base); + const bin = path.join(directory, 'bin'); + fs.mkdirSync(bin); + fs.writeFileSync( + path.join(bin, 'gh'), + `#!/usr/bin/env node +const fs = require('node:fs'); +const state = JSON.parse(fs.readFileSync(process.env.TEST_GH_STATE, 'utf8')); +const command = process.argv.slice(2, 4).join(' '); +if (state.fail === command) process.exit(1); +if (command === 'pr list') console.log(JSON.stringify(state.pullRequests || [])); +else if (command === 'label list') console.log(JSON.stringify(state.labels || [{name:'automated'},{name:'dependencies'},{name:'security'}])); +else if (command !== 'label create') process.exit(99); +`, + { mode: 0o755 } + ); + env = { + ...process.env, + BASE_SHA: base, + GITHUB_REPOSITORY: 'BitGo/BitGoJS', + GITHUB_OUTPUT: path.join(directory, 'output'), + GITHUB_STEP_SUMMARY: path.join(directory, 'summary'), + GITHUB_PATH: path.join(directory, 'github-path'), + IYARC_PRUNE_PR_BODY: path.join(directory, 'report.md'), + EXECUTION_FILE: path.join(directory, 'execution.json'), + RUNNER_TEMP: directory, + TEST_GH_STATE: path.join(directory, 'github-state.json'), + PATH: bin + path.delimiter + process.env.PATH, + }; + for (const file of [env.GITHUB_OUTPUT, env.GITHUB_STEP_SUMMARY, env.GITHUB_PATH]) fs.writeFileSync(file, ''); + fs.writeFileSync(env.TEST_GH_STATE, '{}'); + }); + + afterEach(function () { + fs.rmSync(directory, { recursive: true, force: true }); + }); + + it('does not publish when the working tree and index are unchanged', function () { + assert.equal(execute('prepare-pull-request').status, 0); + assert.equal(fs.readFileSync(env.GITHUB_OUTPUT, 'utf8'), ''); + }); + + it('accepts validated direct workspace manifests and preserves the checkout parent', function () { + validatedChanges(); + fs.mkdirSync(path.join(repository, 'modules/new')); + fs.writeFileSync(path.join(repository, 'modules/new/package.json'), '{}\n'); + assert.equal(execute('prepare-pull-request').status, 0); + assert.equal(fs.readFileSync(env.GITHUB_OUTPUT, 'utf8'), 'ready=true\n'); + assert.equal(git('symbolic-ref', '--short', 'HEAD'), 'master'); + assert.equal(git('rev-parse', 'refs/remotes/origin/master'), base); + assert.equal(git('rev-parse', 'HEAD'), base); + }); + + for (const file of [ + 'source.ts', + 'modules/sample/test/package.json', + 'modules/sample/src/nested/package.json', + 'package.json ', + ]) { + it(`rejects changes to ${JSON.stringify(file)}`, function () { + validatedChanges(); + fs.mkdirSync(path.dirname(path.join(repository, file)), { recursive: true }); + fs.writeFileSync(path.join(repository, file), 'unexpected\n'); + const result = execute('prepare-pull-request'); + assert.equal(result.status, 1); + assert.ok(result.stderr.includes(file)); + assert.equal(fs.readFileSync(env.GITHUB_OUTPUT, 'utf8'), ''); + }); + } + + it('rejects source changes hidden in the index while the working tree matches baseline', function () { + validatedChanges(); + fs.writeFileSync(path.join(repository, 'source.ts'), 'hidden source change\n'); + git('add', 'source.ts'); + fs.writeFileSync(path.join(repository, 'source.ts'), 'baseline\n'); + const result = execute('prepare-pull-request'); + assert.equal(result.status, 1); + assert.ok(result.stderr.includes('source.ts')); + assert.equal(fs.readFileSync(env.GITHUB_OUTPUT, 'utf8'), ''); + }); + + it('publishes the validated working-tree bytes rather than a different staged dependency value', function () { + validatedChanges(); + const manifest = path.join(repository, 'package.json'); + fs.writeFileSync(manifest, '{"version":2}\n'); + git('add', '.'); + const validatedTree = git('write-tree'); + fs.writeFileSync(manifest, '{"version":99}\n'); + git('add', 'package.json'); + fs.writeFileSync(manifest, '{"version":2}\n'); + assert.equal(execute('prepare-pull-request').status, 0); + assert.equal(git('diff', '--cached', '--name-only', base), ''); + git('add', '.'); + assert.equal(git('write-tree'), validatedTree); + }); + + it('rejects an agent-created commit even when its working tree is clean', function () { + git('commit', '--quiet', '--allow-empty', '-m', 'unexpected agent commit'); + assert.equal(execute('prepare-pull-request').status, 1); + assert.equal(fs.readFileSync(env.GITHUB_OUTPUT, 'utf8'), ''); + }); + + it('rejects dependency changes without a changed exclusion file', function () { + fs.writeFileSync(path.join(repository, 'package.json'), 'changed\n'); + fs.writeFileSync(env.IYARC_PRUNE_PR_BODY, 'validated\n'); + assert.equal(execute('prepare-pull-request').status, 1); + }); + + for (const report of [undefined, '']) { + it(`rejects ${report === undefined ? 'a missing' : 'an empty'} validation report`, function () { + validatedChanges(); + if (report === undefined) fs.unlinkSync(env.IYARC_PRUNE_PR_BODY); + else fs.writeFileSync(env.IYARC_PRUNE_PR_BODY, report); + assert.equal(execute('prepare-pull-request').status, 1); + assert.equal(fs.readFileSync(env.GITHUB_OUTPUT, 'utf8'), ''); + }); + } + + it('skips an existing prune pull request but not unrelated pull requests', function () { + validatedChanges(); + fs.writeFileSync(env.TEST_GH_STATE, JSON.stringify({ pullRequests: [{ number: 1, headRefName: 'unrelated' }] })); + assert.equal(execute('prepare-pull-request').status, 0); + assert.equal(fs.readFileSync(env.GITHUB_OUTPUT, 'utf8'), 'ready=true\n'); + fs.writeFileSync(env.GITHUB_OUTPUT, ''); + fs.writeFileSync( + env.TEST_GH_STATE, + JSON.stringify({ pullRequests: [{ number: 2, headRefName: 'osv-scanner-prune/previous' }] }) + ); + assert.equal(execute('prepare-pull-request').status, 0); + assert.equal(fs.readFileSync(env.GITHUB_OUTPUT, 'utf8'), ''); + }); + + for (const command of ['pr list', 'label list', 'label create']) { + it(`fails publication when ${command} fails`, function () { + validatedChanges(); + fs.writeFileSync(env.TEST_GH_STATE, JSON.stringify({ fail: command, labels: [] })); + assert.equal(execute('prepare-pull-request').status, 1); + assert.equal(fs.readFileSync(env.GITHUB_OUTPUT, 'utf8'), ''); + }); + } + + it('preserves existing labels regardless of case', function () { + validatedChanges(); + fs.writeFileSync( + env.TEST_GH_STATE, + JSON.stringify({ + fail: 'label create', + labels: [{ name: 'Automated' }, { name: 'Dependencies' }, { name: 'Security' }], + }) + ); + assert.equal(execute('prepare-pull-request').status, 0); + assert.equal(fs.readFileSync(env.GITHUB_OUTPUT, 'utf8'), 'ready=true\n'); + }); + + it('loads a trusted helper outside the repository after the agent overwrites the working copy', function () { + validatedChanges(); + fs.writeFileSync(path.join(repository, 'scripts/iyarc-prune.js'), 'process.exit(0);\n'); + assert.equal(executeWorkflowStep('trusted-helper').status, 0); + const result = executeWorkflowStep('publication'); + assert.equal(result.status, 1); + assert.ok(result.stderr.includes('scripts/iyarc-prune.js')); + fs.writeFileSync( + path.join(repository, 'scripts/iyarc-prune.js'), + git('show', `${base}:scripts/iyarc-prune.js`) + '\n' + ); + assert.equal(executeWorkflowStep('publication').status, 0); + assert.equal(fs.readFileSync(env.GITHUB_OUTPUT, 'utf8'), 'ready=true\n'); + }); + + it('accepts successful completion in both supported execution encodings', function () { + const success = { type: 'result', is_error: false, subtype: 'success' }; + for (const execution of [success, [success]]) { + fs.writeFileSync(env.EXECUTION_FILE, JSON.stringify(execution)); + assert.equal(execute('require-successful-completion').status, 0); + } + }); + + for (const execution of [ + '{', + '[]', + JSON.stringify([{ type: 'result', subtype: 'success' }]), + JSON.stringify([{ type: 'result', is_error: true, subtype: 'error_max_turns' }]), + ]) { + it(`rejects unsuccessful or unreadable execution ${execution}`, function () { + fs.writeFileSync(env.EXECUTION_FILE, execution); + assert.equal(execute('require-successful-completion').status, 1); + }); + } + + it('uses the final result rather than an earlier success', function () { + fs.writeFileSync( + env.EXECUTION_FILE, + JSON.stringify([ + { type: 'result', is_error: false, subtype: 'success' }, + { type: 'result', is_error: true, subtype: 'error' }, + ]) + ); + assert.equal(execute('require-successful-completion').status, 1); + }); + + it('treats missing execution as a completion failure but not a reporting failure', function () { + assert.equal(execute('require-successful-completion').status, 1); + assert.equal(execute('write-agent-summary').status, 0); + }); + + it('keeps tool output and unaudited result fields out of the published summary', function () { + fs.writeFileSync( + env.EXECUTION_FILE, + JSON.stringify([ + { + type: 'assistant', + message: { + content: [ + { type: 'text', text: 'public report' }, + { type: 'tool_use', input: 'private tool input' }, + ], + }, + }, + { + type: 'result', + is_error: false, + subtype: 'success', + result: 'private result', + future_secret: 'private new metadata', + usage: { output_tokens: 12 }, + }, + ]) + ); + assert.equal(execute('write-agent-summary').status, 0); + const summary = fs.readFileSync(env.GITHUB_STEP_SUMMARY, 'utf8'); + assert.ok(summary.includes('public report')); + assert.ok(!summary.includes('private')); + assert.ok(summary.includes('output_tokens')); + }); + + it('does not fail completion because the summary cannot be written', function () { + fs.writeFileSync(env.EXECUTION_FILE, JSON.stringify([{ type: 'result', is_error: false, subtype: 'success' }])); + assert.equal(execute('write-agent-summary', { GITHUB_STEP_SUMMARY: directory }).status, 0); + assert.equal(execute('require-successful-completion').status, 0); + }); + + it('fails when publication did not return a pull request URL', function () { + assert.equal(execute('report-pull-request', { PR_URL: '' }).status, 1); + }); + + it('never executes a downloaded scanner whose checksum is wrong', function () { + const marker = path.join(directory, 'untrusted-executed'); + const scanner = path.join(directory, 'download'); + fs.writeFileSync(scanner, `#!/bin/sh\ntouch '${marker}'\n`); + const curl = path.join(directory, 'bin/curl'); + fs.writeFileSync( + curl, + `#!/usr/bin/env node +const fs = require('node:fs'); +const args = process.argv.slice(2); +fs.copyFileSync(process.env.TEST_DOWNLOAD, args[args.indexOf('--output') + 1]); +`, + { mode: 0o755 } + ); + const result = execute('install-scanner', { TEST_DOWNLOAD: scanner, OSV_SCANNER_SHA256: '0'.repeat(64) }); + assert.equal(result.status, 1); + assert.equal(fs.existsSync(marker), false); + assert.equal(fs.readFileSync(env.GITHUB_PATH, 'utf8'), ''); + }); +});