diff --git a/.claude/agents/iyarc-prune.md b/.claude/agents/iyarc-prune.md index 61c1057d3b7..b214817a21b 100644 --- a/.claude/agents/iyarc-prune.md +++ b/.claude/agents/iyarc-prune.md @@ -22,14 +22,31 @@ over an unsafe or unverified bump. ## Environment notes +- 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. -- The release audit gate is: +- 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: ``` - 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 +69,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 +82,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 +121,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 +134,55 @@ 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](../../scripts/iyarc-prune.js#L145-L219) + 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 reads its helper from the original + checkout commit 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. - - 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..17ac411fd19 100644 --- a/.github/workflows/iyarc-prune.yml +++ b/.github/workflows/iyarc-prune.yml @@ -20,12 +20,25 @@ jobs: prune: runs-on: ubuntu-latest timeout-minutes: 60 + defaults: + run: + shell: bash 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 +49,11 @@ jobs: - name: Enable Corepack (yarn 1.22.22) run: corepack enable + - name: Install release audit scanner + env: + OSV_SCANNER_SHA256: bc98e15319ed0d515e3f9235287ba53cdc5535d576d24fd573978ecfe9ab92dc + run: node scripts/iyarc-prune.js install-scanner + - name: Configure AWS Credentials (OIDC) uses: aws-actions/configure-aws-credentials@v6 with: @@ -44,41 +62,22 @@ jobs: - name: Assume Bedrock inference role 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) id: claude 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' @@ -88,70 +87,56 @@ 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. + # Agent edits must not change the code that validates or publishes them. - name: Write agent report to job summary - if: always() + if: always() && steps.claude.outcome != 'skipped' env: + BASE_SHA: ${{ steps.checkout.outputs.commit }} 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" - - - name: Surface Claude error and fail on is_error - if: always() + run: git show "$BASE_SHA:scripts/iyarc-prune.js" | node - write-agent-summary + + - name: Require successful Claude completion + if: always() && steps.claude.outcome != 'skipped' env: + BASE_SHA: ${{ steps.checkout.outputs.commit }} 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 -r '.is_error // false')" = "true" ]; then - echo "::error::Claude returned is_error=true"; exit 1 - fi + run: git show "$BASE_SHA:scripts/iyarc-prune.js" | node - require-successful-completion + + - 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: git show "$BASE_SHA:scripts/iyarc-prune.js" | node - prepare-pull-request + + - 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: + BASE_SHA: ${{ steps.checkout.outputs.commit }} + PR_URL: ${{ steps.pull-request.outputs.pull-request-url }} + run: git show "$BASE_SHA:scripts/iyarc-prune.js" | node - report-pull-request diff --git a/scripts/iyarc-prune.js b/scripts/iyarc-prune.js new file mode 100644 index 00000000000..bd14cd32368 --- /dev/null +++ b/scripts/iyarc-prune.js @@ -0,0 +1,250 @@ +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 validateChanges(base, 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', base, '--', '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 preparePullRequest() { + const base = env.BASE_SHA; + if (!base || run('git', ['rev-parse', 'HEAD']) !== base) { + throw new Error('Agent changed HEAD; validated edits must remain uncommitted'); + } + const changedFiles = new Set( + [ + ...run('git', ['diff', '--name-only', base]).split('\n'), + ...run('git', ['diff', '--cached', '--name-only', base]).split('\n'), + ...run('git', ['ls-files', '--others', '--exclude-standard']).split('\n'), + ].filter(Boolean) + ); + if (changedFiles.size === 0) { + writeSummary('No dependency changes to publish.'); + return; + } + validateChanges(base, changedFiles); + const openPullRequests = 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/')); + if (openPullRequests.length > 0) { + writeSummary(`An open prune pull request already exists: ${JSON.stringify(openPullRequests)}`); + return; + } + ensureLabels(); + // 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', base]); + run('git', ['checkout', '-B', 'master', base]); + run('git', ['update-ref', 'refs/remotes/origin/master', base]); + 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..01a6da74cd6 --- /dev/null +++ b/scripts/tests/iyarc-prune.test.js @@ -0,0 +1,310 @@ +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 helper = path.resolve(__dirname, '../iyarc-prune.js'); + +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 = {}, source) { + return spawnSync(process.execPath, [source === undefined ? helper : '-', operation], { + cwd: repository, + env: { ...env, ...overrides }, + input: source, + 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('validates the trusted committed helper even if the agent overwrites the working copy', function () { + validatedChanges(); + fs.writeFileSync(path.join(repository, 'scripts/iyarc-prune.js'), 'process.exit(0);\n'); + const trustedSource = git('show', `${base}:scripts/iyarc-prune.js`); + const result = execute('prepare-pull-request', {}, trustedSource); + assert.equal(result.status, 1); + assert.ok(result.stderr.includes('scripts/iyarc-prune.js')); + }); + + 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'), ''); + }); +});