diff --git a/.github/workflows/codeboarding-sync.yml b/.github/workflows/codeboarding-sync.yml index 5e625e6..c28bf22 100644 --- a/.github/workflows/codeboarding-sync.yml +++ b/.github/workflows/codeboarding-sync.yml @@ -13,16 +13,26 @@ on: push: branches: [main] # Loop guard: don't re-trigger on the files this workflow itself commits. - # Deliberately NOT '.codeboarding/**': that would also swallow pushes that - # only edit the user-authored .codeboarding/.codeboardingignore, which - # changes analysis scope and should regenerate the baseline. The action also - # skips re-analyzing its own bot commit as a backstop; the bot commit uses no + # This list must name EVERY generated artifact (an un-ignored one leaks the + # bot's own commit through and re-triggers), and must name ONLY generated + # artifacts — never a user-authored input, whose edit changes analysis scope + # and must regenerate the baseline. So: deliberately NOT '.codeboarding/**' + # (would swallow .codeboarding/.codeboardingignore) and NOT + # '.codeboarding/health/**' (would swallow the user-authored health/.healthignore + # and health/health_config.json) — only the generated health/health_report.json + # is listed. In push mode the action also skips re-analyzing its own commit by + # author email as a backstop; in pull_request mode this list is the loop guard + # (a merged baseline PR touches only these files), with the commit step's + # "nothing to commit" gate stopping any stray re-run. The bot commit uses no # [skip ci] (that would leak through squash-merges and skip real merges). paths-ignore: - '.codeboarding/*.md' - '.codeboarding/analysis.json' + - '.codeboarding/fingerprint.json' + - '.codeboarding/static_analysis.pkl' + - '.codeboarding/static_analysis.sha' - '.codeboarding/codeboarding_version.json' - - '.codeboarding/health/**' + - '.codeboarding/health/health_report.json' - 'docs/development/architecture.md' workflow_dispatch: inputs: @@ -31,6 +41,17 @@ on: type: boolean required: false default: false + sync_strategy: + description: 'Deliver directly to the target branch or open/update a rolling baseline PR.' + type: choice + options: [push, pull_request] + required: false + default: push + sync_pr_branch: + description: 'Machine-owned branch used when sync_strategy is pull_request.' + type: string + required: false + default: codeboarding/sync # No workflow-level permissions: the single job below requests only what it # needs (least privilege), so the default token starts with none. @@ -47,7 +68,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 permissions: - contents: write # commit the generated baseline + docs to main + contents: write # push the generated baseline branch + pull-requests: write # workflow_dispatch may exercise pull_request delivery steps: # Dogfood: run the action from the checked-out repo (uses: ./) so pushes to # main exercise the action code on main, not the last published release. @@ -124,6 +146,12 @@ jobs: with: mode: sync force_full: ${{ inputs.force_full || false }} + # Push events retain direct delivery to their branch. A manual + # pull_request-strategy run targets main even though the workflow code + # itself is checked out from the feature ref being dogfooded. + target_branch: ${{ github.event_name == 'workflow_dispatch' && inputs.sync_strategy == 'pull_request' && 'main' || github.ref_name }} + sync_strategy: ${{ inputs.sync_strategy || 'push' }} + sync_pr_branch: ${{ inputs.sync_pr_branch || 'codeboarding/sync' }} # App token authenticates the baseline push so the commit is attributed # to the CodeBoarding App (logo avatar). Falls back to the workflow token, # which can push because this job grants contents: write. diff --git a/.github/workflows/codeboarding.yml b/.github/workflows/codeboarding.yml index 40b61d7..595d5d0 100644 --- a/.github/workflows/codeboarding.yml +++ b/.github/workflows/codeboarding.yml @@ -31,8 +31,16 @@ jobs: contents: read pull-requests: write # post / update the architecture-diff PR comment issues: write # the /codeboarding issue_comment trigger + comment API + # Never auto-review the sync mode's own baseline PR (head branch + # 'codeboarding/sync', the sync_pr_branch default): it only changes generated + # files, so a diff comment would be noise. Scoped to THIS repo's head so a fork + # PR that merely happens to name its branch 'codeboarding/sync' is still + # reviewed normally. Consumers using sync_strategy: pull_request should match + # this exclusion to their sync_pr_branch and, if they run other pull_request + # workflows (tests, lint), exclude the branch there too (via a head_ref guard). if: > - (github.event_name == 'pull_request' && github.event.action != 'closed' && github.event.pull_request.draft == false) || + (github.event_name == 'pull_request' && github.event.action != 'closed' && github.event.pull_request.draft == false && + !(github.head_ref == 'codeboarding/sync' && github.event.pull_request.head.repo.full_name == github.repository)) || (github.event_name == 'issue_comment' && github.event.issue.pull_request != null && startsWith(github.event.comment.body, '/codeboarding') && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) diff --git a/README.md b/README.md index b922584..bcbf708 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ Models are optional. Omit `agent_model` and `parsing_model` to use the defaults, ## More usage (your own key or a license) -The free tier is metered per repository owner against a weekly cap. For more — or unmetered — usage, supply a credential. Both paths skip the proxy/OIDC and need no `id-token: write`: +The free tier is metered per repository owner against a weekly cap. For more — or unmetered — usage, supply a credential. Option A (your own key) talks to the provider directly and needs no `id-token: write`; Option B (a license) still uses the hosted proxy, so it keeps `id-token: write` (the proxy verifies the OIDC identity, then the license skips the quota): ```yaml with: @@ -190,23 +190,27 @@ on: push: branches: [main] # Loop guard: don't re-trigger on the files this workflow itself commits. - # Listed explicitly (not '.codeboarding/**') so that editing your own - # .codeboarding/.codeboardingignore still regenerates the docs. (The action - # also skips re-analyzing its own bot commit as a backstop, and deliberately - # does NOT use [skip ci] — that would leak through squash-merges.) + # List every GENERATED file, and only generated files — never a user-authored + # input, whose edit changes analysis scope and must regenerate. So not + # '.codeboarding/**' (would swallow .codeboarding/.codeboardingignore) and not + # '.codeboarding/health/**' (would swallow your health/.healthignore and + # health/health_config.json); list only the generated health/health_report.json. + # (The action also skips re-analyzing its own bot commit as a backstop, and + # deliberately does NOT use [skip ci] — that would leak through squash-merges.) paths-ignore: - '.codeboarding/*.md' - '.codeboarding/analysis.json' + - '.codeboarding/fingerprint.json' - '.codeboarding/static_analysis.pkl' - '.codeboarding/static_analysis.sha' - '.codeboarding/codeboarding_version.json' - - '.codeboarding/health/**' + - '.codeboarding/health/health_report.json' - 'docs/development/architecture.md' workflow_dispatch: permissions: contents: write # commit the generated docs to the branch - id-token: write # free hosted tier (omit if you set llm_api_key/license_key) + id-token: write # hosted tiers, free + license (omit only if you set llm_api_key) concurrency: group: codeboarding-sync @@ -232,6 +236,44 @@ Behavior worth knowing: - The bot commit carries **no `[skip ci]`** — on a squash-merge that marker leaks into the merge commit and would skip the very sync run (and release tooling, CI) the merge should trigger. The regen loop is instead prevented by the `paths-ignore` list above **and** by the action skipping re-analysis of its own bot commit, so a merge to `main` reliably triggers a fresh incremental sync. - `output_dir` is owned by the action: pre-existing top-level markdown files in it are deleted on every run (stale component pages must not linger). Don't point it at a directory with hand-written docs. +### Protected default branch (open a PR instead of pushing) + +If your default branch rejects direct pushes (branch protection), set `sync_strategy: pull_request`. Instead of committing the baseline straight to `target_branch`, the action commits it to a machine-owned branch (`sync_pr_branch`, default `codeboarding/sync`) and opens — then keeps force-updating — a **single rolling PR** into `target_branch`. Merge that PR to land the baseline; your protection rules stay fully intact (no bypass actor), and the PR diff is always just the generated files. + +This builds on the sync workflow above — same `on:` (with the `paths-ignore` list) and the same `concurrency` block, which is **required** here (see the note on concurrency below). Only the job changes: + +```yaml +jobs: + sync: + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: write # push the sync branch + pull-requests: write # open/update the rolling PR + id-token: write # hosted tiers, free + license (omit only if you set llm_api_key) + steps: + - uses: CodeBoarding/CodeBoarding-action@v1 + with: + mode: sync + sync_strategy: pull_request + # push_token must carry pull-requests: write. The default github.token + # does when the job grants it (above) AND the repo/org setting "Allow + # GitHub Actions to create and approve pull requests" is enabled — see the + # note below. A GitHub App token or PAT also works, attributes the PR to + # that identity, and (unlike github.token) lets the PR trigger other + # workflows. + # push_token: ${{ steps.app-token.outputs.token }} +``` + +Operational requirements and behavior: + +- **Merge the rolling PR on a cadence.** The baseline reaches `target_branch` only when this PR is merged. That is what keeps review-mode diffs fast (review reads the baseline from the PR's base branch) *and* keeps incremental sync warm: each sync run seeds its incremental analysis from the baseline committed on `target_branch`, and re-detects **every** change since that last-merged baseline (change detection is whole-tree content-based, so no commits in between are ever missed — just larger diffs the longer you wait to merge). The action never seeds from the unmerged sync branch, so a poisoned or half-written baseline on that branch can never influence an analysis. +- **Merging the PR does not re-trigger a full analysis.** A merge of a baseline-only PR changes only generated files, which the `paths-ignore` above already excludes — so the workflow never starts. Keep that list complete and generated-only: your own `.codeboardingignore` / `.healthignore` / `health_config.json` are deliberately left out so editing analysis scope still regenerates. (If you drop a generated path from the list, the merge triggers one run that then no-ops at the "nothing to commit" step — a single wasted run, never a loop.) +- **Exclude the sync branch from your other PR workflows.** The rolling PR would otherwise trigger your review workflow, tests, and lint on a baseline-only diff. Use a **job-level head-branch guard** on each — `if: github.head_ref != 'codeboarding/sync'`. Note that `on.pull_request.branches-ignore` filters the PR's *base* branch, not its head, so it will **not** exclude the rolling PR (which targets `main`) — the `if:` guard is the correct tool. +- **Allow Actions to create PRs.** With the default `github.token`, `pull-requests: write` alone is not enough if your repo or org has disabled *Settings → Actions → General → "Allow GitHub Actions to create and approve pull requests"*. In that configuration the branch is pushed but every PR-create fails open (no PR). Enable that setting, or set `push_token` to a GitHub App token / PAT with `pull-requests: write`. +- **Keep the serializing `concurrency` block.** It is required, not optional: it makes sync runs execute one at a time so the newest commit's baseline wins the rolling PR. The action leases its force-push (`--force-with-lease`) as a safety net, but without the concurrency group two runs can still race and one will fail open rather than land — so a run may be skipped until the next change. +- **The sync branch is machine-owned.** It is reset to the current `target_branch` tip plus one baseline commit and force-pushed every run. Don't commit to it by hand. Closing the PR without merging is not sticky — the next push reopens it; use `workflow_dispatch` to pause. + ### How the two modes work together Sync mode keeps the committed `.codeboarding/analysis.json` baseline fresh on main. Review mode reuses that committed baseline from the target branch tip, so PR reviews diff against your *current* main architecture and run incrementally instead of rebuilding the target analysis from scratch — faster and cheaper per PR. @@ -263,8 +305,8 @@ Review mode does not need `contents: write`: PR-specific generated files are sto | `proxy_url` | both | CodeBoarding proxy | Hosted LLM proxy base URL for the free/license tiers (the engine's `OPENROUTER_BASE_URL`). Override only for a self-hosted/dev proxy. | | `mode` | both | `review` | `review` posts the PR architecture-diff comment; `sync` analyzes on push and commits the architecture (`analysis.json` + rendered docs) to `target_branch`, keeping it versioned and current. | | `github_token` | both | `${{ github.token }}` | Token for GitHub API calls; in review mode it posts or updates the PR comment. | -| `push_token` | sync | `${{ github.token }}` | Token used for sync-mode pushes to `target_branch`. The workflow token can push when the workflow grants `permissions: contents: write`. Separate from `github_token` so commenting can use a GitHub App token while the push uses the workflow token. | -| `codeboarding_version` | both | `0.13.1` | CodeBoarding PyPI package version used as the analysis engine. Pin for reproducibility. | +| `push_token` | sync | `${{ github.token }}` | Token for sync-mode delivery. The workflow token can push when the workflow grants `permissions: contents: write`. Separate from `github_token` so commenting can use a GitHub App token while the push uses the workflow token. In `sync_strategy: pull_request` it also opens/updates the rolling PR, so it must additionally carry `pull-requests: write`. | +| `codeboarding_version` | both | `0.13.3` | CodeBoarding PyPI package version used as the analysis engine. Pin for reproducibility. | | `depth_level` | both | empty (`2` for cold starts) | Analysis depth for first analysis and `force_full` rebuilds. Max depends on tier: **3** on the free hosted tier, **10** with a CodeBoarding license or your own `llm_api_key`. Once `.codeboarding/analysis.json` exists, its `metadata.depth_level` is the source of truth: sync runs incremental at the baseline depth, and review analyzes the PR head at the committed baseline depth so the diff is apples-to-apples (clamped to the tier max). | | `render_depth` | review | `1` | Display depth for the PR diagram. Keep `1` for a clean top-level view. | | `diagram_direction` | review | `LR` | Mermaid direction: `LR`, `TD`, `TB`, `RL`, or `BT`. | @@ -277,10 +319,13 @@ Review mode does not need `contents: write`: PR-specific generated files are sto | `webview_base_url` | review | `https://app.codeboarding.org` | Hosted webview base URL. The PR comment links to an artifact-backed head-vs-comparison-branch architecture diff. Set empty to disable the browser link. | | `output_dir` | sync | `.codeboarding` | Directory the rendered docs and analysis metadata are committed to. Owned by the action: pre-existing top-level `.md` files in it are deleted on every run. | | `output_format` | sync | `.md` | Output format. Only `.md` is supported. | -| `target_branch` | sync | `${{ github.ref_name }}` | Branch the generated docs are pushed to. | +| `target_branch` | sync | `${{ github.ref_name }}` | In `push` strategy, the branch the docs are pushed to. In `pull_request` strategy, the PR base branch. | | `write_architecture_md` | sync | `true` | Also write `docs/development/architecture.md`: all rendered pages concatenated, `overview.md` first. | | `commit_message` | sync | `chore(codeboarding): sync architecture baseline` | Commit message for the generated docs. No `[skip ci]` (it would leak through squash-merges); the regen loop is guarded by `paths-ignore` + the action's own bot-commit check. | | `force_full` | sync | `false` | Ignore any committed baseline and run a full analysis from scratch. Use to rebuild a stale or corrupt baseline (e.g. from a `workflow_dispatch`). | +| `sync_strategy` | sync | `push` | Delivery method. `push` commits and fast-forwards `target_branch` (needs `contents: write` and an unprotected branch). `pull_request` commits to `sync_pr_branch` and opens/updates one rolling PR into `target_branch`, for protected branches (needs `contents: write` **and** `pull-requests: write`). See [Protected default branch](#protected-default-branch-open-a-pr-instead-of-pushing). | +| `sync_pr_branch` | sync | `codeboarding/sync` | `pull_request` strategy only: the machine-owned head branch, force-updated each run. Ignored in `push` strategy. | +| `sync_pr_title` | sync | `[CodeBoarding sync]` | `pull_request` strategy only: prefix for the rolling baseline PR title. The analyzed commit hash and first 20 characters of its subject are appended and refreshed on every run. | ## Outputs @@ -292,7 +337,9 @@ Review mode does not need `contents: write`: PR-specific generated files are sto | `review_artifact_url` | review | GitHub Actions artifact URL containing the PR-head `analysis.json` and comparison-branch metadata. | | `analysis_mode` | sync | `full` or `incremental`: whether the run rebuilt the analysis from scratch or reused the committed baseline. | | `files_written` | sync | The generated files written for the docs commit. | -| `committed` | sync | `true` when a docs commit was pushed to `target_branch`; `false` when sync mode ran but had nothing to commit (or the push failed open). Empty only if sync mode did not run. | +| `committed` | sync | `true` when a baseline commit was delivered (pushed to `target_branch` in `push` strategy, or pushed to `sync_pr_branch` with its PR opened/updated in `pull_request` strategy); `false` when sync ran but had nothing to commit (or delivery failed open). Empty only if sync mode did not run. | +| `sync_pr_url` | sync | `pull_request` strategy: URL of the opened/updated rolling baseline PR. Empty in `push` strategy or when no PR was produced. | +| `sync_pr_number` | sync | `pull_request` strategy: number of the rolling baseline PR. Empty in `push` strategy or when no PR was produced. | Outputs of the mode that did not run are empty strings. @@ -315,7 +362,7 @@ Full local pipeline: ```bash export OPENROUTER_API_KEY=sk-or-... -python -m pip install codeboarding==0.13.1 +python -m pip install codeboarding==0.13.3 codeboarding-setup --auto-install-npm scripts/run_local.sh --repo /path/to/repo --base --head ``` diff --git a/action.yml b/action.yml index 9108687..75fc874 100644 --- a/action.yml +++ b/action.yml @@ -28,13 +28,13 @@ inputs: required: false default: ${{ github.token }} push_token: - description: 'Token used for sync-mode git pushes to target_branch. Defaults to the workflow github.token, which can push when the calling workflow grants "permissions: contents: write". Kept separate from github_token so commenting can use a GitHub App token while the push uses the workflow token (whose write access the consumer controls).' + description: 'Token used for sync-mode delivery. Defaults to the workflow github.token, which can push when the calling workflow grants "permissions: contents: write". Kept separate from github_token so commenting can use a GitHub App token while the push uses the workflow token (whose write access the consumer controls). In sync_strategy: pull_request this token ALSO opens/updates the rolling PR (via gh), so it must additionally carry pull-requests: write; a github.token-opened PR does not trigger other workflows, while an App/PAT-opened PR does.' required: false default: ${{ github.token }} codeboarding_version: description: 'CodeBoarding PyPI package version used as the analysis engine. Pin for reproducibility; set to a newer released version to opt into newer engine releases.' required: false - default: '0.13.2' + default: '0.13.3' depth_level: description: 'Analysis depth for cold-start or force_full rebuilds. Max depends on tier: 3 on the free hosted tier, 10 with a CodeBoarding license or your own llm_api_key. Once .codeboarding/analysis.json exists, its metadata.depth_level is the source of truth: sync runs incremental at the baseline depth, and review analyzes the PR head at the committed baseline depth so the diff is apples-to-apples (clamped to the tier max). Empty (default): 2 for cold starts.' required: false @@ -111,6 +111,18 @@ inputs: description: 'Sync mode: ignore any committed baseline and run a full analysis from scratch. Use to rebuild a stale or corrupt baseline (the manual escape hatch that replaces the old refresh-baseline workflow).' required: false default: 'false' + sync_strategy: + description: 'Sync mode: how the generated baseline is delivered. "push" (default): commit and fast-forward directly to target_branch (needs contents: write and an unprotected target_branch). "pull_request": commit to a dedicated bot branch (sync_pr_branch) and open/update ONE rolling PR into target_branch, for repositories whose default branch is protected against direct pushes (needs contents: write AND pull-requests: write, and push_token scoped to open PRs). Merge that PR on a cadence to keep the baseline on target_branch current — that is what keeps review-mode diffs fast and incremental sync warm.' + required: false + default: 'push' + sync_pr_branch: + description: 'Sync mode (pull_request strategy): the machine-owned head branch the baseline is committed to and the rolling PR is opened from. Force-updated (reset to the current target_branch tip + one baseline commit) on every run, so exactly one PR stays open and its diff is always just the generated files. Do not commit to it by hand — pushes are overwritten. Ignored when sync_strategy is "push".' + required: false + default: 'codeboarding/sync' + sync_pr_title: + description: 'Sync mode (pull_request strategy): prefix for the rolling baseline PR title. The analyzed commit hash and first 20 characters of its subject are appended and refreshed on every run. Ignored when sync_strategy is "push".' + required: false + default: '[CodeBoarding sync]' outputs: diagram_md: @@ -129,8 +141,14 @@ outputs: description: 'Sync mode: number of rendered markdown files in output_dir.' value: ${{ steps.sync_commit.outputs.files_written }} committed: - description: 'Sync mode: true when a docs commit was created and pushed.' + description: 'Sync mode: true when a baseline commit was created and delivered (pushed to target_branch in "push" strategy, or pushed to sync_pr_branch with its PR opened/updated in "pull_request" strategy).' value: ${{ steps.sync_commit.outputs.committed }} + sync_pr_url: + description: 'Sync mode (pull_request strategy): URL of the opened/updated rolling baseline PR (empty in "push" strategy or when no PR was produced).' + value: ${{ steps.sync_commit.outputs.sync_pr_url }} + sync_pr_number: + description: 'Sync mode (pull_request strategy): number of the opened/updated rolling baseline PR (empty in "push" strategy or when no PR was produced).' + value: ${{ steps.sync_commit.outputs.sync_pr_number }} review_artifact_url: description: 'Review mode: GitHub Actions artifact URL containing the PR-head analysis.json and metadata.' value: ${{ steps.upload_review_artifact.outputs.artifact-url }} @@ -199,6 +217,8 @@ runs: WRITE_ARCH: ${{ inputs.write_architecture_md }} DEPTH: ${{ inputs.depth_level }} HEAD_AUTHOR_EMAIL: ${{ github.event.head_commit.author.email }} + SYNC_STRATEGY: ${{ inputs.sync_strategy }} + SYNC_PR_BRANCH: ${{ inputs.sync_pr_branch }} run: | set -uo pipefail skip() { echo "::notice::$1 Skipping."; echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0; } @@ -245,6 +265,24 @@ runs: fi ;; esac + # Validate the sync delivery strategy up front (misconfig = hard fail). + case "$SYNC_STRATEGY" in + push|pull_request) ;; + *) echo "::error::sync_strategy must be 'push' or 'pull_request' (got '$SYNC_STRATEGY')."; exit 1 ;; + esac + if [ "$SYNC_STRATEGY" = "pull_request" ]; then + [ -n "$SYNC_PR_BRANCH" ] || { echo "::error::sync_pr_branch is empty."; exit 1; } + # Must differ from the PR base, or the force-push would target the base + # branch itself instead of opening a PR (silently no-PR on a protected + # base, or an overwrite of the base on a writable one). + [ "$SYNC_PR_BRANCH" != "$TARGET_BRANCH" ] || { echo "::error::sync_pr_branch ('$SYNC_PR_BRANCH') must differ from target_branch in pull_request strategy."; exit 1; } + fi + # Note: pull_request strategy has NO author-email loop guard — merging the + # rolling sync PR re-authors the commit to the merger, not the bot. The + # loop is prevented by the workflow's paths-ignore (a merged baseline PR + # touches only generated files, all listed there) plus the "nothing to + # commit" gate in the commit step, which stops any stray re-run from + # producing a new commit. See README "Protected default branch". case "$OUTPUT_FORMAT" in .md) ;; *) echo "::error::output_format must be .md."; exit 1 ;; @@ -1432,11 +1470,57 @@ runs: PUSH_TOKEN: ${{ inputs.push_token }} REPOSITORY: ${{ github.repository }} SERVER_URL: ${{ github.server_url }} + SYNC_STRATEGY: ${{ inputs.sync_strategy }} + SYNC_PR_BRANCH: ${{ inputs.sync_pr_branch }} + SYNC_PR_TITLE: ${{ inputs.sync_pr_title }} + # The source commit that was analyzed (recorded in the rolling PR body so + # reviewers see exactly which revision the baseline describes). + ANALYZED_SHA: ${{ steps.guard.outputs.target_sha }} + # gh (PR create/update) authenticates via GH_TOKEN. Reuse push_token so the + # branch push and the PR use one identity; it must carry pull-requests: write + # in pull_request strategy. Unused by the "push" strategy. + GH_TOKEN: ${{ inputs.push_token }} run: | set -euo pipefail echo "committed=false" >> "$GITHUB_OUTPUT" echo "files_written=0" >> "$GITHUB_OUTPUT" [ -n "$PUSH_TOKEN" ] && echo "::add-mask::$PUSH_TOKEN" + AUTH_URL="https://x-access-token:${PUSH_TOKEN}@${SERVER_URL#https://}/${REPOSITORY}.git" + + # The pull_request strategy rebuilds a machine-owned rolling branch on the + # LIVE target_branch tip each run (not the analyzed checkout), so the PR + # diff is always exactly the generated baseline — never a stale merge-base + # that would show conflicts once a prior sync PR has merged. The no-op gate + # below then compares against live target_branch too. The "push" strategy + # keeps committing on the analyzed checkout (github.sha) and fast-forwarding + # target_branch. The tree is clean here (analysis writes to a scratch dir), + # so resetting to the fetched tip cannot clobber local work. + SYNC_TIP="" + if [ "$SYNC_STRATEGY" = "pull_request" ]; then + if ! git fetch "$AUTH_URL" "$TARGET_BRANCH"; then + echo "::warning::Could not fetch ${TARGET_BRANCH} to rebuild ${SYNC_PR_BRANCH}; skipping this run. The next repository change can regenerate." + exit 0 + fi + # If target_branch advanced PAST the commit we analyzed (a newer push + # landed during this run), the push that caused it has its OWN queued sync + # run. Publishing here would put a baseline describing ANALYZED_SHA on top + # of a newer tip — a rolling PR whose baseline doesn't match its base. + # Defer to the newer run instead. Gated on ANALYZED_SHA being a strict + # ANCESTOR of the tip (a genuine forward move on this branch), so a + # target_branch deliberately decoupled from the triggering ref still + # publishes rather than silently deferring on every run. + BASE_TIP="$(git rev-parse FETCH_HEAD)" + if [ -n "$ANALYZED_SHA" ] && [ "$BASE_TIP" != "$ANALYZED_SHA" ] \ + && git merge-base --is-ancestor "$ANALYZED_SHA" "$BASE_TIP" 2>/dev/null; then + echo "::notice::${TARGET_BRANCH} advanced past the analyzed commit ${ANALYZED_SHA} (tip is now ${BASE_TIP}); deferring to the queued run for the newer commit." + exit 0 + fi + # Record the sync branch tip BEFORE we rebuild it, to lease the later + # force-push against it (see the push below). Empty when the branch does + # not exist yet (first run) — then the push plain-creates it. + SYNC_TIP="$(git ls-remote "$AUTH_URL" "refs/heads/${SYNC_PR_BRANCH}" 2>/dev/null | awk '{print $1; exit}')" + git checkout -B "$SYNC_PR_BRANCH" FETCH_HEAD + fi # Replace the previously committed rendered docs wholesale: stale files # (components that no longer exist) must be deleted, not left behind. @@ -1505,14 +1589,42 @@ runs: stage_paths+=("${old_md[@]}" "${new_md[@]}") git add -f -A -- "${stage_paths[@]}" + # A no-op means target_branch already carries the current baseline. In + # pull_request strategy that can happen while a rolling PR is still open — + # e.g. a source change was reverted before its baseline PR merged — leaving + # that PR proposing an OBSOLETE delta that must not stay mergeable. Deleting + # the remote branch auto-closes the PR; the next real change recreates both. + # Fail-open; a no-op in push strategy or when no sync branch exists. + reconcile_stale_sync_pr() { + [ "$SYNC_STRATEGY" = "pull_request" ] || return 0 + # Only remove the branch as we OBSERVED it at the start of this run + # (SYNC_TIP). If the remote tip has since changed, a concurrent newer run + # (when the caller omits the required concurrency group) already pushed a + # valid baseline — leave it and its PR alone rather than closing it. + local current + current="$(git ls-remote "$AUTH_URL" "refs/heads/${SYNC_PR_BRANCH}" 2>/dev/null | awk '{print $1; exit}')" + [ -n "$current" ] || return 0 + if [ "$current" != "$SYNC_TIP" ]; then + echo "Skipping reconcile: ${SYNC_PR_BRANCH} advanced since this run started; a newer run owns it." + return 0 + fi + if git push "$AUTH_URL" --delete "refs/heads/${SYNC_PR_BRANCH}" >/dev/null 2>&1; then + echo "Reconciled: deleted ${SYNC_PR_BRANCH} to close a now-obsolete sync PR (${TARGET_BRANCH} already current)." + else + echo "::warning::Could not delete ${SYNC_PR_BRANCH} to close a stale sync PR; it may need manual closing." + fi + } + if git diff --cached --quiet; then echo "::notice::Generated architecture is unchanged; nothing to commit." + reconcile_stale_sync_pr echo "files_written=${#new_md[@]}" >> "$GITHUB_OUTPUT" exit 0 fi if git diff --cached --quiet -I '"generated_at"' -I '"timestamp"'; then git reset -q echo "::notice::Only volatile timestamp fields changed; skipping commit." + reconcile_stale_sync_pr echo "files_written=${#new_md[@]}" >> "$GITHUB_OUTPUT" exit 0 fi @@ -1521,10 +1633,84 @@ runs: git config user.email "codeboarding-review[bot]@users.noreply.github.com" git commit -m "$COMMIT_MESSAGE" >/dev/null - # Push with fetch+rebase retries: another push may land on target_branch - # while the analysis runs. Fail-open on final rejection — the next - # repository change regenerates. - AUTH_URL="https://x-access-token:${PUSH_TOKEN}@${SERVER_URL#https://}/${REPOSITORY}.git" + # ---- Deliver: pull_request strategy — force-push the rolling branch and + # ensure exactly one open PR into target_branch. ---- + if [ "$SYNC_STRATEGY" = "pull_request" ]; then + NEW_SHA="$(git rev-parse HEAD)" + # Push the machine-owned rolling branch, leased so a concurrent run (when + # the caller omits the required serializing concurrency group) is never + # silently clobbered — the losing push fails and we fail open, letting the + # next change reconcile. If the branch existed at the start of this step + # (SYNC_TIP), force-with-lease against that exact tip. If it did NOT exist, + # a plain (non-force) create is itself a lease: it fails if a concurrent + # first run created the branch first. + if [ -n "$SYNC_TIP" ]; then + push_args=(--force-with-lease="refs/heads/${SYNC_PR_BRANCH}:${SYNC_TIP}") + else + push_args=() + fi + # `${push_args[@]+...}` guards the empty-array case: a bare "${push_args[@]}" + # aborts under `set -u` on bash < 4.4 (macOS-hosted / minimal-container + # runners), which would break the fail-open contract on a first run. + if ! git push ${push_args[@]+"${push_args[@]}"} "$AUTH_URL" "HEAD:refs/heads/${SYNC_PR_BRANCH}"; then + echo "::warning::Could not update ${SYNC_PR_BRANCH} (a concurrent run may have pushed a newer baseline, or contents: write is missing). The next repository change can regenerate." + echo "files_written=${#new_md[@]}" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "files_written=${#new_md[@]}" >> "$GITHUB_OUTPUT" + echo "Pushed baseline to ${SYNC_PR_BRANCH} as ${NEW_SHA}." + + # PR body records the analyzed source revision so reviewers see exactly + # which commit the baseline describes. Kept current on every run. + SRC_SHORT="$(git rev-parse --short "$ANALYZED_SHA" 2>/dev/null || printf '%s' "${ANALYZED_SHA:0:7}")" + SRC_SUBJECT="$(git log -1 --format=%s "$ANALYZED_SHA" 2>/dev/null || true)" + SRC_SUMMARY="$(printf '%s' "${SRC_SUBJECT:0:20}" | sed 's/[[:space:]]*$//')" + PR_TITLE="${SYNC_PR_TITLE} ${SRC_SHORT}: ${SRC_SUMMARY}" + PR_BODY="$(printf '%s\n\n%s' \ + "Generated from source revision \`${SRC_SHORT}\`${SRC_SUBJECT:+: ${SRC_SUBJECT}} (target \`${TARGET_BRANCH}\`)." \ + "Automated CodeBoarding architecture baseline sync. Regenerated and force-updated on every push; merge it to keep the committed analysis under \`${OUTPUT_DIR}/\` current, which keeps pull-request reviews fast and incremental sync warm. \`${SYNC_PR_BRANCH}\` is machine-owned and overwritten on every run.")" + + # Ensure ONE open PR from the sync branch into target_branch. Every gh call + # fails open: an under-scoped token (no pull-requests: write) or an API + # error degrades to "branch pushed, no PR" with a warning, never a red job. + # `gh pr list --head` is a bare branch-NAME filter (no owner:branch), so a + # fork PR that happens to use the same head branch name could match; the + # isCrossRepository==false filter keeps this to OUR own sync branch's PR so + # we never edit a contributor's PR body. + own_open_sync_pr() { + gh pr list --repo "$REPOSITORY" --head "$SYNC_PR_BRANCH" --base "$TARGET_BRANCH" --state open \ + --json number,isCrossRepository --jq 'map(select(.isCrossRepository == false))[0].number // empty' 2>/dev/null || true + } + pr_number="$(own_open_sync_pr)" + if [ -n "$pr_number" ]; then + # Existing rolling PR — refresh its title and body to the current + # source revision. + gh pr edit "$pr_number" --repo "$REPOSITORY" --title "$PR_TITLE" --body "$PR_BODY" >/dev/null 2>&1 || true + pr_url="$(gh pr view "$pr_number" --repo "$REPOSITORY" --json url --jq '.url' 2>/dev/null || true)" + else + # No open PR yet — create one. A dedup error from an eventually-consistent + # replica ("already exists" / 422) is success: re-query for the open PR. + pr_url="$(gh pr create --repo "$REPOSITORY" --head "$SYNC_PR_BRANCH" --base "$TARGET_BRANCH" --title "$PR_TITLE" --body "$PR_BODY" 2>/dev/null || true)" + pr_number="$(own_open_sync_pr)" + if [ -z "$pr_url" ] && [ -n "$pr_number" ]; then + pr_url="$(gh pr view "$pr_number" --repo "$REPOSITORY" --json url --jq '.url' 2>/dev/null || true)" + fi + fi + if [ -n "$pr_url" ]; then + echo "committed=true" >> "$GITHUB_OUTPUT" + echo "pushed_sha=$NEW_SHA" >> "$GITHUB_OUTPUT" + echo "sync_pr_url=$pr_url" >> "$GITHUB_OUTPUT" + echo "sync_pr_number=$pr_number" >> "$GITHUB_OUTPUT" + echo "Sync PR ready: $pr_url" + else + echo "::warning::Sync branch ${SYNC_PR_BRANCH} pushed, but no PR into ${TARGET_BRANCH} could be opened or found. Ensure the token carries pull-requests: write and that the repository allows GitHub Actions to create pull requests." + fi + exit 0 + fi + + # ---- Deliver: push strategy — fast-forward target_branch with fetch+rebase + # retries: another push may land on target_branch while the analysis runs. + # Fail-open on final rejection — the next repository change regenerates. ---- for attempt in 1 2 3; do if git push "$AUTH_URL" "HEAD:refs/heads/${TARGET_BRANCH}"; then NEW_SHA="$(git rev-parse HEAD)" @@ -1535,7 +1721,7 @@ runs: exit 0 fi if [ "$attempt" -eq 3 ]; then - echo "::warning::Could not push architecture to ${TARGET_BRANCH}; likely missing contents: write permission or branch protection rejected the bot." + echo "::warning::Could not push architecture to ${TARGET_BRANCH}; likely missing contents: write permission or branch protection rejected the bot (use sync_strategy: pull_request for a protected branch)." echo "files_written=${#new_md[@]}" >> "$GITHUB_OUTPUT" exit 0 fi @@ -1566,6 +1752,8 @@ runs: FILES_WRITTEN: ${{ steps.sync_commit.outputs.files_written }} COMMITTED: ${{ steps.sync_commit.outputs.committed }} PUSHED_SHA: ${{ steps.sync_commit.outputs.pushed_sha }} + SYNC_STRATEGY: ${{ inputs.sync_strategy }} + SYNC_PR_URL: ${{ steps.sync_commit.outputs.sync_pr_url }} run: | set -euo pipefail NOW="$(date +%s)" @@ -1577,7 +1765,14 @@ runs: echo "- Analysis mode: ${MODE:-unknown}" echo "- Depth: $DEPTH" echo "- Rendered markdown files: ${FILES_WRITTEN:-0}" - echo "- Commit pushed: ${COMMITTED:-false}" + if [ "${SYNC_STRATEGY:-push}" = "pull_request" ]; then + echo "- Baseline PR updated: ${COMMITTED:-false}" + if [ -n "${SYNC_PR_URL:-}" ]; then + echo "- Sync PR: $SYNC_PR_URL" + fi + else + echo "- Commit pushed: ${COMMITTED:-false}" + fi if [ -n "${PUSHED_SHA:-}" ]; then echo "- Pushed SHA: $PUSHED_SHA" fi diff --git a/docs/COMMIT_STRATEGY.md b/docs/COMMIT_STRATEGY.md index 595aa26..2f42073 100644 --- a/docs/COMMIT_STRATEGY.md +++ b/docs/COMMIT_STRATEGY.md @@ -26,6 +26,8 @@ The engine writes these under `.codeboarding/`: > **Principle:** sync mode is the only git writer. Review mode never commits generated files to PR branches, so generated artifacts cannot conflict with `main` during merge. +**Delivery (`sync_strategy`).** The *set* of committed files above is identical either way; only how it reaches `main` differs. `push` (default) fast-forwards `main` directly. `pull_request` (for protected `main`) commits the same files to a machine-owned `sync_pr_branch` and opens one rolling PR into `main` — the baseline reaches `main` only on merge. Incremental sync always seeds from the baseline committed on `main` (never from the unmerged PR branch — that keeps an untrusted `static_analysis.pkl` off the runner), so under `pull_request` the rolling PR must be merged on a cadence to keep the baseline warm; each run still re-detects **every** change since the last-merged baseline via the whole-tree `fingerprint.json`, so no commits are missed between merges. + ## Where to commit — two separate workflows 1. **CI/CD on `main` (the baseline keeper).** On push to `main`, regenerate and commit `analysis.json`, `static_analysis.pkl`, `static_analysis.sha`, `health/health_report.json`, and rendered docs to `main`. Keeps the baseline current so PRs diff against an accurate, up-to-date snapshot and the extension shows a real diagram on the default branch.