diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d85540d..2ac8229 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -312,6 +312,54 @@ jobs: gh release edit "$VERSION" --repo docker/docker-agent-action --notes-file /tmp/release-notes-filtered.md echo "✅ Release notes filtered and updated." + - name: Flag caller-facing permission increases as breaking + if: ${{ !inputs.pre_release }} + env: + VERSION: ${{ steps.version.outputs.version }} + PREVIOUS: ${{ steps.version.outputs.previous }} + GH_TOKEN: ${{ env.GITHUB_APP_TOKEN }} + run: | + # A reusable-workflow caller cannot elevate permissions: when a release + # raises what review-pr.yml requests from its caller, existing callers + # fail GitHub's startup validation until their permissions: block is + # updated (issue #72 — v2.0.3 raised actions: read → write and broke + # callers granting actions: read). Compare against the previous stable + # release and prepend a breaking-change migration warning to the + # generated notes when the requirement increased; the helper prints + # nothing when it is unchanged or reduced. The workspace copy of + # review-pr.yml is authoritative here: the release-commit passes only + # rewrote `uses:` pins, never permissions. + if [ -z "$PREVIOUS" ]; then + echo "ℹ️ First release — no previous requirement to compare against." + exit 0 + fi + PREV_FILE=/tmp/review-pr-previous.yml + if ! git show "${PREVIOUS}:.github/workflows/review-pr.yml" > "$PREV_FILE" 2>/dev/null; then + echo "ℹ️ ${PREVIOUS} has no .github/workflows/review-pr.yml — nothing to compare." + exit 0 + fi + # The tag and GitHub release already exist here, so a helper failure + # must not strand the rest of the release pipeline: annotate and skip + # the safeguard instead. stdout carries only the warning markdown + # (stderr streams to the log), and on a non-zero exit any partial + # stdout is discarded so an error can never be prepended to the notes + # as markdown. The gh calls below stay fatal: once a valid breaking + # warning exists, dropping it would ship misleading release notes. + HELPER_STATUS=0 + WARNING=$(node "$GITHUB_WORKSPACE/dist/caller-permissions.js" "$PREV_FILE" ".github/workflows/review-pr.yml") || HELPER_STATUS=$? + if [ "$HELPER_STATUS" -ne 0 ]; then + echo "::warning title=Caller-permissions safeguard skipped::dist/caller-permissions.js exited ${HELPER_STATUS} comparing review-pr.yml against ${PREVIOUS} (see step log for details). Diff the caller-facing permissions manually and prepend a breaking-change warning to the ${VERSION} release notes if any level increased." + exit 0 + fi + if [ -z "$WARNING" ]; then + echo "✅ Caller permissions unchanged since ${PREVIOUS} — release notes left as generated." + exit 0 + fi + NOTES=$(gh release view "$VERSION" --repo docker/docker-agent-action --json body --jq '.body') + { printf '%s\n\n' "$WARNING"; printf '%s' "$NOTES"; } > /tmp/release-notes-with-warning.md + gh release edit "$VERSION" --repo docker/docker-agent-action --notes-file /tmp/release-notes-with-warning.md + echo "⚠️ Prepended caller-permissions breaking-change warning to ${VERSION} release notes." + publish-agent: name: Push review-pr agent to Docker Hub needs: release diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index c538e59..f6210f0 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -63,6 +63,27 @@ jobs: chmod +x test-job-summary.sh ./test-job-summary.sh + test-release-caller-permissions: + name: Release Caller Permissions Tests + runs-on: ubuntu-latest + if: | + github.event_name == 'push' || + github.event_name == 'workflow_dispatch' || + github.event.workflow_run.conclusion == 'success' + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || '' }} + + - name: Run release caller-permissions tests + run: | + cd tests + chmod +x test-release-caller-permissions.sh + ./test-release-caller-permissions.sh + resolve-context: name: Resolve PR Context runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 5fc4c1b..bd037c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,10 @@ Anything else here (workflows under `.github/workflows/`, scripts, tests) exists │ ├── add-reaction/ # Adds emoji reactions to issue/PR comments. │ │ ├── index.ts # Entry → bundled to dist/add-reaction.js │ │ └── __tests__/ +│ ├── caller-permissions/ # Release safeguard: diffs the caller-facing permission requirement of review-pr.yml between releases (issue #72). +│ │ ├── index.ts # CLI entry → bundled to dist/caller-permissions.js (used by release.yml to prepend a breaking-change warning to release notes). +│ │ ├── caller-permissions.ts # Permissions extractor + requirement diff (none < read < write) + warning renderer. +│ │ └── __tests__/ │ ├── check-org-membership/ # Authorizes a review: auto-run on PR-author membership, review_requested on the (trusted, timeline-derived) requester. Resolves PR author via pulls.get. │ │ ├── index.ts # Entry → bundled to dist/check-org-membership.js (standalone CLI + library). │ │ └── __tests__/ @@ -118,9 +122,10 @@ Anything else here (workflows under `.github/workflows/`, scripts, tests) exists │ └── add-pr-reviewer-to-repo/ │ └── SKILL.md # Skill: set up or upgrade a repo to use the PR reviewer reusable workflow. │ -└── tests/ # Shell-based integration tests for action.yml bash logic. +└── tests/ # Shell-based integration tests for action.yml / release.yml bash logic. ├── test-job-summary.sh ├── test-output-extraction.sh + ├── test-release-caller-permissions.sh # Exercises the release.yml caller-permissions safeguard step (helper failure must be non-fatal). ├── out.diff # Fixture used by test-output-extraction.sh └── test.diff # Fixture used by test-output-extraction.sh ``` @@ -154,7 +159,7 @@ Anything else here (workflows under `.github/workflows/`, scripts, tests) exists - `pnpm test` — Vitest "unit" project (`src/**/__tests__/**/*.test.ts`). - `pnpm test:integration` — Vitest "integration" project (`*.integration.test.ts`). -- `tests/*.sh` are integration tests for the **shell logic** inside `action.yml` (output extraction, job summary, etc.). Run them when changing the bash blocks of `action.yml`. +- `tests/*.sh` are integration tests for **shell logic** embedded in YAML (output extraction and job summary in `action.yml`, the release-notes caller-permissions safeguard in `release.yml`). Run them when changing the corresponding bash blocks. - Security unit tests live in `src/security/__tests__/security.test.ts` (Vitest) and run as part of `pnpm test`. Run them when changing anything under `src/security/`. - The PR review agent has a separate eval suite under `review-pr/agents/evals/`. Run with `docker agent eval review-pr/agents/pr-review.yaml review-pr/agents/evals/`. @@ -219,9 +224,10 @@ pnpm test # Integration tests (Vitest) pnpm test:integration -# Shell-based integration tests for action.yml bash logic +# Shell-based integration tests for shell logic embedded in YAML (action.yml, release.yml) bash tests/test-job-summary.sh bash tests/test-output-extraction.sh +bash tests/test-release-caller-permissions.sh # Format + lint (write fixes) pnpm format @@ -244,6 +250,7 @@ When you change something, verify: - [ ] Did you change anything under `src/security/`? Re-run `pnpm test` (covers `src/security/__tests__/security.test.ts`) and confirm the threat model above is still covered. - [ ] Did you bump a pinned `uses:` SHA? Update the trailing version comment too. - [ ] Did you change a `` marker, an output name, or an env var name? Search the repo (and consumer documentation) for references first — these are public contracts. +- [ ] Did you increase any caller-facing permission requested by `.github/workflows/review-pr.yml` (workflow-level or job-level `permissions:`)? That is a **breaking change** for existing callers of the reusable workflow — update the documented `permissions:` blocks in `README.md` and `review-pr/README.md`. The release workflow's caller-permissions safeguard adds the release-notes warning automatically. ## Things to avoid diff --git a/README.md b/README.md index 660d898..3d32a89 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,24 @@ jobs: For comprehensive documentation on setting up AI-powered PR reviews, including features like automatic reviews, requesting a review from `docker-agent`, feedback learning, and customization options, see the **[PR Review documentation](review-pr/README.md)**. +The job that calls the reusable workflow must grant exactly these permissions: + +```yaml +jobs: + review: + uses: docker/docker-agent-action/.github/workflows/review-pr.yml@VERSION + permissions: + contents: read # Read repository files and PR diffs + pull-requests: write # Post review comments + issues: write # Create security incident issues if secrets detected + checks: write # Show review progress as a check run + id-token: write # Required for OIDC authentication to AWS Secrets Manager + actions: write # Required since v2.0.3 — review-lock cache cleanup and feedback artifacts +``` + +> [!IMPORTANT] +> **`actions: write` is required since v2.0.3** (earlier releases needed only `actions: read`). A called workflow cannot elevate its caller's permissions, so a caller job granting only `actions: read` fails GitHub's workflow validation at startup — no job even runs. This applies only to callers of the reusable PR-review workflow shown above; workflows using the root `docker/docker-agent-action` action directly need only the [permissions listed earlier](#permissions). See the [PR Review documentation](review-pr/README.md#quick-start) for complete setup, including the two-workflow pattern for fork PRs. + For external or fork contributor PRs, an org member approves the workflow run and then requests a review from `docker-agent` via GitHub's native review request UI (no special commands or workflow inputs required). See [External and fork contributor PRs](review-pr/README.md#external-and-fork-contributor-prs). ### Manual Trigger with Inputs diff --git a/review-pr/README.md b/review-pr/README.md index 5baa053..782ee9b 100644 --- a/review-pr/README.md +++ b/review-pr/README.md @@ -6,6 +6,9 @@ AI-powered pull request review using a multi-agent system. Analyzes code changes ## Quick Start +> [!IMPORTANT] +> The calling job must grant every permission shown in the examples below. Since **v2.0.3** that includes **`actions: write`** (earlier releases needed only `actions: read`). A called workflow cannot elevate its caller's permissions, so a caller still granting `actions: read` fails GitHub's workflow validation at startup — before any job runs. Update the `permissions:` block when upgrading. + ### Same-repo PRs (1 workflow) If your repo only accepts PRs from branches within the same repo (no forks), you need a single workflow file: diff --git a/src/caller-permissions/__tests__/caller-permissions.test.ts b/src/caller-permissions/__tests__/caller-permissions.test.ts new file mode 100644 index 0000000..76561da --- /dev/null +++ b/src/caller-permissions/__tests__/caller-permissions.test.ts @@ -0,0 +1,397 @@ +// Copyright The Docker Agent Action authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Unit tests for src/caller-permissions. + * + * Covers: + * - extraction of workflow-level and job-level permissions blocks + * (block maps, trailing comments, inline {}, flow maps, read-all/write-all) + * - indentation scoping: step inputs and block scalars never leak in + * - caller-requirement semantics: job block REPLACES workflow block, + * jobs without a block inherit the workflow block, max across jobs + * - diffing: increases only (none < read < write), reductions ignored + * - warning rendering and the CLI I/O wrapper (first release, errors) + * - a pin on the real .github/workflows/review-pr.yml requirement so any + * caller-facing permission change is loud in review (issue #72) + */ +import { readFileSync } from 'node:fs'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + computeCallerRequirement, + diffCallerRequirements, + generateCallerPermissionsWarning, + parseWorkflowPermissions, + renderBreakingChangeWarning, +} from '../caller-permissions.js'; + +/** Shape of .github/workflows/review-pr.yml at v2.0.2 (review job: no `actions`). */ +const WORKFLOW_V202 = ` +name: PR Review +on: + workflow_call: + inputs: + pr-number: + required: false + type: string + +permissions: + contents: read + pull-requests: write + issues: write + id-token: write + actions: read # download-artifact across workflow_run boundary + +# A column-0 comment between blocks must not terminate parsing early. +concurrency: + group: pr-review-\${{ github.run_id }} + cancel-in-progress: false + +jobs: + resolve-context: + if: inputs.trigger-run-id != '' + runs-on: ubuntu-latest + steps: + - name: Read context + run: echo ok + + review: + needs: [resolve-context] + if: | + always() && ( + (github.event_name == 'issue_comment' && + github.event.comment.user.type != 'Bot') + ) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + issues: write + id-token: write + checks: write + steps: + - name: Run review + run: echo ok + + reply-to-feedback: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + issues: write + id-token: write + actions: read # download cross-run artifacts + steps: + - name: Reply + run: echo ok +`; + +/** Same workflow at v2.0.3: the review job added `actions: write`. */ +const WORKFLOW_V203 = WORKFLOW_V202.replace( + ' checks: write\n', + ' checks: write\n actions: write # cache delete for review-lock release cleanup\n', +); + +describe('parseWorkflowPermissions — block extraction', () => { + it('extracts the workflow-level block with trailing comments', () => { + const wf = parseWorkflowPermissions(WORKFLOW_V202); + expect(wf.workflow).toEqual({ + contents: 'read', + 'pull-requests': 'write', + issues: 'write', + 'id-token': 'write', + actions: 'read', + }); + }); + + it('extracts job-level blocks and leaves jobs without one undefined', () => { + const wf = parseWorkflowPermissions(WORKFLOW_V202); + expect(wf.jobs.map((j) => j.id)).toEqual(['resolve-context', 'review', 'reply-to-feedback']); + expect(wf.jobs[0].permissions).toBeUndefined(); + expect(wf.jobs[1].permissions).toEqual({ + contents: 'read', + 'pull-requests': 'write', + issues: 'write', + 'id-token': 'write', + checks: 'write', + }); + expect(wf.jobs[2].permissions).toEqual({ + contents: 'read', + 'pull-requests': 'write', + issues: 'write', + 'id-token': 'write', + actions: 'read', + }); + }); + + it('returns undefined workflow block and no job blocks when none are declared', () => { + const wf = parseWorkflowPermissions('name: CI\njobs:\n build:\n runs-on: ubuntu-latest\n'); + expect(wf.workflow).toBeUndefined(); + expect(wf.jobs).toEqual([{ id: 'build', permissions: undefined }]); + }); + + it('parses the inline empty map {} as no requested permissions', () => { + const wf = parseWorkflowPermissions('permissions: {}\njobs:\n a:\n permissions: {}\n'); + expect(wf.workflow).toEqual({}); + expect(wf.jobs[0].permissions).toEqual({}); + }); + + it('parses inline flow maps', () => { + const wf = parseWorkflowPermissions( + 'jobs:\n a:\n permissions: { contents: read, actions: write }\n', + ); + expect(wf.jobs[0].permissions).toEqual({ contents: 'read', actions: 'write' }); + }); + + it('parses the read-all / write-all shorthands as the * pseudo-scope', () => { + expect(parseWorkflowPermissions('permissions: read-all\n').workflow).toEqual({ '*': 'read' }); + expect(parseWorkflowPermissions('permissions: write-all\n').workflow).toEqual({ + '*': 'write', + }); + }); + + it('ignores permissions-like keys nested deeper than a job body (step inputs)', () => { + const source = [ + 'jobs:', + ' deploy:', + ' runs-on: ubuntu-latest', + ' steps:', + ' - uses: some/action@v1', + ' with:', + ' permissions: write', + '', + ].join('\n'); + const wf = parseWorkflowPermissions(source); + expect(wf.jobs[0].permissions).toBeUndefined(); + }); + + it('is not confused by block-scalar content (if: | conditions, run: | scripts)', () => { + const wf = parseWorkflowPermissions(WORKFLOW_V202); + // The `if: |` scalar inside the review job contains colon-bearing lines; + // none of them may leak into any permissions block. + expect(wf.jobs[1].permissions).not.toHaveProperty('always()'); + expect(Object.keys(wf.jobs[1].permissions ?? {})).toHaveLength(5); + }); + + it('throws on an unrecognized access level', () => { + expect(() => parseWorkflowPermissions('permissions:\n actions: banana\n')).toThrow( + /Unrecognized permission level "banana" at line 2/, + ); + }); + + it('throws on a malformed block entry', () => { + expect(() => parseWorkflowPermissions('permissions:\n nested:\n deeper: read\n')).toThrow( + /Malformed permissions entry at line 2/, + ); + }); + + it('throws on an unrecognized inline value', () => { + expect(() => parseWorkflowPermissions('permissions: everything\n')).toThrow( + /Unrecognized permissions value "everything" at line 1/, + ); + }); +}); + +describe('computeCallerRequirement', () => { + it('takes the per-scope maximum across jobs (v2.0.2 shape → actions: read)', () => { + const req = computeCallerRequirement(parseWorkflowPermissions(WORKFLOW_V202)); + expect(req).toEqual({ + contents: 'read', + 'pull-requests': 'write', + issues: 'write', + 'id-token': 'write', + checks: 'write', + actions: 'read', + }); + }); + + it('reflects the v2.0.3 review-job increase (actions: write)', () => { + const req = computeCallerRequirement(parseWorkflowPermissions(WORKFLOW_V203)); + expect(req.actions).toBe('write'); + }); + + it('lets a job-level block REPLACE the workflow-level block, not merge with it', () => { + // Every job overrides — the workflow-level `actions: read` never applies. + const source = [ + 'permissions:', + ' actions: read', + 'jobs:', + ' a:', + ' permissions:', + ' contents: read', + '', + ].join('\n'); + const req = computeCallerRequirement(parseWorkflowPermissions(source)); + expect(req).toEqual({ contents: 'read' }); + }); + + it('applies the workflow-level block to jobs without their own', () => { + const source = ['permissions:', ' actions: read', 'jobs:', ' a:', ' runs-on: x', ''].join( + '\n', + ); + const req = computeCallerRequirement(parseWorkflowPermissions(source)); + expect(req).toEqual({ actions: 'read' }); + }); + + it('drops explicit none entries — they impose no caller requirement', () => { + const source = ['jobs:', ' a:', ' permissions:', ' contents: none', ''].join('\n'); + expect(computeCallerRequirement(parseWorkflowPermissions(source))).toEqual({}); + }); + + it('falls back to the workflow-level block when no jobs are present', () => { + const req = computeCallerRequirement( + parseWorkflowPermissions('permissions:\n actions: read\n'), + ); + expect(req).toEqual({ actions: 'read' }); + }); +}); + +describe('diffCallerRequirements', () => { + it('reports a read → write increase', () => { + expect(diffCallerRequirements({ actions: 'read' }, { actions: 'write' })).toEqual([ + { scope: 'actions', from: 'read', to: 'write' }, + ]); + }); + + it('reports newly required scopes as increases from none', () => { + expect(diffCallerRequirements({}, { actions: 'read', checks: 'write' })).toEqual([ + { scope: 'actions', from: 'none', to: 'read' }, + { scope: 'checks', from: 'none', to: 'write' }, + ]); + }); + + it('does not flag reductions or removed scopes', () => { + expect( + diffCallerRequirements({ actions: 'write', checks: 'write' }, { actions: 'read' }), + ).toEqual([]); + }); + + it('returns [] when requirements are identical', () => { + expect(diffCallerRequirements({ actions: 'write' }, { actions: 'write' })).toEqual([]); + }); + + it('detects the v2.0.2 → v2.0.3 incident: only actions read → write', () => { + const prev = computeCallerRequirement(parseWorkflowPermissions(WORKFLOW_V202)); + const cur = computeCallerRequirement(parseWorkflowPermissions(WORKFLOW_V203)); + expect(diffCallerRequirements(prev, cur)).toEqual([ + { scope: 'actions', from: 'read', to: 'write' }, + ]); + }); + + it('does not flag the reverse direction (a future reduction back to read)', () => { + const prev = computeCallerRequirement(parseWorkflowPermissions(WORKFLOW_V203)); + const cur = computeCallerRequirement(parseWorkflowPermissions(WORKFLOW_V202)); + expect(diffCallerRequirements(prev, cur)).toEqual([]); + }); + + it('treats a wildcard grant as covering explicit scopes', () => { + // read-all → an explicit write is an increase; an explicit read is not. + expect(diffCallerRequirements({ '*': 'read' }, { contents: 'write' })).toEqual([ + { scope: 'contents', from: 'read', to: 'write' }, + ]); + expect(diffCallerRequirements({ '*': 'read' }, { contents: 'read' })).toEqual([]); + }); +}); + +describe('renderBreakingChangeWarning', () => { + it('returns an empty string when there is nothing to warn about', () => { + expect(renderBreakingChangeWarning([])).toBe(''); + }); + + it('renders a labeled warning with scope bullets, migration note, and docs link', () => { + const warning = renderBreakingChangeWarning([{ scope: 'actions', from: 'read', to: 'write' }]); + expect(warning).toContain('## ⚠️ Breaking change'); + expect(warning).toContain('- `actions`: `read` → `write`'); + expect(warning).toContain('**before** upgrading'); + expect(warning).toContain("fail GitHub's workflow validation at startup"); + expect(warning).toContain( + 'https://github.com/docker/docker-agent-action/blob/main/review-pr/README.md#quick-start', + ); + }); + + it('renders newly required scopes as "not previously required"', () => { + const warning = renderBreakingChangeWarning([{ scope: 'checks', from: 'none', to: 'write' }]); + expect(warning).toContain('- `checks`: not previously required → `write`'); + }); +}); + +describe('generateCallerPermissionsWarning — I/O wrapper', () => { + async function makeTmpDir(): Promise { + return mkdtemp(join(tmpdir(), 'caller-permissions-test-')); + } + + it('produces the warning for a v2.0.2 → v2.0.3 style increase', async () => { + const dir = await makeTmpDir(); + try { + const prev = join(dir, 'previous.yml'); + const cur = join(dir, 'current.yml'); + await writeFile(prev, WORKFLOW_V202, 'utf-8'); + await writeFile(cur, WORKFLOW_V203, 'utf-8'); + const warning = generateCallerPermissionsWarning(prev, cur); + expect(warning).toContain('- `actions`: `read` → `write`'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('returns an empty string when the requirement did not increase', async () => { + const dir = await makeTmpDir(); + try { + const prev = join(dir, 'previous.yml'); + const cur = join(dir, 'current.yml'); + await writeFile(prev, WORKFLOW_V203, 'utf-8'); + await writeFile(cur, WORKFLOW_V203, 'utf-8'); + expect(generateCallerPermissionsWarning(prev, cur)).toBe(''); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('treats a missing previous file as no baseline (first release is safe)', async () => { + const dir = await makeTmpDir(); + try { + const cur = join(dir, 'current.yml'); + await writeFile(cur, WORKFLOW_V203, 'utf-8'); + expect(generateCallerPermissionsWarning(join(dir, 'nope.yml'), cur)).toBe(''); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('throws when the current file is missing (mistyped path must be loud)', async () => { + const dir = await makeTmpDir(); + try { + const prev = join(dir, 'previous.yml'); + await writeFile(prev, WORKFLOW_V202, 'utf-8'); + expect(() => generateCallerPermissionsWarning(prev, join(dir, 'nope.yml'))).toThrow(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +describe('real .github/workflows/review-pr.yml', () => { + // Pins the caller-facing requirement of the shipped reusable workflow. + // If this test fails, you are changing what callers must grant: that is a + // BREAKING change for existing callers when any level increases (see issue + // #72). Update this expectation consciously, document the new block in + // README.md / review-pr/README.md, and rely on the release workflow to + // prepend the migration warning to the release notes. + const workflowPath = resolve(import.meta.dirname, '../../../.github/workflows/review-pr.yml'); + + it('requires exactly the documented caller permissions', () => { + const source = readFileSync(workflowPath, 'utf-8'); + const requirement = computeCallerRequirement(parseWorkflowPermissions(source)); + expect(requirement).toEqual({ + contents: 'read', + 'pull-requests': 'write', + issues: 'write', + 'id-token': 'write', + checks: 'write', + actions: 'write', + }); + }); +}); diff --git a/src/caller-permissions/caller-permissions.ts b/src/caller-permissions/caller-permissions.ts new file mode 100644 index 0000000..c748c99 --- /dev/null +++ b/src/caller-permissions/caller-permissions.ts @@ -0,0 +1,379 @@ +// Copyright The Docker Agent Action authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * caller-permissions — computes the GITHUB_TOKEN permissions a reusable + * workflow requires from its caller and flags increases between releases. + * + * A called workflow can only downgrade the permissions granted by its caller, + * never elevate them: every permission a job in the called workflow requests + * (via its own `permissions:` block, or the workflow-level block when the job + * has none) must be granted by the calling job, or GitHub rejects the run at + * startup validation — before any job runs. That makes an increased + * requirement a breaking change for every existing caller (issue #72: v2.0.3 + * raised `actions` from read to write on the review job and broke callers + * granting only `actions: read`). + * + * The release workflow uses the CLI (index.ts) to compare the review-pr.yml + * being released against the previous stable release, and prepends a + * breaking-change migration warning to the generated release notes when the + * requirement increased. Decreases are intentionally not flagged — callers + * granting more than required keep working. + * + * The extractor is a focused, dependency-free reader of the GitHub Actions + * workflow grammar (not a general YAML parser). It understands: + * + * - the workflow-level `permissions:` block (column 0) + * - job-level `permissions:` blocks (direct children of entries in `jobs:`) + * - block-map entries (`scope: level`, trailing comments allowed) + * - the inline forms `{}`, `{scope: level, …}`, `read-all`, `write-all` + * + * Anything nested deeper (step `with:` inputs, `run:`/`if:` block scalars) is + * excluded by indentation scoping: block-scalar content is always indented + * deeper than its key, so it can never sit at column 0 or at a job's + * direct-child indent. Unrecognized permission entries fail loudly so a + * parsing gap can never silently suppress a breaking-change warning. + */ +import { readFileSync } from 'node:fs'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type AccessLevel = 'none' | 'read' | 'write'; + +/** Scope name → requested access. The pseudo-scope `*` represents `read-all`/`write-all`. */ +export type PermissionsMap = Record; + +/** Pseudo-scope used to represent the `read-all` / `write-all` shorthands. */ +export const ALL_SCOPES = '*'; + +export interface JobPermissions { + id: string; + /** undefined = no job-level block (the workflow-level block applies, else the caller's grant). */ + permissions: PermissionsMap | undefined; +} + +export interface WorkflowPermissions { + /** Workflow-level `permissions:` block. undefined when absent. */ + workflow: PermissionsMap | undefined; + jobs: JobPermissions[]; +} + +export interface PermissionIncrease { + scope: string; + from: AccessLevel; + to: AccessLevel; +} + +const LEVEL_RANK: Record = { none: 0, read: 1, write: 2 }; + +// --------------------------------------------------------------------------- +// Workflow permissions extractor +// --------------------------------------------------------------------------- + +interface SourceLine { + indent: number; + /** Trimmed line content (never blank, never a whole-line comment). */ + text: string; + /** 1-based line number in the original source, for error messages. */ + lineNo: number; +} + +/** Split into significant lines: blanks and whole-line comments dropped, indent recorded. */ +function significantLines(source: string): SourceLine[] { + const out: SourceLine[] = []; + const rawLines = source.split('\n'); + for (let i = 0; i < rawLines.length; i++) { + const noCr = rawLines[i].endsWith('\r') ? rawLines[i].slice(0, -1) : rawLines[i]; + const text = noCr.trim(); + if (text === '' || text.startsWith('#')) continue; + out.push({ indent: noCr.length - noCr.trimStart().length, text, lineNo: i + 1 }); + } + return out; +} + +/** Strip a trailing ` # comment` (YAML requires whitespace before an inline `#`). */ +function stripTrailingComment(text: string): string { + return text.replace(/(?:^|\s)#.*$/, '').trim(); +} + +const KEY_RE = /^([A-Za-z_][A-Za-z0-9_-]*):(?:\s+(.*))?$/; + +/** Match a `key:` or `key: value` line. Returns the key and its comment-stripped inline value. */ +function matchKey(text: string): { key: string; rest: string } | null { + const m = text.match(KEY_RE); + if (!m) return null; + return { key: m[1], rest: stripTrailingComment(m[2] ?? '') }; +} + +function parseAccessLevel(token: string, lineNo: number): AccessLevel { + const unquoted = token.replace(/^(['"])(.*)\1$/, '$2'); + if (unquoted === 'none' || unquoted === 'read' || unquoted === 'write') return unquoted; + throw new Error( + `Unrecognized permission level "${token}" at line ${lineNo} (expected none, read, or write)`, + ); +} + +/** Parse an inline permissions value: `read-all`, `write-all`, `{}`, or `{scope: level, …}`. */ +function parseInlinePermissions(value: string, lineNo: number): PermissionsMap { + if (value === 'read-all') return { [ALL_SCOPES]: 'read' }; + if (value === 'write-all') return { [ALL_SCOPES]: 'write' }; + if (value.startsWith('{') && value.endsWith('}')) { + const inner = value.slice(1, -1).trim(); + if (inner === '') return {}; + const permissions: PermissionsMap = {}; + for (const part of inner.split(',')) { + const m = part.trim().match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(\S+)$/); + if (!m) { + throw new Error(`Malformed inline permissions entry "${part.trim()}" at line ${lineNo}`); + } + permissions[m[1]] = parseAccessLevel(m[2], lineNo); + } + return permissions; + } + throw new Error(`Unrecognized permissions value "${value}" at line ${lineNo}`); +} + +/** + * Parse the permissions value belonging to the `permissions:` key at lines[keyIdx]. + * Returns the parsed map and the index of the first line after the block. + */ +function parsePermissionsValue( + lines: SourceLine[], + keyIdx: number, + inlineValue: string, +): { permissions: PermissionsMap; nextIdx: number } { + const keyLine = lines[keyIdx]; + if (inlineValue !== '') { + return { + permissions: parseInlinePermissions(inlineValue, keyLine.lineNo), + nextIdx: keyIdx + 1, + }; + } + const permissions: PermissionsMap = {}; + let i = keyIdx + 1; + while (i < lines.length && lines[i].indent > keyLine.indent) { + const entry = stripTrailingComment(lines[i].text); + const m = entry.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(\S+)$/); + if (!m) { + throw new Error(`Malformed permissions entry at line ${lines[i].lineNo}: "${lines[i].text}"`); + } + permissions[m[1]] = parseAccessLevel(m[2], lines[i].lineNo); + i++; + } + return { permissions, nextIdx: i }; +} + +/** Parse the body of one job (lines after its id, deeper than jobIndent). */ +function parseJobBody( + lines: SourceLine[], + start: number, + jobIndent: number, + job: JobPermissions, +): number { + let i = start; + let childIndent = -1; + while (i < lines.length && lines[i].indent > jobIndent) { + const line = lines[i]; + // The first key inside the job fixes the direct-child indent; only a + // `permissions:` at exactly that indent belongs to the job itself + // (deeper occurrences are step inputs or scalar content). + if (childIndent === -1) childIndent = line.indent; + if (line.indent === childIndent) { + const m = matchKey(line.text); + if (m?.key === 'permissions') { + const parsed = parsePermissionsValue(lines, i, m.rest); + job.permissions = parsed.permissions; + i = parsed.nextIdx; + continue; + } + } + i++; + } + return i; +} + +/** Parse the `jobs:` section (lines after the `jobs:` key, until the next column-0 key). */ +function parseJobsSection(lines: SourceLine[], start: number, out: JobPermissions[]): number { + let i = start; + let jobIndent = -1; + while (i < lines.length && lines[i].indent > 0) { + const line = lines[i]; + if (jobIndent === -1) jobIndent = line.indent; + if (line.indent === jobIndent) { + const m = matchKey(line.text); + if (m) { + const job: JobPermissions = { id: m.key, permissions: undefined }; + out.push(job); + i = parseJobBody(lines, i + 1, jobIndent, job); + continue; + } + } + i++; + } + return i; +} + +/** + * Extract the workflow-level and per-job `permissions:` blocks from a GitHub + * Actions workflow source. Throws on malformed permissions entries. + */ +export function parseWorkflowPermissions(source: string): WorkflowPermissions { + const lines = significantLines(source); + let workflow: PermissionsMap | undefined; + const jobs: JobPermissions[] = []; + + let i = 0; + while (i < lines.length) { + const line = lines[i]; + if (line.indent === 0) { + const m = matchKey(line.text); + if (m?.key === 'permissions') { + const parsed = parsePermissionsValue(lines, i, m.rest); + workflow = parsed.permissions; + i = parsed.nextIdx; + continue; + } + if (m?.key === 'jobs') { + i = parseJobsSection(lines, i + 1, jobs); + continue; + } + } + i++; + } + return { workflow, jobs }; +} + +// --------------------------------------------------------------------------- +// Caller-facing requirement + diff +// --------------------------------------------------------------------------- + +/** + * Compute what a caller must grant: for each scope, the maximum level any job + * requests. A job's effective request is its own block when present, else the + * workflow-level block (a job-level block REPLACES the workflow-level one, it + * is not merged). Explicit `none` entries impose no requirement and are dropped. + */ +export function computeCallerRequirement(wf: WorkflowPermissions): PermissionsMap { + const blocks = wf.jobs.map((job) => job.permissions ?? wf.workflow); + // No jobs parsed (degenerate input): fall back to the workflow-level block + // so a requirement is never silently under-reported. + const effectiveBlocks = blocks.length > 0 ? blocks : [wf.workflow]; + + const requirement: PermissionsMap = {}; + for (const block of effectiveBlocks) { + if (block === undefined) continue; + for (const [scope, level] of Object.entries(block)) { + const current = requirement[scope] ?? 'none'; + if (LEVEL_RANK[level] > LEVEL_RANK[current]) requirement[scope] = level; + } + } + for (const [scope, level] of Object.entries(requirement)) { + if (level === 'none') delete requirement[scope]; + } + return requirement; +} + +/** + * Scopes whose required level increased (none < read < write) from `previous` + * to `current`, sorted by scope name. Reductions are never reported — callers + * granting more than required keep working. + */ +export function diffCallerRequirements( + previous: PermissionsMap, + current: PermissionsMap, +): PermissionIncrease[] { + const effective = (req: PermissionsMap, scope: string): AccessLevel => { + const direct = req[scope] ?? 'none'; + if (scope === ALL_SCOPES) return direct; + const wildcard = req[ALL_SCOPES] ?? 'none'; + return LEVEL_RANK[direct] >= LEVEL_RANK[wildcard] ? direct : wildcard; + }; + + const scopes = [...new Set([...Object.keys(previous), ...Object.keys(current)])].sort(); + const increases: PermissionIncrease[] = []; + for (const scope of scopes) { + const from = effective(previous, scope); + const to = effective(current, scope); + if (LEVEL_RANK[to] > LEVEL_RANK[from]) increases.push({ scope, from, to }); + } + return increases; +} + +// --------------------------------------------------------------------------- +// Release-notes warning +// --------------------------------------------------------------------------- + +const SETUP_DOCS_URL = + 'https://github.com/docker/docker-agent-action/blob/main/review-pr/README.md#quick-start'; + +/** + * Render the breaking-change migration warning prepended to release notes. + * Returns '' when there is nothing to warn about. + */ +export function renderBreakingChangeWarning(increases: PermissionIncrease[]): string { + if (increases.length === 0) return ''; + const bullets = increases + .map((inc) => { + const scope = inc.scope === ALL_SCOPES ? 'all scopes' : `\`${inc.scope}\``; + const from = inc.from === 'none' ? 'not previously required' : `\`${inc.from}\``; + return `- ${scope}: ${from} → \`${inc.to}\``; + }) + .join('\n'); + return [ + '## ⚠️ Breaking change: callers of the PR-review workflow must grant more permissions', + '', + 'This release increases the GitHub token permissions that the reusable PR-review workflow (`.github/workflows/review-pr.yml`) requests from its caller:', + '', + bullets, + '', + `A called workflow cannot elevate the permissions granted by its caller, so calling jobs that still grant the previous level fail GitHub's workflow validation at startup — before any job runs. Update the \`permissions:\` block on every job that calls this workflow **before** upgrading. See the [PR review setup docs](${SETUP_DOCS_URL}) for the full recommended block.`, + ].join('\n'); +} + +// --------------------------------------------------------------------------- +// I/O wrapper (used by the CLI entry point) +// --------------------------------------------------------------------------- + +/** + * Compare the caller-facing permission requirement of two workflow files and + * return the release-notes warning ('' when the requirement did not increase). + * + * The previous file may be absent (workflow introduced in this release — no + * existing caller can break): treated as no baseline, empty result. The + * current file must exist so a mistyped path in release.yml fails loudly + * instead of silently disabling the safeguard. + * + * Progress messages are written to stderr; stdout is reserved for the warning. + */ +export function generateCallerPermissionsWarning( + previousPath: string, + currentPath: string, +): string { + const currentSource = readFileSync(currentPath, 'utf-8'); + + let previousSource: string; + try { + previousSource = readFileSync(previousPath, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + process.stderr.write(`ℹ️ No previous workflow at ${previousPath} — nothing to compare\n`); + return ''; + } + throw err; + } + + const previous = computeCallerRequirement(parseWorkflowPermissions(previousSource)); + const current = computeCallerRequirement(parseWorkflowPermissions(currentSource)); + const increases = diffCallerRequirements(previous, current); + + if (increases.length === 0) { + process.stderr.write('✅ No caller-facing permission increases\n'); + return ''; + } + for (const inc of increases) { + process.stderr.write(`⚠️ Caller permission increased: ${inc.scope}: ${inc.from} → ${inc.to}\n`); + } + return renderBreakingChangeWarning(increases); +} diff --git a/src/caller-permissions/index.ts b/src/caller-permissions/index.ts new file mode 100644 index 0000000..79ae6ac --- /dev/null +++ b/src/caller-permissions/index.ts @@ -0,0 +1,39 @@ +// Copyright The Docker Agent Action authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * caller-permissions CLI entrypoint. + * + * Usage: + * node dist/caller-permissions.js + * + * Compares the caller-facing GITHUB_TOKEN permission requirement of a + * reusable workflow between two releases. When the current workflow requires + * more than the previous one (none < read < write), a markdown breaking-change + * warning is printed to stdout for the release workflow to prepend to the + * generated release notes. Prints nothing when the requirement is unchanged + * or reduced. + * + * The previous path may not exist (workflow introduced in this release): + * treated as no baseline. The current path must exist — a missing file exits + * non-zero so a mistyped path in release.yml cannot silently disable the + * safeguard. + * + * See caller-permissions.ts for the extraction and comparison logic. + */ +import { generateCallerPermissionsWarning } from './caller-permissions.js'; + +const [, , previousPath, currentPath] = process.argv; + +if (!previousPath || !currentPath) { + process.stderr.write('Usage: caller-permissions \n'); + process.exit(1); +} + +try { + const warning = generateCallerPermissionsWarning(previousPath, currentPath); + if (warning !== '') process.stdout.write(`${warning}\n`); +} catch (err) { + process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`); + process.exit(1); +} diff --git a/tests/test-release-caller-permissions.sh b/tests/test-release-caller-permissions.sh new file mode 100755 index 0000000..23d6bd9 --- /dev/null +++ b/tests/test-release-caller-permissions.sh @@ -0,0 +1,206 @@ +#!/bin/bash + +# Copyright The Docker Agent Action authors +# SPDX-License-Identifier: Apache-2.0 + +# Test the "Flag caller-facing permission increases as breaking" step of +# .github/workflows/release.yml. The step runs AFTER the immutable tag and the +# GitHub release exist, so a caller-permissions helper failure must be +# non-fatal (annotate + skip), while failures reading/editing the release +# notes once a valid warning exists must still fail the step. +# +# The step's run: block is extracted from the workflow file verbatim and +# executed with `bash -e` (the GitHub Actions default shell mode) against +# stubbed git/node/gh binaries. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +echo "==========================================" +echo "Testing release caller-permissions step" +echo "==========================================" + +TEST_DIR=$(mktemp -d) +trap 'rm -rf "$TEST_DIR" /tmp/review-pr-previous.yml /tmp/release-notes-with-warning.md' EXIT + +# ── Extract the step's run: block from release.yml ────────────────────────── +STEP_SCRIPT="$TEST_DIR/step.sh" +awk ' + !found && index($0, "- name: Flag caller-facing permission increases as breaking") { found=1; next } + found && !inrun { if ($0 ~ /^ run: \|/) inrun=1; next } + inrun { + if ($0 == "" || $0 ~ /^ /) { sub(/^ /, ""); print; next } + exit + } +' "$REPO_ROOT/.github/workflows/release.yml" > "$STEP_SCRIPT" + +if ! grep -q 'caller-permissions\.js' "$STEP_SCRIPT"; then + echo "❌ Failed to extract the step script from release.yml (step renamed or re-indented?)" + exit 1 +fi +echo "✅ Extracted step script ($(wc -l < "$STEP_SCRIPT" | tr -d ' ') lines)" + +# ── Stub binaries ──────────────────────────────────────────────────────────── +STUB_BIN="$TEST_DIR/bin" +STUB_CALLS="$TEST_DIR/calls.log" +mkdir -p "$STUB_BIN" + +cat > "$STUB_BIN/git" <<'EOF' +#!/bin/bash +echo "git $*" >> "$STUB_CALLS" +if [ "${STUB_GIT_SHOW_FAIL:-0}" = "1" ]; then + echo "fatal: invalid object name" >&2 + exit 128 +fi +echo 'permissions: {}' +EOF + +cat > "$STUB_BIN/node" <<'EOF' +#!/bin/bash +echo "node $*" >> "$STUB_CALLS" +case "${STUB_NODE_MODE:?STUB_NODE_MODE not set}" in + warn) + echo '⚠️ Caller permission increased: actions: read → write' >&2 + printf '%s\n' \ + '## ⚠️ Breaking change: callers must grant more permissions' \ + '' \ + '- `actions`: `read` → `write`' + ;; + empty) + echo '✅ No caller-facing permission increases' >&2 + ;; + fail) + # Partial stdout before the crash — must never reach the release notes. + echo 'partial stdout before crash' + echo 'Error: Unrecognized permission level "banana" at line 2' >&2 + exit 1 + ;; +esac +EOF + +cat > "$STUB_BIN/gh" <<'EOF' +#!/bin/bash +echo "gh $*" >> "$STUB_CALLS" +case "$1 $2" in + 'release view') + if [ "${STUB_GH_VIEW_FAIL:-0}" = "1" ]; then + echo 'stub: release view failed' >&2 + exit 1 + fi + printf '%s\n' "## What's Changed" '- generated note line 1' + ;; + 'release edit') + if [ "${STUB_GH_EDIT_FAIL:-0}" = "1" ]; then + echo 'stub: release edit failed' >&2 + exit 1 + fi + ;; + *) + echo "stub gh: unexpected invocation: $*" >&2 + exit 64 + ;; +esac +EOF + +chmod +x "$STUB_BIN/git" "$STUB_BIN/node" "$STUB_BIN/gh" + +# Runs the extracted step with the given NAME=value env overrides. +# Captures stdout in $OUTPUT, stderr in $TEST_DIR/stderr.txt, status in $STATUS. +run_step() { + : > "$STUB_CALLS" + rm -f /tmp/review-pr-previous.yml /tmp/release-notes-with-warning.md + set +e + OUTPUT=$(cd "$REPO_ROOT" && env \ + PATH="$STUB_BIN:$PATH" \ + STUB_CALLS="$STUB_CALLS" \ + GITHUB_WORKSPACE="$TEST_DIR" \ + VERSION="v9.9.9" \ + PREVIOUS="v9.9.8" \ + "$@" bash -e "$STEP_SCRIPT" 2>"$TEST_DIR/stderr.txt") + STATUS=$? + set -e +} + +fail() { + echo "❌ $1" + echo "--- stdout ---"; echo "$OUTPUT" + echo "--- stderr ---"; cat "$TEST_DIR/stderr.txt" + echo "--- calls ---"; cat "$STUB_CALLS" + exit 1 +} + +echo "" +echo "Test 1: first release (no previous tag) → clean skip" +echo "---" +run_step PREVIOUS= STUB_NODE_MODE=empty +[ "$STATUS" -eq 0 ] || fail "expected exit 0, got $STATUS" +echo "$OUTPUT" | grep -q "First release" || fail "expected first-release message" +grep -q '^gh ' "$STUB_CALLS" && fail "gh must not be called" || true +echo "✅ Skips cleanly on first release" + +echo "" +echo "Test 2: previous tag has no review-pr.yml → clean skip" +echo "---" +run_step STUB_GIT_SHOW_FAIL=1 STUB_NODE_MODE=empty +[ "$STATUS" -eq 0 ] || fail "expected exit 0, got $STATUS" +echo "$OUTPUT" | grep -q "nothing to compare" || fail "expected nothing-to-compare message" +grep -q '^node ' "$STUB_CALLS" && fail "helper must not be called" || true +echo "✅ Skips cleanly when the previous tag lacks the workflow" + +echo "" +echo "Test 3: no permission increase → notes left as generated" +echo "---" +run_step STUB_NODE_MODE=empty +[ "$STATUS" -eq 0 ] || fail "expected exit 0, got $STATUS" +echo "$OUTPUT" | grep -q "unchanged" || fail "expected unchanged message" +grep -q '^gh release edit' "$STUB_CALLS" && fail "release must not be edited" || true +echo "✅ Leaves release notes untouched" + +echo "" +echo "Test 4: permission increase → warning prepended to release notes" +echo "---" +run_step STUB_NODE_MODE=warn +[ "$STATUS" -eq 0 ] || fail "expected exit 0, got $STATUS" +grep -q '^gh release view v9.9.9' "$STUB_CALLS" || fail "expected gh release view call" +grep -q '^gh release edit v9.9.9' "$STUB_CALLS" || fail "expected gh release edit call" +[ -f /tmp/release-notes-with-warning.md ] || fail "expected updated notes file" +head -n1 /tmp/release-notes-with-warning.md | grep -q '^## ⚠️ Breaking change' \ + || fail "warning must be the first line of the notes" +grep -q 'generated note line 1' /tmp/release-notes-with-warning.md \ + || fail "generated notes must be preserved after the warning" +echo "✅ Warning prepended, generated notes preserved" + +echo "" +echo "Test 5: helper failure → non-fatal, ::warning emitted, notes untouched" +echo "---" +run_step STUB_NODE_MODE=fail +[ "$STATUS" -eq 0 ] || fail "helper failure must not fail the step (got $STATUS)" +echo "$OUTPUT" | grep -q '^::warning' || fail "expected a ::warning workflow command on stdout" +grep -q 'Error: Unrecognized permission level' "$TEST_DIR/stderr.txt" \ + || fail "helper stderr must stream through to the step log" +grep -q '^gh ' "$STUB_CALLS" && fail "gh must not be called after a helper failure" || true +[ ! -f /tmp/release-notes-with-warning.md ] || fail "notes file must not be written on helper failure" +echo "$OUTPUT" | grep -q 'partial stdout before crash' \ + && fail "partial helper stdout must be discarded" || true +echo "✅ Helper failure is non-fatal and never leaks into release notes" + +echo "" +echo "Test 6: valid warning but gh release view fails → step fails" +echo "---" +run_step STUB_NODE_MODE=warn STUB_GH_VIEW_FAIL=1 +[ "$STATUS" -ne 0 ] || fail "reading release notes must stay fatal" +echo "✅ gh release view failure still fails the step" + +echo "" +echo "Test 7: valid warning but gh release edit fails → step fails" +echo "---" +run_step STUB_NODE_MODE=warn STUB_GH_EDIT_FAIL=1 +[ "$STATUS" -ne 0 ] || fail "editing release notes must stay fatal" +echo "✅ gh release edit failure still fails the step" + +echo "" +echo "==========================================" +echo "✅ All release caller-permissions tests passed" +echo "==========================================" diff --git a/tsup.config.ts b/tsup.config.ts index e1c31d1..2574834 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -24,6 +24,7 @@ const src = (name: string) => { }; const entry = { 'auto-filter-diff': src('auto-filter-diff'), + 'caller-permissions': src('caller-permissions'), 'check-org-membership': src('check-org-membership'), credentials: src('credentials'), 'dedupe-findings': src('dedupe-findings'),