From 498a292396b349dc0378d4b3ab2cbb40efc4d7d2 Mon Sep 17 00:00:00 2001 From: Sayt-0 Date: Mon, 24 Aug 2026 18:17:37 +0200 Subject: [PATCH] feat(update-consumers): fork-PR fallback, caller-permissions sync, dry-run Open a cross-repo PR from a machine-user fork when the consumer repo denies write access (same routing as migrate-consumers), and raise the caller's permissions: grants to what the pinned review-pr.yml requires (issue #72: actions read -> write) so a version bump can no longer break callers at startup validation. Add dry-run (default true) and a repos allowlist for pilot runs, plus a job summary for triage. --- .github/workflows/update-consumers.yml | 291 +++++++++- .../__tests__/sync-caller-permissions.test.ts | 476 +++++++++++++++++ src/sync-caller-permissions/index.ts | 62 +++ .../sync-caller-permissions.ts | 501 ++++++++++++++++++ tsup.config.ts | 1 + 5 files changed, 1302 insertions(+), 29 deletions(-) create mode 100644 src/sync-caller-permissions/__tests__/sync-caller-permissions.test.ts create mode 100644 src/sync-caller-permissions/index.ts create mode 100644 src/sync-caller-permissions/sync-caller-permissions.ts diff --git a/.github/workflows/update-consumers.yml b/.github/workflows/update-consumers.yml index 51b8bcb..7e9fcf5 100644 --- a/.github/workflows/update-consumers.yml +++ b/.github/workflows/update-consumers.yml @@ -10,6 +10,16 @@ on: description: "Release version to propagate (e.g. v1.4.2). Defaults to latest release." required: false type: string + repos: + description: "Comma-separated allowlist of repos to process (e.g. docker/sailor,docker/compose). Empty = all discovered consumers." + required: false + type: string + default: "" + dry-run: + description: "Dry run: show the route (direct/fork/skip) and the diff that would be committed, but do not push commits or open PRs." + required: false + type: boolean + default: true permissions: contents: read @@ -79,7 +89,7 @@ jobs: node-version: 24 cache: pnpm - - name: Build signed-commit CLI + - name: Build CLI tools run: pnpm install --frozen-lockfile && pnpm build - name: Discover and update consumer repos @@ -87,6 +97,8 @@ jobs: GH_TOKEN: ${{ env.GITHUB_APP_TOKEN }} SHA: ${{ steps.resolve.outputs.sha }} VERSION: ${{ steps.resolve.outputs.version }} + DRY_RUN: ${{ inputs.dry-run }} + REPO_ALLOWLIST: ${{ inputs.repos }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | set -e @@ -113,24 +125,80 @@ jobs: echo "$REPOS" echo "" + # Apply the allowlist filter if provided (pilot runs on a few repos). + # Entries are compared against the repo field only — discovery lines + # are "repo file_path" pairs. + if [ -n "$REPO_ALLOWLIST" ]; then + FILTERED="" + IFS=',' read -ra ALLOWED <<< "$REPO_ALLOWLIST" + while IFS=' ' read -r R P; do + for A in "${ALLOWED[@]}"; do + # Trim surrounding whitespace via parameter expansion — unlike + # xargs this does not interpret backslashes or quotes, so a + # malformed entry cannot corrupt the filtering. + A_TRIMMED="${A#"${A%%[![:space:]]*}"}" + A_TRIMMED="${A_TRIMMED%"${A_TRIMMED##*[![:space:]]}"}" + if [ "$R" = "$A_TRIMMED" ]; then + FILTERED+="$R $P"$'\n' + fi + done + done <<< "$REPOS" + REPOS=$(printf '%s' "$FILTERED") + if [ -z "$REPOS" ]; then + echo "::warning::Allowlist did not match any discovered consumer repos" + exit 0 + fi + echo "After allowlist filter:" + echo "$REPOS" + echo "" + fi + + # review-pr.yml as of the target version: the permission sync below + # compares each consumer's caller grants against what THIS version + # requires (issue #72: v2.0.3 raised actions: read → write and broke + # callers granting only read). Abort rather than open PRs that could + # break callers unchecked. + TARGET_REVIEW_PR=$(mktemp) + gh api -H "Accept: application/vnd.github.raw" \ + "repos/docker/docker-agent-action/contents/.github/workflows/review-pr.yml?ref=$SHA" \ + > "$TARGET_REVIEW_PR" || { + echo "::error::Could not fetch review-pr.yml at $SHA for the caller-permissions check" + exit 1 + } + # Pattern to match any docker-agent-action workflow ref (SHA, tag, branch, SHA+comment) OLD_PATTERN='docker-agent-action/\.github/workflows/review-pr\.yml@' BRANCH="auto/update-docker-agent-action" RELEASE_URL="https://github.com/docker/docker-agent-action/releases/tag/$VERSION" + SUMMARY_CHANGED="" + SUMMARY_SKIPPED="" + + # Single EXIT trap registered once, referencing a global — traps don't + # stack, so a per-iteration trap would silently replace the previous + # handler. cleanup_workdir is also called explicitly on every skip + # path and is a no-op when already cleaned. + CURRENT_WORK_DIR="" + cleanup_workdir() { + cd / + if [ -n "$CURRENT_WORK_DIR" ]; then + rm -rf "$CURRENT_WORK_DIR" + CURRENT_WORK_DIR="" + fi + } + trap cleanup_workdir EXIT while IFS=' ' read -r REPO FILE_PATH; do echo "==========================================" echo "Processing ${REPO} (${FILE_PATH})..." echo "==========================================" - # Clone the repo into a temp directory - # Set up cleanup trap for this iteration WORK_DIR=$(mktemp -d) - trap 'cd /; rm -rf "$WORK_DIR"' EXIT + CURRENT_WORK_DIR="$WORK_DIR" if ! gh repo clone "$REPO" "$WORK_DIR" -- --depth=1 2>/dev/null; then echo "::warning::Failed to clone $REPO — skipping (token may lack access)" - rm -rf "$WORK_DIR" + SUMMARY_SKIPPED+="- ${REPO} (clone failed)"$'\n' + cleanup_workdir continue fi @@ -139,15 +207,15 @@ jobs: # Check that the file exists and contains the pattern if [ ! -f "$FILE_PATH" ]; then echo "::warning::$FILE_PATH not found in $REPO — skipping" - cd / - rm -rf "$WORK_DIR" + SUMMARY_SKIPPED+="- ${REPO} (${FILE_PATH} not found)"$'\n' + cleanup_workdir continue fi if ! grep -q "$OLD_PATTERN" "$FILE_PATH"; then echo "Pattern not found in $FILE_PATH — may already be up to date, skipping" - cd / - rm -rf "$WORK_DIR" + SUMMARY_SKIPPED+="- ${REPO} (no reusable-workflow ref found)"$'\n' + cleanup_workdir continue fi @@ -156,41 +224,183 @@ jobs: SAFE_SHA=$(printf '%s' "$SHA" | sed 's/[|&\]/\\&/g') sed -i 's|\(docker/docker-agent-action/\.github/workflows/review-pr\.yml@\).*|\1'"${SAFE_SHA}"' # '"${SAFE_VERSION}"'|g' "$FILE_PATH" + # Raise the caller's `permissions:` grants to what the target + # version requires — a called workflow cannot elevate its caller's + # permissions, so an under-granting caller fails GitHub's startup + # validation as soon as the bump merges. A check failure must not + # drop the version bump itself, so it degrades to a warning. + SYNC_OUT=$(node "$GITHUB_WORKSPACE/dist/sync-caller-permissions.js" \ + --reusable "$TARGET_REVIEW_PR" "$FILE_PATH") || { + echo "::warning::sync-caller-permissions failed on $REPO/$FILE_PATH — proceeding without the permissions check" + SYNC_OUT="" + } + PERM_CHANGED=$(printf '%s\n' "$SYNC_OUT" | grep '^changed ' || true) + PERM_MANUAL=$(printf '%s\n' "$SYNC_OUT" | grep '^manual ' || true) + if git diff --quiet "$FILE_PATH"; then - echo "No changes after sed — already up to date" - cd / - rm -rf "$WORK_DIR" + echo "No ref or permission changes — already up to date" + SUMMARY_SKIPPED+="- ${REPO} (already up to date)"$'\n' + cleanup_workdir continue fi echo "Updated reference to ${SHA} # ${VERSION}" - # Create signed commit via API + # Resolve how the update would be delivered (read-only, so a dry + # run can report the routing without performing any writes): + # direct: the machine user has write — commit a branch into the repo. + # fork: no write access — fork under the machine user, commit on + # the fork, and open a cross-repo PR (head "owner:branch"). + # skip: no write access AND forking disabled — needs a manual update. + # Fetch the repo metadata in one call (race-free vs separate calls) + # and coerce missing fields with jq's `// false`: `gh ... --jq` prints + # the string "null" for an absent field, which is neither "true" nor + # "false" and would misroute a repo. + REPO_META=$(gh api "repos/$REPO" 2>/dev/null || echo '{}') + DEFAULT_BRANCH=$(printf '%s' "$REPO_META" | jq -r '.default_branch // empty') + if [ -z "$DEFAULT_BRANCH" ]; then + echo "::warning::Failed to resolve default branch for $REPO — skipping" + SUMMARY_SKIPPED+="- ${REPO} (default-branch lookup failed)"$'\n' + cleanup_workdir + continue + fi + CAN_PUSH=$(printf '%s' "$REPO_META" | jq -r '.permissions.push // false') + ALLOW_FORKING=$(printf '%s' "$REPO_META" | jq -r '.allow_forking // false') + if [ "$CAN_PUSH" = "true" ]; then + ROUTE="direct" + elif [ "$ALLOW_FORKING" = "true" ]; then + ROUTE="fork" + else + ROUTE="skip" + fi + + if [ "$DRY_RUN" = "true" ]; then + echo "🧪 DRY RUN — route: ${ROUTE}; diff that would be committed:" + git --no-pager diff + if [ -n "$PERM_MANUAL" ]; then + echo "Caller-permission increases that would need a manual edit:" + printf '%s\n' "$PERM_MANUAL" + fi + if [ "$ROUTE" = "skip" ]; then + SUMMARY_SKIPPED+="- ${REPO} (dry run — no write access, forking disabled)"$'\n' + else + SUMMARY_CHANGED+="- ${REPO} (dry run — ${ROUTE} PR)"$'\n' + fi + cleanup_workdir + continue + fi + + if [ "$ROUTE" = "skip" ]; then + echo "::warning::No write access and forking disabled on $REPO — skipping (needs a write grant or manual update)" + SUMMARY_SKIPPED+="- ${REPO} (no write access, forking disabled)"$'\n' + cleanup_workdir + continue + elif [ "$ROUTE" = "direct" ]; then + COMMIT_REPO="$REPO" + PR_HEAD="$BRANCH" + else + FORK_OWNER=$(gh api user --jq .login) || { + echo "::warning::Could not resolve the machine-user login for $REPO — skipping" + SUMMARY_SKIPPED+="- ${REPO} (could not resolve fork owner)"$'\n' + cleanup_workdir + continue + } + if [ -z "$FORK_OWNER" ]; then + echo "::warning::Machine-user login resolved to empty for $REPO — skipping" + SUMMARY_SKIPPED+="- ${REPO} (empty fork owner)"$'\n' + cleanup_workdir + continue + fi + FORK="${FORK_OWNER}/$(basename "$REPO")" + echo "No write access on $REPO — opening a fork PR from ${FORK}" + # Fork is idempotent (no-op when it already exists) and created + # asynchronously, so poll until the API can see it. The exit code is + # ignored on purpose (some gh versions return non-zero when the fork + # already exists), but stderr is kept so a genuine fork failure is + # distinguishable from slow async readiness in the skip warning. + FORK_ERR=$(gh repo fork "$REPO" --clone=false --default-branch-only 2>&1 >/dev/null || true) + FORK_READY="" + for _ in $(seq 1 10); do + if gh api "repos/$FORK" >/dev/null 2>&1; then FORK_READY=1; break; fi + sleep 3 + done + if [ -z "$FORK_READY" ]; then + echo "::warning::Fork ${FORK} did not become available — skipping $REPO${FORK_ERR:+ (fork error: $FORK_ERR)}" + SUMMARY_SKIPPED+="- ${REPO} (fork not ready)"$'\n' + cleanup_workdir + continue + fi + # Guard against a name collision: proceed only if $FORK is really a + # fork of $REPO. gh can rename a fork, and the machine user may own + # an unrelated repo of the same basename — committing to the wrong + # repo must never happen. + FORK_PARENT=$(gh api "repos/$FORK" --jq '.parent.full_name // empty' 2>/dev/null || true) + if [ "$FORK_PARENT" != "$REPO" ]; then + echo "::warning::${FORK} is not a fork of ${REPO} (parent='${FORK_PARENT:-none}') — skipping to avoid writing to the wrong repo" + SUMMARY_SKIPPED+="- ${REPO} (fork name collision)"$'\n' + cleanup_workdir + continue + fi + # Force-sync the fork's default branch to upstream so the cross-repo + # PR diff shows only the update, not drift from a stale fork. + gh repo sync "$FORK" --branch "$DEFAULT_BRANCH" --force >/dev/null 2>&1 || true + COMMIT_REPO="$FORK" + PR_HEAD="${FORK_OWNER}:${BRANCH}" + fi + + # Create signed commit via API, on whichever repo was resolved + # above (the upstream when we have write, else the fork). COMMIT_OID=$(GITHUB_TOKEN="${GH_TOKEN}" node "$GITHUB_WORKSPACE/dist/signed-commit.js" \ - --repo "$REPO" \ + --repo "$COMMIT_REPO" \ --branch "$BRANCH" \ - --base-ref main \ + --base-ref "$DEFAULT_BRANCH" \ --force \ --message "chore: update docker-agent-action to $VERSION" \ --add "$FILE_PATH") || { - echo "::warning::Failed to create signed commit in $REPO (may lack write access)" - cd / - rm -rf "$WORK_DIR" + echo "::warning::Failed to create signed commit in $COMMIT_REPO (may lack write access)" + SUMMARY_SKIPPED+="- ${REPO} (commit failed)"$'\n' + cleanup_workdir continue } echo "✅ Signed commit: $COMMIT_OID" - # Create or update PR - EXISTING_PR=$(gh pr list --repo "$REPO" --head "$BRANCH" --state open --json number --jq '.[0].number') + # Look up an existing open PR for idempotent re-runs. `gh pr list + # --head` matches a branch NAME only and does NOT support the + # "owner:branch" form, so a fork PR is looked up via the REST pulls + # endpoint, whose head=owner:branch filter does. `// empty` keeps an + # absent PR from becoming the literal string "null". + if [ "$COMMIT_REPO" != "$REPO" ]; then + EXISTING_PR=$(gh api -X GET "repos/$REPO/pulls" -f state=open -f head="$PR_HEAD" --jq '.[0].number // empty' 2>/dev/null || true) + else + EXISTING_PR=$(gh pr list --repo "$REPO" --head "$BRANCH" --state open --json number --jq '.[0].number // empty') + fi + + # Surface what the permission sync did (or could not do) in the PR + # body, so consumer maintainers see why their permissions block + # changed — or what they must change themselves before merging. + PERM_SECTION="" + if [ -n "$PERM_CHANGED" ]; then + PERM_SECTION+="Also raises the caller \`permissions:\` grants that ${VERSION} requires (a caller granting less fails GitHub's workflow validation at startup):"$'\n' + while read -r _ P_BLOCK P_SCOPE P_FROM P_TO; do + PERM_SECTION+="- \`${P_SCOPE}\`: \`${P_FROM}\` → \`${P_TO}\` (${P_BLOCK})"$'\n' + done <<< "$PERM_CHANGED" + fi + if [ -n "$PERM_MANUAL" ]; then + PERM_SECTION+="> ⚠️ ${VERSION} requires caller permissions this PR could not raise automatically — make sure the calling job grants:"$'\n' + while read -r _ P_BLOCK P_SCOPE P_FROM P_TO; do + PERM_SECTION+="> - \`${P_SCOPE}: ${P_TO}\` (${P_BLOCK}, currently ${P_FROM})"$'\n' + done <<< "$PERM_MANUAL" + fi # Build PR body safely using printf to avoid shell expansion of FILE_PATH # FILE_PATH comes from GitHub API and could theoretically contain shell metacharacters - printf -v PR_BODY '%s\n%s\n%s\n%s\n%s' \ + printf -v PR_BODY '%s\n%s\n%s\n%s\n%s%s' \ "## Summary" \ "Updates \`docker-agent-action\` reference in \`${FILE_PATH}\` to [${VERSION}](${RELEASE_URL})." \ "- **Commit**: \`${SHA}\`" \ "- **Version**: \`${VERSION}\`" \ + "$PERM_SECTION" \ "> Auto-generated by the [release](${RUN_URL}) workflow." if [ -n "$EXISTING_PR" ]; then @@ -198,20 +408,43 @@ jobs: gh pr edit "$EXISTING_PR" --repo "$REPO" \ --title "chore: update docker-agent-action to $VERSION" \ --body "$PR_BODY" 2>&1 || echo "::warning::Failed to update PR #$EXISTING_PR in $REPO (may be non-fatal)" + PR_URL=$(gh pr view "$EXISTING_PR" --repo "$REPO" --json url --jq .url 2>/dev/null || echo "") else echo "Creating new PR in $REPO" - gh pr create --repo "$REPO" \ - --head "$BRANCH" \ + PR_URL=$(gh pr create --repo "$REPO" \ + --head "$PR_HEAD" \ + --base "$DEFAULT_BRANCH" \ --title "chore: update docker-agent-action to $VERSION" \ - --body "$PR_BODY" || echo "::warning::Failed to create PR in $REPO" + --body "$PR_BODY") || { + echo "::warning::Failed to create PR in $REPO" + PR_URL="" + } fi + if [ -n "$PR_URL" ]; then + echo "PR: $PR_URL" + fi + SUMMARY_CHANGED+="- ${REPO}${PR_URL:+ (${PR_URL})}"$'\n' - # Clear trap and cleanup - trap - EXIT - - cd / - rm -rf "$WORK_DIR" + cleanup_workdir echo "" done <<< "$REPOS" + # Job summary for triage — dry runs are reviewed from here before + # re-running with dry-run disabled. + { + echo "## Update consumers — $([ "$DRY_RUN" = "true" ] && echo 'DRY RUN' || echo 'EXECUTED')" + echo "" + echo "Target: \`${VERSION}\` @ \`${SHA}\`" + echo "" + if [ -n "$SUMMARY_CHANGED" ]; then + echo "### PRs opened / repos with changes" + printf '%s' "$SUMMARY_CHANGED" + echo "" + fi + if [ -n "$SUMMARY_SKIPPED" ]; then + echo "### Skipped" + printf '%s' "$SUMMARY_SKIPPED" + fi + } >> "$GITHUB_STEP_SUMMARY" + echo "Done updating consumer repos." diff --git a/src/sync-caller-permissions/__tests__/sync-caller-permissions.test.ts b/src/sync-caller-permissions/__tests__/sync-caller-permissions.test.ts new file mode 100644 index 0000000..c2be409 --- /dev/null +++ b/src/sync-caller-permissions/__tests__/sync-caller-permissions.test.ts @@ -0,0 +1,476 @@ +// Copyright The Docker Agent Action authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Unit tests for src/sync-caller-permissions. + * + * Covers: + * - upgrading an insufficient grant in the block that applies to the + * calling job (job-level block, else the workflow-level one) + * - job block REPLACES workflow block (never merged) + * - appending required scopes missing from the applicable block + * - grants above the requirement are never reduced; sufficient files are + * returned byte-for-byte (idempotency) + * - inline `{…}` maps, `read-all`/`write-all` shorthands, empty `{}` + * - manual reporting: no explicit block (repo default unknowable), + * read-all shorthand with a write requirement, `*` pseudo-requirement + * - scoping: non-calling jobs and step-level `uses:` are never touched; + * block-scalar content cannot leak into the scan + * - formatting preservation: indent, trailing comments, CRLF + * - the applySync I/O wrapper (writes only when changed, missing files) + * - a pin against the real .github/workflows/review-pr.yml so the + * update-consumers PR flow (issue #72: actions read → write) stays covered + */ +import { readFileSync } from 'node:fs'; +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + computeCallerRequirement, + type PermissionsMap, + parseWorkflowPermissions, +} from '../../caller-permissions/caller-permissions.js'; +import { applySync, syncCallerPermissions } from '../sync-caller-permissions.js'; + +const REUSABLE_USES = + 'docker/docker-agent-action/.github/workflows/review-pr.yml@0000000000000000000000000000000000000000 # v9.9.9'; + +/** The caller requirement of review-pr.yml since v2.0.3 (issue #72). */ +const REQUIRED: PermissionsMap = { + contents: 'read', + 'pull-requests': 'write', + issues: 'write', + 'id-token': 'write', + actions: 'write', +}; + +/** README same-repo caller shape, still granting the pre-v2.0.3 `actions: read`. */ +const CALLER_JOB_BLOCK = `name: PR Review +on: + issue_comment: + types: [created] + +permissions: + contents: read + +jobs: + review: + uses: ${REUSABLE_USES} + permissions: + contents: read # Read repository files and PR diffs + pull-requests: write # Post review comments + issues: write + id-token: write + actions: read # Cache read for binary cache + secrets: inherit +`; + +describe('syncCallerPermissions', () => { + it('upgrades an insufficient job-level grant in place, preserving the comment', () => { + const result = syncCallerPermissions(CALLER_JOB_BLOCK, REQUIRED); + expect(result.changed).toBe(true); + expect(result.applied).toEqual([ + { block: 'job:review', scope: 'actions', from: 'read', to: 'write' }, + ]); + expect(result.manual).toEqual([]); + expect(result.content).toContain(' actions: write # Cache read for binary cache'); + // Only that single line differs. + const before = CALLER_JOB_BLOCK.split('\n'); + const after = result.content.split('\n'); + expect(after.length).toBe(before.length); + expect(after.filter((line, i) => line !== before[i])).toEqual([ + ' actions: write # Cache read for binary cache', + ]); + }); + + it('is idempotent: a synced file is returned unchanged', () => { + const first = syncCallerPermissions(CALLER_JOB_BLOCK, REQUIRED); + const second = syncCallerPermissions(first.content, REQUIRED); + expect(second.changed).toBe(false); + expect(second.applied).toEqual([]); + expect(second.content).toBe(first.content); + }); + + it('appends required scopes missing from the applicable block', () => { + const source = `jobs: + review: + uses: ${REUSABLE_USES} + permissions: + contents: read + pull-requests: write +`; + const result = syncCallerPermissions(source, REQUIRED); + expect(result.applied).toEqual([ + { block: 'job:review', scope: 'actions', from: 'none', to: 'write' }, + { block: 'job:review', scope: 'id-token', from: 'none', to: 'write' }, + { block: 'job:review', scope: 'issues', from: 'none', to: 'write' }, + ]); + expect(result.content).toBe(`jobs: + review: + uses: ${REUSABLE_USES} + permissions: + contents: read + pull-requests: write + actions: write + id-token: write + issues: write +`); + }); + + it('never reduces a grant above the requirement', () => { + const source = `permissions: + contents: write + pull-requests: write + issues: write + id-token: write + actions: write + +jobs: + review: + uses: ${REUSABLE_USES} +`; + const result = syncCallerPermissions(source, REQUIRED); + expect(result.changed).toBe(false); + expect(result.content).toBe(source); + }); + + it('falls back to the workflow-level block when the calling job has none', () => { + const source = `permissions: + contents: read + pull-requests: write + issues: write + id-token: write + actions: read + +jobs: + review: + uses: ${REUSABLE_USES} +`; + const result = syncCallerPermissions(source, REQUIRED); + expect(result.applied).toEqual([ + { block: 'workflow', scope: 'actions', from: 'read', to: 'write' }, + ]); + expect(result.content).toContain('\n actions: write\n'); + }); + + it('job block replaces the workflow block: only the job block is edited', () => { + const source = `permissions: + actions: read + +jobs: + review: + uses: ${REUSABLE_USES} + permissions: + contents: read + pull-requests: write + issues: write + id-token: write + actions: read +`; + const result = syncCallerPermissions(source, REQUIRED); + expect(result.applied).toEqual([ + { block: 'job:review', scope: 'actions', from: 'read', to: 'write' }, + ]); + // Workflow-level block (which does not apply to the calling job) untouched. + expect(result.content).toContain('permissions:\n actions: read\n'); + expect(result.content).toContain(' actions: write\n'); + }); + + it('edits a shared workflow-level block once for multiple calling jobs', () => { + const source = `permissions: + contents: read + pull-requests: write + issues: write + id-token: write + actions: read + +jobs: + review-a: + uses: ${REUSABLE_USES} + review-b: + uses: ${REUSABLE_USES} +`; + const result = syncCallerPermissions(source, REQUIRED); + expect(result.applied).toEqual([ + { block: 'workflow', scope: 'actions', from: 'read', to: 'write' }, + ]); + }); + + it('rewrites inline maps and appends missing scopes', () => { + const source = `jobs: + review: + uses: ${REUSABLE_USES} + permissions: {contents: read, pull-requests: write, issues: write, actions: read} # inline +`; + const result = syncCallerPermissions(source, REQUIRED); + expect(result.applied).toEqual([ + { block: 'job:review', scope: 'actions', from: 'read', to: 'write' }, + { block: 'job:review', scope: 'id-token', from: 'none', to: 'write' }, + ]); + expect(result.content).toContain( + 'permissions: {contents: read, pull-requests: write, issues: write, actions: write, id-token: write} # inline', + ); + }); + + it('fills an empty inline {} block with every required scope', () => { + const source = `jobs: + review: + uses: ${REUSABLE_USES} + permissions: {} +`; + const result = syncCallerPermissions(source, REQUIRED); + expect(result.content).toContain( + 'permissions: {actions: write, contents: read, id-token: write, issues: write, pull-requests: write}', + ); + expect(result.manual).toEqual([]); + }); + + it('treats write-all as sufficient', () => { + const source = `jobs: + review: + uses: ${REUSABLE_USES} + permissions: write-all +`; + const result = syncCallerPermissions(source, REQUIRED); + expect(result.changed).toBe(false); + expect(result.manual).toEqual([]); + }); + + it('reports read-all with write requirements as manual (read scopes satisfied)', () => { + const source = `jobs: + review: + uses: ${REUSABLE_USES} + permissions: read-all +`; + const result = syncCallerPermissions(source, REQUIRED); + expect(result.changed).toBe(false); + expect(result.manual).toEqual([ + { block: 'job:review', scope: 'actions', from: 'read', to: 'write' }, + { block: 'job:review', scope: 'id-token', from: 'read', to: 'write' }, + { block: 'job:review', scope: 'issues', from: 'read', to: 'write' }, + { block: 'job:review', scope: 'pull-requests', from: 'read', to: 'write' }, + ]); + }); + + it('reports every required scope as manual when no explicit block exists', () => { + const source = `jobs: + review: + uses: ${REUSABLE_USES} +`; + const result = syncCallerPermissions(source, REQUIRED); + expect(result.changed).toBe(false); + expect(result.manual).toHaveLength(Object.keys(REQUIRED).length); + expect(result.manual[0]).toEqual({ + block: 'job:review', + scope: 'actions', + from: 'unknown', + to: 'write', + }); + }); + + it('reports a * pseudo-requirement (reusable declares write-all) as manual', () => { + const source = `jobs: + review: + uses: ${REUSABLE_USES} + permissions: + contents: read +`; + const result = syncCallerPermissions(source, { '*': 'write', contents: 'read' }); + expect(result.changed).toBe(false); + expect(result.manual).toEqual([{ block: 'job:review', scope: '*', from: 'none', to: 'write' }]); + }); + + it('never touches jobs that do not call the reusable workflow', () => { + const source = `jobs: + build: + permissions: + actions: read + steps: + - uses: docker/docker-agent-action@0000000000000000000000000000000000000000 +`; + const result = syncCallerPermissions(source, REQUIRED); + expect(result.changed).toBe(false); + expect(result.applied).toEqual([]); + expect(result.manual).toEqual([]); + }); + + it('does nothing when the file has no calling job at all', () => { + const source = 'name: CI\njobs:\n test:\n steps:\n - run: echo ok\n'; + const result = syncCallerPermissions(source, REQUIRED); + expect(result.changed).toBe(false); + }); + + it('ignores permissions/uses lines inside block scalars', () => { + const source = `jobs: + review: + uses: ${REUSABLE_USES} + permissions: + contents: read + pull-requests: write + issues: write + id-token: write + actions: write + docs: + steps: + - run: | + echo "permissions:" + echo " actions: read" + echo "uses: ${REUSABLE_USES}" +`; + const result = syncCallerPermissions(source, REQUIRED); + expect(result.changed).toBe(false); + expect(result.manual).toEqual([]); + }); + + it('preserves CRLF line endings on edited and inserted lines', () => { + const source = [ + 'jobs:', + ' review:', + ` uses: ${REUSABLE_USES}`, + ' permissions:', + ' contents: read', + ' pull-requests: write', + ' issues: write', + ' id-token: write', + ' actions: read', + '', + ].join('\r\n'); + const result = syncCallerPermissions(source, { ...REQUIRED, checks: 'write' }); + const lines = result.content.split('\n'); + expect(lines).toContain(' actions: write\r'); + expect(lines).toContain(' checks: write\r'); + }); + + it('upgrades quoted levels without breaking the quoting', () => { + const source = `jobs: + review: + uses: ${REUSABLE_USES} + permissions: + contents: read + pull-requests: write + issues: write + id-token: write + actions: 'read' +`; + const result = syncCallerPermissions(source, REQUIRED); + expect(result.content).toContain(" actions: 'write'\n"); + }); + + it('throws on a malformed permissions entry instead of silently skipping', () => { + const source = `jobs: + review: + uses: ${REUSABLE_USES} + permissions: + actions: [read] +`; + expect(() => syncCallerPermissions(source, REQUIRED)).toThrow(/Unrecognized permission level/); + }); +}); + +describe('against the real .github/workflows/review-pr.yml', () => { + const workflowPath = resolve(import.meta.dirname, '../../../.github/workflows/review-pr.yml'); + const required = computeCallerRequirement( + parseWorkflowPermissions(readFileSync(workflowPath, 'utf-8')), + ); + + it('upgrades a pre-v2.0.3 caller (actions: read) to the current requirement', () => { + const result = syncCallerPermissions(CALLER_JOB_BLOCK, required); + const actions = result.applied.find((inc) => inc.scope === 'actions'); + expect(actions).toEqual({ block: 'job:review', scope: 'actions', from: 'read', to: 'write' }); + expect(result.manual).toEqual([]); + }); + + it('leaves the README quick-start caller block unchanged (docs stay sufficient)', () => { + const readme = readFileSync( + resolve(import.meta.dirname, '../../../review-pr/README.md'), + 'utf-8', + ); + // Extract the same-repo quick-start caller from the README so drift + // between docs and the actual requirement fails this test. + const yaml = readme.match(/```yaml\n(name: PR Review\non:\n {2}pull_request:[\s\S]*?)```/); + expect(yaml).not.toBeNull(); + const result = syncCallerPermissions( + (yaml as RegExpMatchArray)[1].replace( + '@VERSION', + '@0000000000000000000000000000000000000000', + ), + required, + ); + expect(result.changed).toBe(false); + expect(result.manual).toEqual([]); + }); +}); + +describe('applySync (I/O wrapper)', () => { + async function makeTempDir(): Promise { + return mkdtemp(join(tmpdir(), 'sync-caller-permissions-test-')); + } + + const REUSABLE = `on: + workflow_call: + +permissions: + contents: read + +jobs: + review: + permissions: + contents: read + actions: write + steps: + - run: echo ok +`; + + it('writes the consumer file only when a grant was raised', async () => { + const dir = await makeTempDir(); + try { + const reusable = join(dir, 'review-pr.yml'); + const consumer = join(dir, 'caller.yml'); + await writeFile(reusable, REUSABLE); + await writeFile( + consumer, + `jobs:\n review:\n uses: ${REUSABLE_USES}\n permissions:\n contents: read\n actions: read\n`, + ); + const result = applySync(reusable, consumer); + expect(result.changed).toBe(true); + expect(result.applied).toEqual([ + { block: 'job:review', scope: 'actions', from: 'read', to: 'write' }, + ]); + expect(await readFile(consumer, 'utf-8')).toContain(' actions: write\n'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('does not rewrite an already-sufficient consumer file', async () => { + const dir = await makeTempDir(); + try { + const reusable = join(dir, 'review-pr.yml'); + const consumer = join(dir, 'caller.yml'); + await writeFile(reusable, REUSABLE); + await writeFile( + consumer, + `jobs:\n review:\n uses: ${REUSABLE_USES}\n permissions:\n contents: read\n actions: write\n`, + ); + const before = await stat(consumer); + const result = applySync(reusable, consumer); + expect(result.changed).toBe(false); + const after = await stat(consumer); + expect(after.mtimeMs).toBe(before.mtimeMs); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('throws when the reusable workflow file is missing', async () => { + const dir = await makeTempDir(); + try { + const consumer = join(dir, 'caller.yml'); + await writeFile(consumer, 'jobs: {}\n'); + expect(() => applySync(join(dir, 'nope.yml'), consumer)).toThrow(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/sync-caller-permissions/index.ts b/src/sync-caller-permissions/index.ts new file mode 100644 index 0000000..89c270d --- /dev/null +++ b/src/sync-caller-permissions/index.ts @@ -0,0 +1,62 @@ +// Copyright The Docker Agent Action authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * sync-caller-permissions CLI entrypoint. + * + * Usage: + * node dist/sync-caller-permissions.js --reusable + * + * Raises the `permissions:` grants in a consumer caller workflow to what the + * reusable PR-review workflow at (the file as of the + * version being pinned) requires from its caller. The consumer file is edited + * in place, only when a grant is insufficient (issue #72: v2.0.3 raised + * `actions` from read to write and broke callers granting only read). + * + * stdout is a machine-readable report, one line per increase: + * + * changed — edited into the file + * manual — required but not editable safely + * + * where is `workflow` or `job:`, and is `unknown` when the + * consumer has no explicit permissions block (the repo default applies). + * Prints nothing when the grants are already sufficient. Progress messages go + * to stderr. Exits non-zero on unreadable/unparseable input so the calling + * workflow can tell "nothing to do" apart from "could not check". + * + * See sync-caller-permissions.ts for the scanning and rewrite logic. + */ +import { applySync } from './sync-caller-permissions.js'; + +const args = process.argv.slice(2); +let reusablePath: string | undefined; +const positional: string[] = []; + +for (let i = 0; i < args.length; i++) { + if (args[i] === '--reusable') { + reusablePath = args[++i]; + } else { + positional.push(args[i]); + } +} + +const consumerPath = positional[0]; +if (!reusablePath || !consumerPath || positional.length > 1) { + process.stderr.write( + 'Usage: sync-caller-permissions --reusable \n', + ); + process.exit(1); +} + +try { + const result = applySync(reusablePath, consumerPath); + for (const inc of result.applied) { + process.stdout.write(`changed ${inc.block} ${inc.scope} ${inc.from} ${inc.to}\n`); + } + for (const inc of result.manual) { + process.stdout.write(`manual ${inc.block} ${inc.scope} ${inc.from} ${inc.to}\n`); + } +} catch (err) { + process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`); + process.exit(1); +} diff --git a/src/sync-caller-permissions/sync-caller-permissions.ts b/src/sync-caller-permissions/sync-caller-permissions.ts new file mode 100644 index 0000000..8021e52 --- /dev/null +++ b/src/sync-caller-permissions/sync-caller-permissions.ts @@ -0,0 +1,501 @@ +// Copyright The Docker Agent Action authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * sync-caller-permissions — raises a consumer workflow's `permissions:` grants + * to what the pinned reusable PR-review workflow requires from its caller. + * + * A called workflow cannot elevate its caller's permissions: when a release + * raises what review-pr.yml requests (issue #72: v2.0.3 raised `actions` from + * read to write), merging a version-bump PR breaks every caller still granting + * the old level — GitHub rejects the run at startup validation. The + * update-consumers workflow therefore runs this tool right after re-pinning a + * consumer's `uses:` ref, so the same PR also fixes the caller's grant. + * + * The comparison is absolute (target requirement vs what the consumer grants), + * not a version diff — a consumer that was already under-granting gets fixed + * regardless of which version it is coming from. + * + * Only the block that applies to the calling job is touched (its own + * `permissions:` block, else the workflow-level one — a job block REPLACES the + * workflow block, they are not merged), and only upward: grants above the + * requirement are never reduced. Everything else in the file is preserved + * byte-for-byte. Cases that cannot be edited safely are reported as `manual` + * instead of guessed: + * + * - no explicit `permissions:` block anywhere: the effective grant is the + * repo/org default, which is unknowable here — inventing a block could + * REDUCE effective permissions (unlisted scopes become none). + * - a `read-all` shorthand with a write-level requirement: rewriting the + * shorthand into a block map is too invasive for an automated PR. + * + * The requirement side reuses the caller-permissions extractor (also used by + * the release-notes breaking-change safeguard) so both tools can never + * disagree on what a release requires. + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { + type AccessLevel, + ALL_SCOPES, + computeCallerRequirement, + type PermissionsMap, + parseWorkflowPermissions, +} from '../caller-permissions/caller-permissions.js'; + +/** `uses:` marker identifying a job that calls the reusable PR-review workflow. */ +export const REUSABLE_WORKFLOW_REF = 'docker/docker-agent-action/.github/workflows/review-pr.yml@'; + +const LEVEL_RANK: Record = { none: 0, read: 1, write: 2 }; + +export interface AppliedIncrease { + /** `workflow` for the workflow-level block, `job:` for a job-level block. */ + block: string; + scope: string; + from: AccessLevel; + to: AccessLevel; +} + +export interface ManualIncrease { + /** `job:` of the calling job (or the block owner when a block exists but is uneditable). */ + block: string; + scope: string; + /** `unknown` when no explicit block exists (the repo/org default applies). */ + from: AccessLevel | 'unknown'; + to: AccessLevel; +} + +export interface SyncResult { + /** Rewritten file content. Identical to the input when nothing was applied. */ + content: string; + changed: boolean; + /** Increases edited into the file. */ + applied: AppliedIncrease[]; + /** Increases that are required but could not be edited safely. */ + manual: ManualIncrease[]; +} + +// --------------------------------------------------------------------------- +// Consumer-side scanner (line positions retained for in-place editing) +// --------------------------------------------------------------------------- + +interface ScanLine { + indent: number; + /** Trimmed text with any trailing \r removed (never blank / whole-line comment). */ + text: string; + /** Index into the raw line array (0-based). */ + idx: number; +} + +type BlockKind = 'block-map' | 'inline-map' | 'read-all' | 'write-all'; + +interface BlockEntry { + lineIdx: number; + scope: string; + level: AccessLevel; +} + +interface PermissionsBlock { + owner: string; + kind: BlockKind; + keyLineIdx: number; + entries: BlockEntry[]; + /** Indent for inserted entries (block-map only). */ + entryIndent: number; + /** Granted scopes (ALL_SCOPES pseudo-scope for the shorthands). */ + map: PermissionsMap; +} + +interface JobScan { + id: string; + callsReusable: boolean; + block: PermissionsBlock | undefined; +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function significantLines(rawLines: string[]): ScanLine[] { + const out: ScanLine[] = []; + 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, idx: i }); + } + 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+(.*))?$/; + +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, lineIdx: 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 ${lineIdx + 1} (expected none, read, or write)`, + ); +} + +/** Parse the ordered entries of an inline `{scope: level, …}` map. */ +function parseInlineEntries( + value: string, + lineIdx: number, +): Array<{ scope: string; level: AccessLevel }> { + const inner = value.slice(1, -1).trim(); + if (inner === '') return []; + return inner.split(',').map((part) => { + const m = part.trim().match(/^(['"]?)([A-Za-z][A-Za-z0-9_-]*)\1:\s*(\S+)$/); + if (!m) { + throw new Error(`Malformed inline permissions entry "${part.trim()}" at line ${lineIdx + 1}`); + } + return { scope: m[2], level: parseAccessLevel(m[3], lineIdx) }; + }); +} + +/** Parse the permissions block whose key sits at lines[keyPos]. Returns the next scan position. */ +function parseBlockAt( + lines: ScanLine[], + keyPos: number, + inlineValue: string, + owner: string, +): { block: PermissionsBlock; nextPos: number } { + const keyLine = lines[keyPos]; + const base: Omit = { + owner, + keyLineIdx: keyLine.idx, + entries: [], + entryIndent: keyLine.indent + 2, + }; + + if (inlineValue !== '') { + if (inlineValue === 'read-all') { + return { + block: { ...base, kind: 'read-all', map: { [ALL_SCOPES]: 'read' } }, + nextPos: keyPos + 1, + }; + } + if (inlineValue === 'write-all') { + return { + block: { ...base, kind: 'write-all', map: { [ALL_SCOPES]: 'write' } }, + nextPos: keyPos + 1, + }; + } + if (inlineValue.startsWith('{') && inlineValue.endsWith('}')) { + const map: PermissionsMap = {}; + for (const { scope, level } of parseInlineEntries(inlineValue, keyLine.idx)) { + map[scope] = level; + } + return { block: { ...base, kind: 'inline-map', map }, nextPos: keyPos + 1 }; + } + throw new Error(`Unrecognized permissions value "${inlineValue}" at line ${keyLine.idx + 1}`); + } + + const entries: BlockEntry[] = []; + const map: PermissionsMap = {}; + let i = keyPos + 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].idx + 1}: "${lines[i].text}"`, + ); + } + const level = parseAccessLevel(m[2], lines[i].idx); + entries.push({ lineIdx: lines[i].idx, scope: m[1], level }); + map[m[1]] = level; + i++; + } + const entryIndent = entries.length > 0 ? lines[i - entries.length].indent : keyLine.indent + 2; + return { + block: { ...base, kind: 'block-map', entries, entryIndent, map }, + nextPos: i, + }; +} + +/** Scan one job body; returns the next scan position. */ +function scanJobBody(lines: ScanLine[], start: number, jobIndent: number, job: JobScan): 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; deeper + // occurrences (step inputs, block-scalar content) never belong to the job. + if (childIndent === -1) childIndent = line.indent; + if (line.indent === childIndent) { + const m = matchKey(line.text); + if (m?.key === 'permissions') { + const parsed = parseBlockAt(lines, i, m.rest, `job:${job.id}`); + job.block = parsed.block; + i = parsed.nextPos; + continue; + } + if (m?.key === 'uses' && m.rest.includes(REUSABLE_WORKFLOW_REF)) { + job.callsReusable = true; + } + } + i++; + } + return i; +} + +function scanJobs(lines: ScanLine[], start: number, out: JobScan[]): 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: JobScan = { id: m.key, callsReusable: false, block: undefined }; + out.push(job); + i = scanJobBody(lines, i + 1, jobIndent, job); + continue; + } + } + i++; + } + return i; +} + +function scanConsumer(rawLines: string[]): { + workflowBlock: PermissionsBlock | undefined; + jobs: JobScan[]; +} { + const lines = significantLines(rawLines); + let workflowBlock: PermissionsBlock | undefined; + const jobs: JobScan[] = []; + + 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 = parseBlockAt(lines, i, m.rest, 'workflow'); + workflowBlock = parsed.block; + i = parsed.nextPos; + continue; + } + if (m?.key === 'jobs') { + i = scanJobs(lines, i + 1, jobs); + continue; + } + } + i++; + } + return { workflowBlock, jobs }; +} + +// --------------------------------------------------------------------------- +// Edit planning and application +// --------------------------------------------------------------------------- + +function effectiveGrant(map: PermissionsMap, scope: string): AccessLevel { + const direct = map[scope] ?? 'none'; + if (scope === ALL_SCOPES) return direct; + const wildcard = map[ALL_SCOPES] ?? 'none'; + return LEVEL_RANK[direct] >= LEVEL_RANK[wildcard] ? direct : wildcard; +} + +/** Replace the level of a `scope: level` block-map line, preserving indent, quotes, and comment. */ +function upgradeEntryLine(line: string, scope: string, to: AccessLevel): string | null { + const re = new RegExp( + `^(\\s*${escapeRegExp(scope)}:\\s*)(['"]?)(?:none|read|write)\\2((?:\\s+#.*)?\\s*)$`, + ); + const m = line.match(re); + if (!m) return null; + return `${m[1]}${m[2]}${to}${m[2]}${m[3]}`; +} + +/** + * Rebuild an inline `permissions: {…}` value with upgrades applied and missing + * scopes appended. Spacing inside the braces is normalized; the prefix and any + * trailing comment are preserved. Returns null when the line shape is + * unexpected (caller falls back to a manual report). + */ +function editInlineLine( + line: string, + lineIdx: number, + upgrades: ReadonlyMap, + additions: ReadonlyArray<{ scope: string; level: AccessLevel }>, +): string | null { + const m = line.match(/^(\s*permissions:\s*)(\{[^}]*\})((?:\s+#.*)?\s*)$/); + if (!m) return null; + const entries = parseInlineEntries(m[2], lineIdx).map(({ scope, level }) => ({ + scope, + level: upgrades.get(scope) ?? level, + })); + entries.push(...additions); + const inner = entries.map(({ scope, level }) => `${scope}: ${level}`).join(', '); + return `${m[1]}{${inner}}${m[3]}`; +} + +/** + * Raise the consumer's grants to `required`. Pure: returns the rewritten + * content plus what was applied and what needs a manual follow-up. + */ +export function syncCallerPermissions(source: string, required: PermissionsMap): SyncResult { + const rawLines = source.split('\n'); + const { workflowBlock, jobs } = scanConsumer(rawLines); + const callers = jobs.filter((job) => job.callsReusable); + + const applied: AppliedIncrease[] = []; + const manual: ManualIncrease[] = []; + const requiredScopes = Object.keys(required).sort(); + + if (callers.length === 0 || requiredScopes.length === 0) { + return { content: source, changed: false, applied, manual }; + } + + // Dedupe by block position: several calling jobs may share the + // workflow-level block, which must be edited (and reported) once. + const blocks = new Map(); + for (const job of callers) { + const block = job.block ?? workflowBlock; + if (block === undefined) { + // No explicit block: the repo/org default applies and cannot be + // verified from here. Never invent a block — unlisted scopes would + // drop to none and could break scopes the default currently grants. + for (const scope of requiredScopes) { + manual.push({ block: `job:${job.id}`, scope, from: 'unknown', to: required[scope] }); + } + continue; + } + blocks.set(block.keyLineIdx, block); + } + + const lineEdits = new Map(); + const insertions: Array<{ afterIdx: number; text: string }> = []; + + for (const block of blocks.values()) { + const needs = requiredScopes + .map((scope) => ({ scope, from: effectiveGrant(block.map, scope), to: required[scope] })) + .filter(({ from, to }) => LEVEL_RANK[to] > LEVEL_RANK[from]); + if (needs.length === 0) continue; + + if (block.kind === 'read-all' || block.kind === 'write-all') { + // Rewriting a shorthand into a block map is too invasive to automate. + // (write-all can only get here for the ALL_SCOPES pseudo-requirement.) + for (const need of needs) manual.push({ block: block.owner, ...need }); + continue; + } + + // The ALL_SCOPES pseudo-requirement (reusable workflow declares read-all/ + // write-all) has no block-map representation — report it, never write `*:`. + const editable = needs.filter((need) => need.scope !== ALL_SCOPES); + for (const need of needs) { + if (need.scope === ALL_SCOPES) manual.push({ block: block.owner, ...need }); + } + + if (block.kind === 'inline-map') { + const upgrades = new Map(); + const additions: Array<{ scope: string; level: AccessLevel }> = []; + for (const need of editable) { + if (need.scope in block.map) upgrades.set(need.scope, need.to); + else additions.push({ scope: need.scope, level: need.to }); + } + const raw = rawLines[block.keyLineIdx]; + const hasCR = raw.endsWith('\r'); + const edited = editInlineLine( + hasCR ? raw.slice(0, -1) : raw, + block.keyLineIdx, + upgrades, + additions, + ); + if (edited === null) { + for (const need of editable) manual.push({ block: block.owner, ...need }); + continue; + } + lineEdits.set(block.keyLineIdx, hasCR ? `${edited}\r` : edited); + for (const need of editable) applied.push({ block: block.owner, ...need }); + continue; + } + + // block-map: upgrade existing entry lines in place, append missing scopes + // after the last entry (or right after the key when the block is empty). + const insertAfter = + block.entries.length > 0 ? block.entries[block.entries.length - 1].lineIdx : block.keyLineIdx; + const anchorHasCR = rawLines[insertAfter].endsWith('\r'); + for (const need of editable) { + const entry = block.entries.find((e) => e.scope === need.scope); + if (entry === undefined) { + const text = `${' '.repeat(block.entryIndent)}${need.scope}: ${need.to}`; + insertions.push({ afterIdx: insertAfter, text: anchorHasCR ? `${text}\r` : text }); + applied.push({ block: block.owner, ...need }); + continue; + } + const raw = rawLines[entry.lineIdx]; + const hasCR = raw.endsWith('\r'); + const edited = upgradeEntryLine(hasCR ? raw.slice(0, -1) : raw, need.scope, need.to); + if (edited === null) { + manual.push({ block: block.owner, ...need }); + continue; + } + lineEdits.set(entry.lineIdx, hasCR ? `${edited}\r` : edited); + applied.push({ block: block.owner, ...need }); + } + } + + const out: string[] = []; + for (let i = 0; i < rawLines.length; i++) { + out.push(lineEdits.get(i) ?? rawLines[i]); + for (const ins of insertions) { + if (ins.afterIdx === i) out.push(ins.text); + } + } + const content = out.join('\n'); + return { content, changed: content !== source, applied, manual }; +} + +// --------------------------------------------------------------------------- +// I/O wrapper (used by the CLI entry point) +// --------------------------------------------------------------------------- + +export interface ApplySyncResult { + changed: boolean; + applied: AppliedIncrease[]; + manual: ManualIncrease[]; +} + +/** + * Compute the caller requirement of the reusable workflow at `reusablePath` + * and sync `consumerPath` against it in place (written only when changed). + * Progress goes to stderr; stdout is reserved for the CLI's report lines. + */ +export function applySync(reusablePath: string, consumerPath: string): ApplySyncResult { + const required = computeCallerRequirement( + parseWorkflowPermissions(readFileSync(reusablePath, 'utf-8')), + ); + const consumerSource = readFileSync(consumerPath, 'utf-8'); + const result = syncCallerPermissions(consumerSource, required); + + if (result.changed) { + writeFileSync(consumerPath, result.content, 'utf-8'); + for (const inc of result.applied) { + process.stderr.write( + `✅ ${consumerPath}: ${inc.scope}: ${inc.from} → ${inc.to} (${inc.block})\n`, + ); + } + } else { + process.stderr.write(`ℹ️ ${consumerPath}: caller permissions already sufficient\n`); + } + for (const inc of result.manual) { + process.stderr.write( + `⚠️ ${consumerPath}: ${inc.scope} needs ${inc.to} but could not be edited (${inc.block}, currently ${inc.from})\n`, + ); + } + return { changed: result.changed, applied: result.applied, manual: result.manual }; +} diff --git a/tsup.config.ts b/tsup.config.ts index 9664847..33fd9ab 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -41,6 +41,7 @@ const entry = { 'score-risk': src('score-risk'), security: src('security'), 'signed-commit': src('signed-commit'), + 'sync-caller-permissions': src('sync-caller-permissions'), 'validate-suggestions': src('validate-suggestions'), };