diff --git a/README.md b/README.md index ea4a95f..6304051 100644 --- a/README.md +++ b/README.md @@ -18,48 +18,48 @@ name: CodeBoarding review on: pull_request: - types: [opened, reopened, ready_for_review, synchronize] + types: [opened, reopened, ready_for_review, closed, synchronize] issue_comment: types: [created] -permissions: - contents: read - actions: read # download the analysis an earlier run published - pull-requests: write - issues: write - id-token: write +# No workflow-level permissions: each job requests only what it needs (least +# privilege), so the default token starts with none. +permissions: {} -# One review at a time per pull request. Two pushes in quick succession would -# otherwise analyze concurrently, and both would start from the same older -# analysis instead of the newer one continuing from its predecessor. They also -# share one sticky comment, so whichever finishes last wins — which can be the -# run for the older commit. Queue rather than cancel, so a /codeboarding command -# waits for a running review instead of killing it. concurrency: group: codeboarding-${{ github.event.pull_request.number || github.event.issue.number }} - cancel-in-progress: false + cancel-in-progress: ${{ github.event_name == 'pull_request' && github.event.action == 'closed' }} jobs: review: + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read # check out the repo + read the committed baseline (no writes in review mode) + pull-requests: write # post the architecture-diff PR comment + issues: write # the /codeboarding issue_comment trigger + comment API + id-token: write # mint a GitHub OIDC token for CodeBoarding's hosted tier + actions: read # let a repeat review download the analysis an earlier run published, instead of re-deriving the whole PR if: > - (github.event_name == 'pull_request' && github.event.pull_request.draft == false && + (github.event_name == 'pull_request' && github.event.action != 'closed' && + github.event.pull_request.draft == false && 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)) - runs-on: ubuntu-latest - timeout-minutes: 60 steps: - uses: CodeBoarding/CodeBoarding-action@v1 with: - llm: hosted # or license, or a provider name -- see Authentication + # CodeBoarding's free hosted tier. No secret to add: the run authenticates + # with the GitHub OIDC token that `id-token: write` above grants. + llm: hosted ``` Automatic runs update one sticky **CodeBoarding review** comment. A trusted repository owner, member, or collaborator can comment `/codeboarding` to analyze the current PR head again, including on fork PRs; every command creates a new result comment. `synchronize` re-runs the review on every push to the branch. Each of those runs covers only the commits pushed since the previous one, so a push costs a fraction of a first analysis — and a pushed commit is the only thing that builds the reusable analysis, since GitHub gives comment-triggered runs a read-only cache. Drop `synchronize` from the list if you would rather spend one analysis per pull request than one per push. -Keep the `concurrency` block if you keep `synchronize`: it is what makes a push continue from the push before it, and what stops a slower run for an older commit from overwriting the review comment for a newer one. Set `cancel-in-progress: true` instead to abandon a superseded run rather than queue it, which costs less when branches are pushed to rapidly, at the price of no analysis for the commits in between. +Keep the `concurrency` block if you keep `synchronize`: it is what makes a push continue from the push before it, and what stops a slower run for an older commit from overwriting the review comment for a newer one. `cancel-in-progress` fires only when a pull request is *closed*, which is why `closed` is in the trigger list: it stops an in-flight review finishing for a pull request nobody is going to read. Set it to `true` to abandon any superseded run rather than queue it, which costs less on rapidly pushed branches, at the price of no analysis for the commits in between. `/codeboarding` analyzes the current head, reusing this pull request's previous analysis when there is one. It takes no arguments. @@ -231,25 +231,34 @@ name: CodeBoarding sync on: push: - branches: [main] + branches: ['main'] + # Loop guard: don't re-trigger on the files this workflow itself commits. + # List generated files only: user-authored scope configuration must still trigger + # regeneration, while a merged sync PR must not trigger a loop. paths-ignore: + - '.codeboarding/*.md' - '.codeboarding/analysis.json' - '.codeboarding/fingerprint.json' - '.codeboarding/static_analysis.pkl' - '.codeboarding/static_analysis.sha' - '.codeboarding/codeboarding_version.json' + - '.codeboarding/health/health_report.json' + - 'docs/development/architecture.md' workflow_dispatch: inputs: force_full: - description: Rebuild without the committed baseline + description: 'Ignore the committed baseline and rebuild it from scratch (full analysis).' type: boolean + required: false default: false permissions: - contents: write - id-token: write + contents: write # commit the generated baseline + docs to the branch + id-token: write # identifies this repo to CodeBoarding's hosted tier; a run on your own + # provider key never mints one, so it is not granted there concurrency: + # Serialize against itself so a push landing mid-run can't make two commits. group: codeboarding-sync cancel-in-progress: false @@ -261,9 +270,11 @@ jobs: - uses: CodeBoarding/CodeBoarding-action@v1 with: mode: sync - llm: hosted - target_branch: main force_full: ${{ inputs.force_full || false }} + target_branch: 'main' + # CodeBoarding's free hosted tier. No secret to add: the run authenticates + # with the GitHub OIDC token that `id-token: write` above grants. + llm: hosted ``` The first run, `force_full: true`, or an incompatible baseline causes a full analysis. Otherwise sync asks Core for an incremental update. If the generated state is unchanged, no commit is created. If the target advances while analysis is running, the stale result is not rebased onto code it did not analyze; the newer push run is allowed to produce the current baseline. diff --git a/scripts/action/workflow_templates.py b/scripts/action/workflow_templates.py new file mode 100755 index 0000000..ac3e581 --- /dev/null +++ b/scripts/action/workflow_templates.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +"""Render a workflow template, or recognise one that is already committed. + +Both directions from one file, because they are the same knowledge read two ways. A hole is +substituted to render and captured to match, so a template can never be renderable but +unmatchable, which is exactly how a generator and a detector drift apart when they are +written separately. + +Recognising beats parsing. If a committed workflow matches the v3 template, we wrote v3, so +its triggers, permissions and credential wiring are already known and none of them has to be +inferred from the file. A file that matches nothing has been edited, which is a fact rather +than the heuristic guess that "no extra steps and no extra inputs" gives. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent +TEMPLATES = ROOT / "templates" +PROVIDERS = ROOT / "scripts" / "action" / "supported-providers.json" + +HOLE = re.compile(r"\{\{(\w+)\}\}") + + +def _same(fill: str, captured: str) -> bool: + """Normalised, and forgiving only about the trailing newline a hole cannot capture.""" + return normalise(fill).rstrip("\n") == normalise(captured).rstrip("\n") + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def normalise(text: str) -> str: + """Line endings and trailing whitespace, and nothing cleverer. + + Anything more forgiving starts matching files we did not write, which turns "this is + yours, we will not touch it" into a silent overwrite. + """ + return "\n".join(line.rstrip() for line in text.replace("\r\n", "\n").split("\n")) + + +def credential_fills(version: int | None = None) -> dict[str, str]: + """Every credential block a generated workflow can carry, keyed by tier or provider. + + `byok` is one authored fill expanded across the provider table, so adding a provider is + a change to that table and nowhere else. + """ + root = fills_root(version) + fills = { + "hosted": _read(root / "credentials.hosted.yml"), + "license": _read(root / "credentials.license.yml"), + } + byok = _read(root / "credentials.byok.yml") + endpoint = _read(root / "credentials.byok-endpoint.yml") + table = json.loads(_read(providers_path(version))) + for name, provider in table["providers"].items(): + # The input that SELECTS the provider, not merely the one that looks like a key. + # ollama and litellm are selected by their base URL, so a workflow wiring + # `ollama_api_key` is refused by the action's own contract with + # `missing_provider_key`, after telling the user to create a secret that could + # never have worked. The contract already draws this line; the fill has to as well. + selectors = [i for i, var in provider["inputs"].items() if var in provider["selection_envs"]] + keys = [i for i in selectors if i.endswith("_api_key")] + if keys: + fills[f"byok:{name}"] = ( + byok.replace("{{LABEL}}", provider["label"]) + .replace("{{SECRET}}", provider["inputs"][keys[0]]) + .replace("{{KEY_INPUT}}", keys[0]) + .replace("{{LLM}}", name) + ) + else: + fills[f"byok:{name}"] = ( + endpoint.replace("{{LABEL}}", provider["label"]) + .replace("{{SELECTOR}}", selectors[0]) + .replace("{{LLM}}", name) + ) + return fills + + +def extra_permissions(tier: str, delivery: str, version: int | None = None) -> str: + """The optional lines in the sync job's `permissions:` block, in file order.""" + root = fills_root(version) + oidc = "byok" if tier.startswith("byok") else "hosted" + return _read(root / f"delivery.permission.{delivery}.yml") + _read(root / f"oidc.sync.{oidc}.yml") + + +def fills_for(kind: str, tier: str, delivery: str, version: int | None = None) -> dict[str, str]: + """What each hole in `kind` takes, for one configuration.""" + root = fills_root(version) + creds = credential_fills(version)[tier] + # Least privilege, and it is the credentials that decide it: only the hosted tiers mint + # an OIDC token, so a workflow running on the user's own provider key has no reason to + # let a moving third-party action identify their repository. + oidc = "byok" if tier.startswith("byok") else "hosted" + if kind == "review": + return { + "CREDENTIALS": creds, + "OIDC_PERMISSION": _read(root / f"oidc.review.{oidc}.yml"), + "SYNC_PR_GUARD": _read(root / f"sync_pr_guard.{delivery}.yml"), + } + return { + "CREDENTIALS": creds, + # One hole, not two. The delivery permission and the OIDC permission are adjacent + # lines in the same block, and two adjacent holes cannot be told apart: the regex + # would let the first capture nothing and the second capture both. + "EXTRA_PERMISSIONS": extra_permissions(tier, delivery, version), + "DELIVERY_INPUT": _read(root / f"delivery.input.{delivery}.yml"), + } + + +def render(kind: str, *, branch: str, tier: str, delivery: str, version: int | None = None) -> str: + """The file we would write for this configuration.""" + template = _read(template_path(kind, version)) + values = {"BRANCH": yaml_scalar(branch), **fills_for(kind, tier, delivery, version)} + return HOLE.sub(lambda m: values[m.group(1)], template) + + +def yaml_scalar(value: str) -> str: + """A value safe to drop inside single quotes. + + Branch names may contain an apostrophe: `release/o'neil` is a valid ref, and + interpolating it raw produced `branches: ['release/o'neil']`, which GitHub cannot + parse. Doubling is how a single-quoted YAML scalar escapes one. + """ + return value.replace("'", "''") + + +def fills_root(version: int | None = None) -> Path: + """Fills belong to the version that shipped them. + + A historical template with today's fills is not that historical template. If a fill's + wording or the provider table changed since, every repository on that version would + stop matching and be reported as hand-edited, which is exactly the failure the history + exists to prevent. + """ + return TEMPLATES / "fills" if version is None else TEMPLATES / "history" / f"v{version}" / "fills" + + +def providers_path(version: int | None = None) -> Path: + if version is None: + return PROVIDERS + return TEMPLATES / "history" / f"v{version}" / "supported-providers.json" + + +def template_path(kind: str, version: int | None = None) -> Path: + name = "codeboarding.yml" if kind == "review" else "codeboarding-sync.yml" + if version is None: + return TEMPLATES / name + return TEMPLATES / "history" / f"v{version}" / name + + +def to_pattern(template: str) -> re.Pattern[str]: + """The same template as a matcher, each hole a capture group. + + Built from the template rather than written alongside it, so the two cannot disagree + about what is fixed and what is configurable. + """ + parts, holes, last = [], [], 0 + for hole in HOLE.finditer(template): + parts.append(re.escape(normalise(template[last : hole.start()]))) + holes.append(hole.group(1)) + parts.append(f"(?P<{hole.group(1)}_{len(holes)}>[\\s\\S]*?)") + last = hole.end() + parts.append(re.escape(normalise(template[last:]))) + # `\\Z`, not `$`: `$` also matches just before a trailing newline, so a hole at the end + # of a template captures one character less than the fill that produced it. + return re.compile("\\A" + "".join(parts) + "\\Z") + + +def match(kind: str, text: str, version: int | None = None) -> dict[str, str] | None: + """What produced this file, or None when nothing we shipped did. + + Every capture is checked against what we could have written there. The holes accept + arbitrary text by construction, so without that check an edited delivery block or a + credential block we never authored would still "match", and the update path would then + claim ownership of a file it did not write and overwrite the user's edits. + """ + template = _read(template_path(kind, version)) + found = to_pattern(template).match(normalise(text)) + if not found: + return None + + # A hole that appears twice must capture the same value both times. `branches:` and + # `target_branch:` are one setting written in two places; editing only one of them is + # an edit, not a configuration we generated. + captured: dict[str, str] = {} + for group, value in found.groupdict().items(): + name = group.rsplit("_", 1)[0] + if name in captured and captured[name] != value: + return None + captured[name] = value + + result: dict[str, str] = {} + if "BRANCH" in captured: + result["branch"] = captured["BRANCH"] + + fills = credential_fills(version) + tier = next( + (t for t, body in fills.items() if _same(body, captured.get("CREDENTIALS", ""))), + None, + ) + if tier is None: + return None # a credential block we never wrote: the file has been edited + result["tier"] = tier + + # Delivery is two or three separate holes that have to describe the SAME mode. Reading + # one of them and inferring the rest would accept a file with a pull-request guard and + # a push permission, which is not something we ever generate. + for delivery in ("push", "pull_request"): + if all( + _same(expected, captured[key]) + for key, expected in ( + ("SYNC_PR_GUARD", _read(fills_root(version) / f"sync_pr_guard.{delivery}.yml")), + ("EXTRA_PERMISSIONS", extra_permissions(tier, delivery, version)), + ("DELIVERY_INPUT", _read(fills_root(version) / f"delivery.input.{delivery}.yml")), + ) + if key in captured + ): + result["delivery"] = delivery + return result + return None # the delivery holes disagree, or none of them is a fill we authored + + +def bundle() -> dict: + """Everything a consumer needs, in one file it can import. + + The webview bundles its generator into a browser build, so it cannot read these files + from disk. Rather than have it keep a second, hand-maintained copy of the text, the + action publishes the templates as data and the webview vendors that one artifact. + `tests/test_workflow_templates.py` asserts this matches the .yml files it is built from, + so the authored template stays the thing under review. + """ + log = json.loads(_read(TEMPLATES / "CHANGELOG.json")) + kinds = {"review": "codeboarding.yml", "sync": "codeboarding-sync.yml"} + return { + "current": log["current"], + "changelog": log["versions"], + "templates": {k: _read(TEMPLATES / name) for k, name in kinds.items()}, + "credentials": credential_fills(), + "oidc": { + kind: {t: _read(TEMPLATES / "fills" / f"oidc.{kind}.{t}.yml") for t in ("hosted", "byok")} + for kind in ("review", "sync") + }, + "delivery": { + d: { + "permission": _read(TEMPLATES / "fills" / f"delivery.permission.{d}.yml"), + "input": _read(TEMPLATES / "fills" / f"delivery.input.{d}.yml"), + "sync_pr_guard": _read(TEMPLATES / "fills" / f"sync_pr_guard.{d}.yml"), + } + for d in ("push", "pull_request") + }, + } + + +def write_bundle() -> Path: + path = TEMPLATES / "bundle.json" + path.write_text(json.dumps(bundle(), indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + return path + + +if __name__ == "__main__": + print(write_bundle()) diff --git a/templates/CHANGELOG.json b/templates/CHANGELOG.json new file mode 100644 index 0000000..4f85a84 --- /dev/null +++ b/templates/CHANGELOG.json @@ -0,0 +1,25 @@ +{ + "_comment": [ + "Every workflow template CodeBoarding has shipped, newest first, with one sentence", + "saying what changed. The webview renders the entries between the version a repository", + "matches and `current` into the message it shows, so this file IS the user-facing copy;", + "it is not a developer changelog that someone later paraphrases.", + "", + "`type` decides how an entry composes with the ones after it:", + " update - adds to what came before; concatenated with later entries.", + " replace - supersedes every earlier description; rendering starts here.", + "", + "`replace` is what keeps a revert honest. Without it, a repository two versions behind a", + "change that was itself undone would be told about both, and read a description of a", + "journey rather than of where it is going." + ], + "current": 1, + "versions": [ + { + "version": 1, + "type": "replace", + "summary": "Says where CodeBoarding gets its AI credentials, and fails with the missing secret named instead of quietly running on a different provider.", + "detail": "First template tracked by version. Everything committed before this is recognised by content, and described by this entry." + } + ] +} diff --git a/templates/README.md b/templates/README.md new file mode 100644 index 0000000..e9fc31f --- /dev/null +++ b/templates/README.md @@ -0,0 +1,47 @@ +# Workflow templates + +The workflows CodeBoarding writes into a repository, and the only place their text lives. + +Three things read these files: + +- the **webview**, which renders them when it opens a setup pull request; +- the **webview again**, which matches a repository's committed workflow against every + version here to work out which one it is running and what has changed since; +- the **action's README**, whose copy-and-paste examples are generated from them, so the + file someone copies by hand and the file the button writes cannot drift apart. They had + already drifted before this existed: the README's review workflow lacked the `closed` + trigger, so closing a pull request left its analysis running to completion. + +## Shape + +A template is the finished file with named holes: + + {{BRANCH}} target branch, in the push filter and in `target_branch` + {{CREDENTIALS}} the `llm:` block, one of the fills below + {{DELIVERY_PERMISSION}} `pull-requests: write`, or nothing + {{DELIVERY_INPUT}} `sync_strategy: pull_request`, or nothing + {{SYNC_PR_GUARD}} skips the rolling baseline PR, or nothing + +Holes are what makes a template both renderable and matchable. Rendering substitutes them. +Matching turns the same file into a regular expression, with each hole a capture group, so +one pass over a committed workflow answers *which version* and *configured how* together. +Nothing has to be parsed, and nothing has to be inferred. + +## Fills + +`fills/` holds every value a hole can take, verbatim. `credentials.byok.yml` is itself a +template, expanded across the provider table in `scripts/action/supported-providers.json`, +so adding a provider stays a one-line change to that table. + +## History + +`history/` holds the exact bytes of every template version we have shipped. It must be +frozen, never regenerated: if an old version were re-rendered by today's code, one cosmetic +change would invalidate every repository on that version at once and they would all read as +edited-by-hand. + +## Changelog + +`CHANGELOG.json` carries one sentence per version, typed `update` or `replace`. It is the +copy the webview shows, not a developer changelog someone later paraphrases. Adding a +template version means adding an entry; there is no separate detector to write. diff --git a/templates/bundle.json b/templates/bundle.json new file mode 100644 index 0000000..9d4bdc2 --- /dev/null +++ b/templates/bundle.json @@ -0,0 +1,54 @@ +{ + "current": 1, + "changelog": [ + { + "version": 1, + "type": "replace", + "summary": "Says where CodeBoarding gets its AI credentials, and fails with the missing secret named instead of quietly running on a different provider.", + "detail": "First template tracked by version. Everything committed before this is recognised by content, and described by this entry." + } + ], + "templates": { + "review": "name: CodeBoarding review\n\non:\n pull_request:\n types: [opened, reopened, ready_for_review, closed, synchronize]\n issue_comment:\n types: [created]\n\n# No workflow-level permissions: each job requests only what it needs (least\n# privilege), so the default token starts with none.\npermissions: {}\n\nconcurrency:\n group: codeboarding-${{ github.event.pull_request.number || github.event.issue.number }}\n cancel-in-progress: ${{ github.event_name == 'pull_request' && github.event.action == 'closed' }}\n\njobs:\n review:\n runs-on: ubuntu-latest\n timeout-minutes: 60\n permissions:\n contents: read # check out the repo + read the committed baseline (no writes in review mode)\n pull-requests: write # post the architecture-diff PR comment\n issues: write # the /codeboarding issue_comment trigger + comment API\n{{OIDC_PERMISSION}} actions: read # let a repeat review download the analysis an earlier run published, instead of re-deriving the whole PR\n if: >\n (github.event_name == 'pull_request' && github.event.action != 'closed' &&\n github.event.pull_request.draft == false &&\n github.event.pull_request.head.repo.full_name == github.repository{{SYNC_PR_GUARD}}) ||\n (github.event_name == 'issue_comment' && github.event.issue.pull_request != null &&\n startsWith(github.event.comment.body, '/codeboarding') &&\n contains(fromJSON('[\"OWNER\",\"MEMBER\",\"COLLABORATOR\"]'), github.event.comment.author_association))\n steps:\n - uses: CodeBoarding/CodeBoarding-action@v1\n with:\n{{CREDENTIALS}}", + "sync": "name: CodeBoarding sync\n\non:\n push:\n branches: ['{{BRANCH}}']\n # Loop guard: don't re-trigger on the files this workflow itself commits.\n # List generated files only: user-authored scope configuration must still trigger\n # regeneration, while a merged sync PR must not trigger a loop.\n paths-ignore:\n - '.codeboarding/*.md'\n - '.codeboarding/analysis.json'\n - '.codeboarding/fingerprint.json'\n - '.codeboarding/static_analysis.pkl'\n - '.codeboarding/static_analysis.sha'\n - '.codeboarding/codeboarding_version.json'\n - '.codeboarding/health/health_report.json'\n - 'docs/development/architecture.md'\n workflow_dispatch:\n inputs:\n force_full:\n description: 'Ignore the committed baseline and rebuild it from scratch (full analysis).'\n type: boolean\n required: false\n default: false\n\npermissions:\n contents: write # commit the generated baseline + docs to the branch\n{{EXTRA_PERMISSIONS}}\nconcurrency:\n # Serialize against itself so a push landing mid-run can't make two commits.\n group: codeboarding-sync\n cancel-in-progress: false\n\njobs:\n sync:\n runs-on: ubuntu-latest\n timeout-minutes: 60\n steps:\n - uses: CodeBoarding/CodeBoarding-action@v1\n with:\n mode: sync\n force_full: ${{ inputs.force_full || false }}\n{{DELIVERY_INPUT}} target_branch: '{{BRANCH}}'\n{{CREDENTIALS}}" + }, + "credentials": { + "hosted": " # CodeBoarding's free hosted tier. No secret to add: the run authenticates\n # with the GitHub OIDC token that `id-token: write` above grants.\n llm: hosted\n", + "license": " # Your CodeBoarding plan, on CodeBoarding's hosted tier. The run fails with\n # a message naming this secret if CODEBOARDING_LICENSE is not set.\n llm: license\n license_key: ${{ secrets.CODEBOARDING_LICENSE }}\n", + "byok:openrouter": " # Your own OpenRouter key: every run calls OpenRouter directly with it.\n # Add OPENROUTER_API_KEY under Settings → Secrets and variables → Actions. Until it\n # exists the run FAILS and says so. It does not fall back to a different\n # provider on CodeBoarding's hosted tier.\n llm: openrouter\n openrouter_api_key: ${{ secrets.OPENROUTER_API_KEY }}\n", + "byok:orcarouter": " # Your own OrcaRouter key: every run calls OrcaRouter directly with it.\n # Add ORCAROUTER_API_KEY under Settings → Secrets and variables → Actions. Until it\n # exists the run FAILS and says so. It does not fall back to a different\n # provider on CodeBoarding's hosted tier.\n llm: orcarouter\n orcarouter_api_key: ${{ secrets.ORCAROUTER_API_KEY }}\n", + "byok:anthropic": " # Your own Anthropic key: every run calls Anthropic directly with it.\n # Add ANTHROPIC_API_KEY under Settings → Secrets and variables → Actions. Until it\n # exists the run FAILS and says so. It does not fall back to a different\n # provider on CodeBoarding's hosted tier.\n llm: anthropic\n anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}\n", + "byok:openai": " # Your own OpenAI key: every run calls OpenAI directly with it.\n # Add OPENAI_API_KEY under Settings → Secrets and variables → Actions. Until it\n # exists the run FAILS and says so. It does not fall back to a different\n # provider on CodeBoarding's hosted tier.\n llm: openai\n openai_api_key: ${{ secrets.OPENAI_API_KEY }}\n", + "byok:google": " # Your own Google Gemini key: every run calls Google Gemini directly with it.\n # Add GOOGLE_API_KEY under Settings → Secrets and variables → Actions. Until it\n # exists the run FAILS and says so. It does not fall back to a different\n # provider on CodeBoarding's hosted tier.\n llm: google\n google_api_key: ${{ secrets.GOOGLE_API_KEY }}\n", + "byok:vercel": " # Your own Vercel AI Gateway key: every run calls Vercel AI Gateway directly with it.\n # Add VERCEL_API_KEY under Settings → Secrets and variables → Actions. Until it\n # exists the run FAILS and says so. It does not fall back to a different\n # provider on CodeBoarding's hosted tier.\n llm: vercel\n vercel_api_key: ${{ secrets.VERCEL_API_KEY }}\n", + "byok:aws_bedrock": " # Your own AWS Bedrock key: every run calls AWS Bedrock directly with it.\n # Add AWS_BEARER_TOKEN_BEDROCK under Settings → Secrets and variables → Actions. Until it\n # exists the run FAILS and says so. It does not fall back to a different\n # provider on CodeBoarding's hosted tier.\n llm: aws_bedrock\n aws_bedrock_api_key: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }}\n", + "byok:cerebras": " # Your own Cerebras key: every run calls Cerebras directly with it.\n # Add CEREBRAS_API_KEY under Settings → Secrets and variables → Actions. Until it\n # exists the run FAILS and says so. It does not fall back to a different\n # provider on CodeBoarding's hosted tier.\n llm: cerebras\n cerebras_api_key: ${{ secrets.CEREBRAS_API_KEY }}\n", + "byok:deepseek": " # Your own DeepSeek key: every run calls DeepSeek directly with it.\n # Add DEEPSEEK_API_KEY under Settings → Secrets and variables → Actions. Until it\n # exists the run FAILS and says so. It does not fall back to a different\n # provider on CodeBoarding's hosted tier.\n llm: deepseek\n deepseek_api_key: ${{ secrets.DEEPSEEK_API_KEY }}\n", + "byok:glm": " # Your own GLM key: every run calls GLM directly with it.\n # Add GLM_API_KEY under Settings → Secrets and variables → Actions. Until it\n # exists the run FAILS and says so. It does not fall back to a different\n # provider on CodeBoarding's hosted tier.\n llm: glm\n glm_api_key: ${{ secrets.GLM_API_KEY }}\n", + "byok:kimi": " # Your own Kimi key: every run calls Kimi directly with it.\n # Add KIMI_API_KEY under Settings → Secrets and variables → Actions. Until it\n # exists the run FAILS and says so. It does not fall back to a different\n # provider on CodeBoarding's hosted tier.\n llm: kimi\n kimi_api_key: ${{ secrets.KIMI_API_KEY }}\n", + "byok:ollama": " # Your own Ollama endpoint: every run calls it directly.\n # ollama is selected by its endpoint rather than a key, so set\n # ollama_base_url to the address CodeBoarding should reach it on. Until it\n # is set the run FAILS and says so. It does not fall back to a different\n # provider on CodeBoarding's hosted tier.\n llm: ollama\n ollama_base_url: https://your-ollama-host\n", + "byok:litellm": " # Your own LiteLLM endpoint: every run calls it directly.\n # litellm is selected by its endpoint rather than a key, so set\n # litellm_base_url to the address CodeBoarding should reach it on. Until it\n # is set the run FAILS and says so. It does not fall back to a different\n # provider on CodeBoarding's hosted tier.\n llm: litellm\n litellm_base_url: https://your-litellm-host\n" + }, + "oidc": { + "review": { + "hosted": " id-token: write # mint a GitHub OIDC token for CodeBoarding's hosted tier\n", + "byok": "" + }, + "sync": { + "hosted": " id-token: write # identifies this repo to CodeBoarding's hosted tier; a run on your own\n # provider key never mints one, so it is not granted there\n", + "byok": "" + } + }, + "delivery": { + "push": { + "permission": "", + "input": "", + "sync_pr_guard": "" + }, + "pull_request": { + "permission": " pull-requests: write # open/update the rolling baseline PR for a protected target branch\n", + "input": " sync_strategy: pull_request\n", + "sync_pr_guard": " &&\n github.head_ref != 'codeboarding/sync'" + } + } +} diff --git a/templates/codeboarding-sync.yml b/templates/codeboarding-sync.yml new file mode 100644 index 0000000..66f1bab --- /dev/null +++ b/templates/codeboarding-sync.yml @@ -0,0 +1,44 @@ +name: CodeBoarding sync + +on: + push: + branches: ['{{BRANCH}}'] + # Loop guard: don't re-trigger on the files this workflow itself commits. + # List generated files only: user-authored scope configuration must still trigger + # regeneration, while a merged sync PR must not trigger a loop. + paths-ignore: + - '.codeboarding/*.md' + - '.codeboarding/analysis.json' + - '.codeboarding/fingerprint.json' + - '.codeboarding/static_analysis.pkl' + - '.codeboarding/static_analysis.sha' + - '.codeboarding/codeboarding_version.json' + - '.codeboarding/health/health_report.json' + - 'docs/development/architecture.md' + workflow_dispatch: + inputs: + force_full: + description: 'Ignore the committed baseline and rebuild it from scratch (full analysis).' + type: boolean + required: false + default: false + +permissions: + contents: write # commit the generated baseline + docs to the branch +{{EXTRA_PERMISSIONS}} +concurrency: + # Serialize against itself so a push landing mid-run can't make two commits. + group: codeboarding-sync + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: CodeBoarding/CodeBoarding-action@v1 + with: + mode: sync + force_full: ${{ inputs.force_full || false }} +{{DELIVERY_INPUT}} target_branch: '{{BRANCH}}' +{{CREDENTIALS}} \ No newline at end of file diff --git a/templates/codeboarding.yml b/templates/codeboarding.yml new file mode 100644 index 0000000..9b31f6c --- /dev/null +++ b/templates/codeboarding.yml @@ -0,0 +1,36 @@ +name: CodeBoarding review + +on: + pull_request: + types: [opened, reopened, ready_for_review, closed, synchronize] + issue_comment: + types: [created] + +# No workflow-level permissions: each job requests only what it needs (least +# privilege), so the default token starts with none. +permissions: {} + +concurrency: + group: codeboarding-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: ${{ github.event_name == 'pull_request' && github.event.action == 'closed' }} + +jobs: + review: + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read # check out the repo + read the committed baseline (no writes in review mode) + pull-requests: write # post the architecture-diff PR comment + issues: write # the /codeboarding issue_comment trigger + comment API +{{OIDC_PERMISSION}} actions: read # let a repeat review download the analysis an earlier run published, instead of re-deriving the whole PR + if: > + (github.event_name == 'pull_request' && github.event.action != 'closed' && + github.event.pull_request.draft == false && + github.event.pull_request.head.repo.full_name == github.repository{{SYNC_PR_GUARD}}) || + (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)) + steps: + - uses: CodeBoarding/CodeBoarding-action@v1 + with: +{{CREDENTIALS}} \ No newline at end of file diff --git a/templates/fills/credentials.byok-endpoint.yml b/templates/fills/credentials.byok-endpoint.yml new file mode 100644 index 0000000..45b293c --- /dev/null +++ b/templates/fills/credentials.byok-endpoint.yml @@ -0,0 +1,7 @@ + # Your own {{LABEL}} endpoint: every run calls it directly. + # {{LLM}} is selected by its endpoint rather than a key, so set + # {{SELECTOR}} to the address CodeBoarding should reach it on. Until it + # is set the run FAILS and says so. It does not fall back to a different + # provider on CodeBoarding's hosted tier. + llm: {{LLM}} + {{SELECTOR}}: https://your-{{LLM}}-host diff --git a/templates/fills/credentials.byok.yml b/templates/fills/credentials.byok.yml new file mode 100644 index 0000000..a713db7 --- /dev/null +++ b/templates/fills/credentials.byok.yml @@ -0,0 +1,6 @@ + # Your own {{LABEL}} key: every run calls {{LABEL}} directly with it. + # Add {{SECRET}} under Settings → Secrets and variables → Actions. Until it + # exists the run FAILS and says so. It does not fall back to a different + # provider on CodeBoarding's hosted tier. + llm: {{LLM}} + {{KEY_INPUT}}: ${{ secrets.{{SECRET}} }} diff --git a/templates/fills/credentials.hosted.yml b/templates/fills/credentials.hosted.yml new file mode 100644 index 0000000..47ac94d --- /dev/null +++ b/templates/fills/credentials.hosted.yml @@ -0,0 +1,3 @@ + # CodeBoarding's free hosted tier. No secret to add: the run authenticates + # with the GitHub OIDC token that `id-token: write` above grants. + llm: hosted diff --git a/templates/fills/credentials.license.yml b/templates/fills/credentials.license.yml new file mode 100644 index 0000000..edf6993 --- /dev/null +++ b/templates/fills/credentials.license.yml @@ -0,0 +1,4 @@ + # Your CodeBoarding plan, on CodeBoarding's hosted tier. The run fails with + # a message naming this secret if CODEBOARDING_LICENSE is not set. + llm: license + license_key: ${{ secrets.CODEBOARDING_LICENSE }} diff --git a/templates/fills/delivery.input.pull_request.yml b/templates/fills/delivery.input.pull_request.yml new file mode 100644 index 0000000..ac6093c --- /dev/null +++ b/templates/fills/delivery.input.pull_request.yml @@ -0,0 +1 @@ + sync_strategy: pull_request diff --git a/templates/fills/delivery.input.push.yml b/templates/fills/delivery.input.push.yml new file mode 100644 index 0000000..e69de29 diff --git a/templates/fills/delivery.permission.pull_request.yml b/templates/fills/delivery.permission.pull_request.yml new file mode 100644 index 0000000..9867e9f --- /dev/null +++ b/templates/fills/delivery.permission.pull_request.yml @@ -0,0 +1 @@ + pull-requests: write # open/update the rolling baseline PR for a protected target branch diff --git a/templates/fills/delivery.permission.push.yml b/templates/fills/delivery.permission.push.yml new file mode 100644 index 0000000..e69de29 diff --git a/templates/fills/oidc.review.byok.yml b/templates/fills/oidc.review.byok.yml new file mode 100644 index 0000000..e69de29 diff --git a/templates/fills/oidc.review.hosted.yml b/templates/fills/oidc.review.hosted.yml new file mode 100644 index 0000000..e095130 --- /dev/null +++ b/templates/fills/oidc.review.hosted.yml @@ -0,0 +1 @@ + id-token: write # mint a GitHub OIDC token for CodeBoarding's hosted tier diff --git a/templates/fills/oidc.sync.byok.yml b/templates/fills/oidc.sync.byok.yml new file mode 100644 index 0000000..e69de29 diff --git a/templates/fills/oidc.sync.hosted.yml b/templates/fills/oidc.sync.hosted.yml new file mode 100644 index 0000000..635c483 --- /dev/null +++ b/templates/fills/oidc.sync.hosted.yml @@ -0,0 +1,2 @@ + id-token: write # identifies this repo to CodeBoarding's hosted tier; a run on your own + # provider key never mints one, so it is not granted there diff --git a/templates/fills/sync_pr_guard.pull_request.yml b/templates/fills/sync_pr_guard.pull_request.yml new file mode 100644 index 0000000..2b115b5 --- /dev/null +++ b/templates/fills/sync_pr_guard.pull_request.yml @@ -0,0 +1,2 @@ + && + github.head_ref != 'codeboarding/sync' \ No newline at end of file diff --git a/templates/fills/sync_pr_guard.push.yml b/templates/fills/sync_pr_guard.push.yml new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/generated/byok-push-main.review.yml b/tests/fixtures/generated/byok-push-main.review.yml new file mode 100644 index 0000000..0470f1e --- /dev/null +++ b/tests/fixtures/generated/byok-push-main.review.yml @@ -0,0 +1,42 @@ +name: CodeBoarding review + +on: + pull_request: + types: [opened, reopened, ready_for_review, closed, synchronize] + issue_comment: + types: [created] + +# No workflow-level permissions: each job requests only what it needs (least +# privilege), so the default token starts with none. +permissions: {} + +concurrency: + group: codeboarding-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: ${{ github.event_name == 'pull_request' && github.event.action == 'closed' }} + +jobs: + review: + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read # check out the repo + read the committed baseline (no writes in review mode) + pull-requests: write # post the architecture-diff PR comment + issues: write # the /codeboarding issue_comment trigger + comment API + id-token: write # mint a GitHub OIDC token for the free hosted tier (write is the only level for id-token) + actions: read # let a repeat review download the analysis an earlier run published, instead of re-deriving the whole PR + if: > + (github.event_name == 'pull_request' && github.event.action != 'closed' && + github.event.pull_request.draft == false && + 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)) + steps: + - uses: CodeBoarding/CodeBoarding-action@v1 + with: + # Your own Anthropic key: every run calls Anthropic directly with it. + # Add ANTHROPIC_API_KEY under Settings → Secrets and variables → Actions. Until it + # exists the run FAILS and says so. It does not fall back to a different + # provider on CodeBoarding's hosted tier. + llm: anthropic + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/tests/fixtures/generated/byok-push-main.sync.yml b/tests/fixtures/generated/byok-push-main.sync.yml new file mode 100644 index 0000000..d28299a --- /dev/null +++ b/tests/fixtures/generated/byok-push-main.sync.yml @@ -0,0 +1,51 @@ +name: CodeBoarding sync + +on: + push: + branches: ['main'] + # Loop guard: don't re-trigger on the files this workflow itself commits. + # List generated files only: user-authored scope configuration must still trigger + # regeneration, while a merged sync PR must not trigger a loop. + paths-ignore: + - '.codeboarding/*.md' + - '.codeboarding/analysis.json' + - '.codeboarding/fingerprint.json' + - '.codeboarding/static_analysis.pkl' + - '.codeboarding/static_analysis.sha' + - '.codeboarding/codeboarding_version.json' + - '.codeboarding/health/health_report.json' + - 'docs/development/architecture.md' + workflow_dispatch: + inputs: + force_full: + description: 'Ignore the committed baseline and rebuild it from scratch (full analysis).' + type: boolean + required: false + default: false + +permissions: + contents: write # commit the generated baseline + docs to the branch + id-token: write # identifies this repo to CodeBoarding's hosted tier, used by the free + # tier AND a license, and as the fallback until your own key exists + +concurrency: + # Serialize against itself so a push landing mid-run can't make two commits. + group: codeboarding-sync + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: CodeBoarding/CodeBoarding-action@v1 + with: + mode: sync + force_full: ${{ inputs.force_full || false }} + target_branch: 'main' + # Your own Anthropic key: every run calls Anthropic directly with it. + # Add ANTHROPIC_API_KEY under Settings → Secrets and variables → Actions. Until it + # exists the run FAILS and says so. It does not fall back to a different + # provider on CodeBoarding's hosted tier. + llm: anthropic + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/tests/fixtures/generated/free-pr-main.review.yml b/tests/fixtures/generated/free-pr-main.review.yml new file mode 100644 index 0000000..6867287 --- /dev/null +++ b/tests/fixtures/generated/free-pr-main.review.yml @@ -0,0 +1,40 @@ +name: CodeBoarding review + +on: + pull_request: + types: [opened, reopened, ready_for_review, closed, synchronize] + issue_comment: + types: [created] + +# No workflow-level permissions: each job requests only what it needs (least +# privilege), so the default token starts with none. +permissions: {} + +concurrency: + group: codeboarding-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: ${{ github.event_name == 'pull_request' && github.event.action == 'closed' }} + +jobs: + review: + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read # check out the repo + read the committed baseline (no writes in review mode) + pull-requests: write # post the architecture-diff PR comment + issues: write # the /codeboarding issue_comment trigger + comment API + id-token: write # mint a GitHub OIDC token for the free hosted tier (write is the only level for id-token) + actions: read # let a repeat review download the analysis an earlier run published, instead of re-deriving the whole PR + if: > + (github.event_name == 'pull_request' && github.event.action != 'closed' && + github.event.pull_request.draft == false && + github.event.pull_request.head.repo.full_name == github.repository && + github.head_ref != 'codeboarding/sync') || + (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)) + steps: + - uses: CodeBoarding/CodeBoarding-action@v1 + with: + # CodeBoarding's free hosted tier. No secret to add: the run authenticates + # with the GitHub OIDC token that `id-token: write` above grants. + llm: hosted diff --git a/tests/fixtures/generated/free-pr-main.sync.yml b/tests/fixtures/generated/free-pr-main.sync.yml new file mode 100644 index 0000000..f7e5868 --- /dev/null +++ b/tests/fixtures/generated/free-pr-main.sync.yml @@ -0,0 +1,50 @@ +name: CodeBoarding sync + +on: + push: + branches: ['main'] + # Loop guard: don't re-trigger on the files this workflow itself commits. + # List generated files only: user-authored scope configuration must still trigger + # regeneration, while a merged sync PR must not trigger a loop. + paths-ignore: + - '.codeboarding/*.md' + - '.codeboarding/analysis.json' + - '.codeboarding/fingerprint.json' + - '.codeboarding/static_analysis.pkl' + - '.codeboarding/static_analysis.sha' + - '.codeboarding/codeboarding_version.json' + - '.codeboarding/health/health_report.json' + - 'docs/development/architecture.md' + workflow_dispatch: + inputs: + force_full: + description: 'Ignore the committed baseline and rebuild it from scratch (full analysis).' + type: boolean + required: false + default: false + +permissions: + contents: write # commit the generated baseline + docs to the branch + pull-requests: write # open/update the rolling baseline PR for a protected target branch + id-token: write # identifies this repo to CodeBoarding's hosted tier, used by the free + # tier AND a license, and as the fallback until your own key exists + +concurrency: + # Serialize against itself so a push landing mid-run can't make two commits. + group: codeboarding-sync + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: CodeBoarding/CodeBoarding-action@v1 + with: + mode: sync + force_full: ${{ inputs.force_full || false }} + sync_strategy: pull_request + target_branch: 'main' + # CodeBoarding's free hosted tier. No secret to add: the run authenticates + # with the GitHub OIDC token that `id-token: write` above grants. + llm: hosted diff --git a/tests/fixtures/generated/free-push-main.review.yml b/tests/fixtures/generated/free-push-main.review.yml new file mode 100644 index 0000000..d558857 --- /dev/null +++ b/tests/fixtures/generated/free-push-main.review.yml @@ -0,0 +1,39 @@ +name: CodeBoarding review + +on: + pull_request: + types: [opened, reopened, ready_for_review, closed, synchronize] + issue_comment: + types: [created] + +# No workflow-level permissions: each job requests only what it needs (least +# privilege), so the default token starts with none. +permissions: {} + +concurrency: + group: codeboarding-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: ${{ github.event_name == 'pull_request' && github.event.action == 'closed' }} + +jobs: + review: + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read # check out the repo + read the committed baseline (no writes in review mode) + pull-requests: write # post the architecture-diff PR comment + issues: write # the /codeboarding issue_comment trigger + comment API + id-token: write # mint a GitHub OIDC token for the free hosted tier (write is the only level for id-token) + actions: read # let a repeat review download the analysis an earlier run published, instead of re-deriving the whole PR + if: > + (github.event_name == 'pull_request' && github.event.action != 'closed' && + github.event.pull_request.draft == false && + 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)) + steps: + - uses: CodeBoarding/CodeBoarding-action@v1 + with: + # CodeBoarding's free hosted tier. No secret to add: the run authenticates + # with the GitHub OIDC token that `id-token: write` above grants. + llm: hosted diff --git a/tests/fixtures/generated/free-push-main.sync.yml b/tests/fixtures/generated/free-push-main.sync.yml new file mode 100644 index 0000000..8dc4b7a --- /dev/null +++ b/tests/fixtures/generated/free-push-main.sync.yml @@ -0,0 +1,48 @@ +name: CodeBoarding sync + +on: + push: + branches: ['main'] + # Loop guard: don't re-trigger on the files this workflow itself commits. + # List generated files only: user-authored scope configuration must still trigger + # regeneration, while a merged sync PR must not trigger a loop. + paths-ignore: + - '.codeboarding/*.md' + - '.codeboarding/analysis.json' + - '.codeboarding/fingerprint.json' + - '.codeboarding/static_analysis.pkl' + - '.codeboarding/static_analysis.sha' + - '.codeboarding/codeboarding_version.json' + - '.codeboarding/health/health_report.json' + - 'docs/development/architecture.md' + workflow_dispatch: + inputs: + force_full: + description: 'Ignore the committed baseline and rebuild it from scratch (full analysis).' + type: boolean + required: false + default: false + +permissions: + contents: write # commit the generated baseline + docs to the branch + id-token: write # identifies this repo to CodeBoarding's hosted tier, used by the free + # tier AND a license, and as the fallback until your own key exists + +concurrency: + # Serialize against itself so a push landing mid-run can't make two commits. + group: codeboarding-sync + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: CodeBoarding/CodeBoarding-action@v1 + with: + mode: sync + force_full: ${{ inputs.force_full || false }} + target_branch: 'main' + # CodeBoarding's free hosted tier. No secret to add: the run authenticates + # with the GitHub OIDC token that `id-token: write` above grants. + llm: hosted diff --git a/tests/fixtures/generated/free-push-trunk.review.yml b/tests/fixtures/generated/free-push-trunk.review.yml new file mode 100644 index 0000000..d558857 --- /dev/null +++ b/tests/fixtures/generated/free-push-trunk.review.yml @@ -0,0 +1,39 @@ +name: CodeBoarding review + +on: + pull_request: + types: [opened, reopened, ready_for_review, closed, synchronize] + issue_comment: + types: [created] + +# No workflow-level permissions: each job requests only what it needs (least +# privilege), so the default token starts with none. +permissions: {} + +concurrency: + group: codeboarding-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: ${{ github.event_name == 'pull_request' && github.event.action == 'closed' }} + +jobs: + review: + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read # check out the repo + read the committed baseline (no writes in review mode) + pull-requests: write # post the architecture-diff PR comment + issues: write # the /codeboarding issue_comment trigger + comment API + id-token: write # mint a GitHub OIDC token for the free hosted tier (write is the only level for id-token) + actions: read # let a repeat review download the analysis an earlier run published, instead of re-deriving the whole PR + if: > + (github.event_name == 'pull_request' && github.event.action != 'closed' && + github.event.pull_request.draft == false && + 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)) + steps: + - uses: CodeBoarding/CodeBoarding-action@v1 + with: + # CodeBoarding's free hosted tier. No secret to add: the run authenticates + # with the GitHub OIDC token that `id-token: write` above grants. + llm: hosted diff --git a/tests/fixtures/generated/free-push-trunk.sync.yml b/tests/fixtures/generated/free-push-trunk.sync.yml new file mode 100644 index 0000000..f39012d --- /dev/null +++ b/tests/fixtures/generated/free-push-trunk.sync.yml @@ -0,0 +1,48 @@ +name: CodeBoarding sync + +on: + push: + branches: ['trunk'] + # Loop guard: don't re-trigger on the files this workflow itself commits. + # List generated files only: user-authored scope configuration must still trigger + # regeneration, while a merged sync PR must not trigger a loop. + paths-ignore: + - '.codeboarding/*.md' + - '.codeboarding/analysis.json' + - '.codeboarding/fingerprint.json' + - '.codeboarding/static_analysis.pkl' + - '.codeboarding/static_analysis.sha' + - '.codeboarding/codeboarding_version.json' + - '.codeboarding/health/health_report.json' + - 'docs/development/architecture.md' + workflow_dispatch: + inputs: + force_full: + description: 'Ignore the committed baseline and rebuild it from scratch (full analysis).' + type: boolean + required: false + default: false + +permissions: + contents: write # commit the generated baseline + docs to the branch + id-token: write # identifies this repo to CodeBoarding's hosted tier, used by the free + # tier AND a license, and as the fallback until your own key exists + +concurrency: + # Serialize against itself so a push landing mid-run can't make two commits. + group: codeboarding-sync + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: CodeBoarding/CodeBoarding-action@v1 + with: + mode: sync + force_full: ${{ inputs.force_full || false }} + target_branch: 'trunk' + # CodeBoarding's free hosted tier. No secret to add: the run authenticates + # with the GitHub OIDC token that `id-token: write` above grants. + llm: hosted diff --git a/tests/fixtures/generated/lic-push-main.review.yml b/tests/fixtures/generated/lic-push-main.review.yml new file mode 100644 index 0000000..299674e --- /dev/null +++ b/tests/fixtures/generated/lic-push-main.review.yml @@ -0,0 +1,40 @@ +name: CodeBoarding review + +on: + pull_request: + types: [opened, reopened, ready_for_review, closed, synchronize] + issue_comment: + types: [created] + +# No workflow-level permissions: each job requests only what it needs (least +# privilege), so the default token starts with none. +permissions: {} + +concurrency: + group: codeboarding-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: ${{ github.event_name == 'pull_request' && github.event.action == 'closed' }} + +jobs: + review: + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read # check out the repo + read the committed baseline (no writes in review mode) + pull-requests: write # post the architecture-diff PR comment + issues: write # the /codeboarding issue_comment trigger + comment API + id-token: write # mint a GitHub OIDC token for the free hosted tier (write is the only level for id-token) + actions: read # let a repeat review download the analysis an earlier run published, instead of re-deriving the whole PR + if: > + (github.event_name == 'pull_request' && github.event.action != 'closed' && + github.event.pull_request.draft == false && + 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)) + steps: + - uses: CodeBoarding/CodeBoarding-action@v1 + with: + # Your CodeBoarding plan, on CodeBoarding's hosted tier. The run fails with + # a message naming this secret if CODEBOARDING_LICENSE is not set. + llm: license + license_key: ${{ secrets.CODEBOARDING_LICENSE }} diff --git a/tests/fixtures/generated/lic-push-main.sync.yml b/tests/fixtures/generated/lic-push-main.sync.yml new file mode 100644 index 0000000..8d3e7ce --- /dev/null +++ b/tests/fixtures/generated/lic-push-main.sync.yml @@ -0,0 +1,49 @@ +name: CodeBoarding sync + +on: + push: + branches: ['main'] + # Loop guard: don't re-trigger on the files this workflow itself commits. + # List generated files only: user-authored scope configuration must still trigger + # regeneration, while a merged sync PR must not trigger a loop. + paths-ignore: + - '.codeboarding/*.md' + - '.codeboarding/analysis.json' + - '.codeboarding/fingerprint.json' + - '.codeboarding/static_analysis.pkl' + - '.codeboarding/static_analysis.sha' + - '.codeboarding/codeboarding_version.json' + - '.codeboarding/health/health_report.json' + - 'docs/development/architecture.md' + workflow_dispatch: + inputs: + force_full: + description: 'Ignore the committed baseline and rebuild it from scratch (full analysis).' + type: boolean + required: false + default: false + +permissions: + contents: write # commit the generated baseline + docs to the branch + id-token: write # identifies this repo to CodeBoarding's hosted tier, used by the free + # tier AND a license, and as the fallback until your own key exists + +concurrency: + # Serialize against itself so a push landing mid-run can't make two commits. + group: codeboarding-sync + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: CodeBoarding/CodeBoarding-action@v1 + with: + mode: sync + force_full: ${{ inputs.force_full || false }} + target_branch: 'main' + # Your CodeBoarding plan, on CodeBoarding's hosted tier. The run fails with + # a message naming this secret if CODEBOARDING_LICENSE is not set. + llm: license + license_key: ${{ secrets.CODEBOARDING_LICENSE }} diff --git a/tests/test_workflow_templates.py b/tests/test_workflow_templates.py new file mode 100644 index 0000000..10153f1 --- /dev/null +++ b/tests/test_workflow_templates.py @@ -0,0 +1,212 @@ +"""The templates must reproduce, byte for byte, what the webview generates today. + +These fixtures were rendered by the webview at the commit this template set was lifted +from. They are the reason the move can be trusted: a template that merely looks right would +silently re-write every repository's workflow on its next update. + +The round-trip tests matter for the other direction. A template is matched by turning it +into a regular expression, so a hole that renders correctly but captures wrongly would make +every repository read as hand-edited, and every one of them would be told to check what +changed instead of being updated. +""" + +from __future__ import annotations + +import difflib +import importlib.util +import json +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +FIXTURES = ROOT / "tests" / "fixtures" / "generated" +_spec = importlib.util.spec_from_file_location( + "workflow_templates", ROOT / "scripts" / "action" / "workflow_templates.py" +) +wt = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(wt) + +# name -> (branch, tier, delivery), matching the fixture files committed beside this test. +CASES = { + "free-push-main": ("main", "hosted", "push"), + "free-push-trunk": ("trunk", "hosted", "push"), + "free-pr-main": ("main", "hosted", "pull_request"), + "lic-push-main": ("main", "license", "push"), + "byok-push-main": ("main", "byok:anthropic", "push"), +} + + +class TemplateRenderTests(unittest.TestCase): + def test_the_only_change_from_what_the_webview_ships_is_the_oidc_permission(self) -> None: + """The fixtures are the webview's own output, and the templates reproduce them + except for one reviewed change: `id-token: write` is no longer granted to a + workflow running on the user's own provider key, because such a run never mints a + token. Every other line must be identical. A template that merely looked right + would rewrite every repository's workflow on its next update, so the assertion is + deliberately "nothing else moved" rather than "close enough". + """ + for name, (branch, tier, delivery) in CASES.items(): + for kind in ("review", "sync"): + with self.subTest(case=name, kind=kind): + shipped = (FIXTURES / f"{name}.{kind}.yml").read_text(encoding="utf-8") + rendered = wt.render(kind, branch=branch, tier=tier, delivery=delivery) + changed = [ + line + for line in difflib.unified_diff(shipped.splitlines(), rendered.splitlines(), lineterm="") + if line[:1] in "+-" and not line.startswith(("---", "+++")) + ] + stray = [ + c + for c in changed + if "id-token" not in c and "provider key never" not in c and "tier AND a license" not in c + ] + self.assertEqual(stray, [], "the template changed something it should not have") + + def test_only_the_hosted_tiers_can_identify_the_repository(self) -> None: + """Least privilege, decided by the credentials. A direct provider call never asks + GitHub for a token, so a moving third-party action has no reason to be able to.""" + for tier, granted in (("hosted", True), ("license", True), ("byok:anthropic", False)): + for kind in ("review", "sync"): + with self.subTest(tier=tier, kind=kind): + rendered = wt.render(kind, branch="main", tier=tier, delivery="push") + self.assertEqual("id-token: write" in rendered, granted) + + def test_a_branch_name_with_an_apostrophe_stays_valid_yaml(self) -> None: + """`release/o'neil` is a valid ref, and raw interpolation produced a scalar GitHub + could not parse.""" + rendered = wt.render("sync", branch="release/o'neil", tier="hosted", delivery="push") + # Doubling is how a single-quoted YAML scalar escapes an apostrophe. Raw + # interpolation produced `['release/o'neil']`, which GitHub refuses to parse. + self.assertIn("branches: ['release/o''neil']", rendered) + self.assertIn("target_branch: 'release/o''neil'", rendered) + self.assertNotIn("['release/o'neil']", rendered) + + +class TemplateMatchTests(unittest.TestCase): + def test_every_configuration_round_trips(self) -> None: + """Render it, then recognise it, and get the configuration back.""" + for name, (branch, tier, delivery) in CASES.items(): + for kind in ("review", "sync"): + with self.subTest(case=name, kind=kind): + found = wt.match(kind, wt.render(kind, branch=branch, tier=tier, delivery=delivery)) + self.assertIsNotNone(found, "a file we generated must be recognised") + self.assertEqual(found["tier"], tier) + self.assertEqual(found["delivery"], delivery) + if kind == "sync": + self.assertEqual(found["branch"], branch) + + def test_every_provider_round_trips(self) -> None: + """The byok fill is expanded from the provider table, so all of them must work.""" + table = json.loads((ROOT / "scripts" / "action" / "supported-providers.json").read_text()) + for provider in table["providers"]: + with self.subTest(provider=provider): + rendered = wt.render("review", branch="main", tier=f"byok:{provider}", delivery="push") + self.assertEqual(wt.match("review", rendered)["tier"], f"byok:{provider}") + + def test_an_edited_workflow_matches_nothing(self) -> None: + """The whole point: any edit is a fact, not a heuristic. One added comment is enough.""" + rendered = wt.render("review", branch="main", tier="hosted", delivery="push") + self.assertIsNone(wt.match("review", rendered + "\n# my own note\n")) + self.assertIsNone(wt.match("review", rendered.replace("timeout-minutes: 60", "timeout-minutes: 90"))) + + def test_a_credential_block_we_never_wrote_is_not_a_match(self) -> None: + """The hole would happily capture anything, so the capture is checked against the + fills rather than trusted.""" + rendered = wt.render("review", branch="main", tier="hosted", delivery="push") + self.assertIsNone(wt.match("review", rendered.replace("llm: hosted", "llm: mystery"))) + + def test_line_endings_do_not_decide_the_answer(self) -> None: + """A workflow committed from Windows is the same workflow.""" + rendered = wt.render("sync", branch="main", tier="hosted", delivery="push") + self.assertIsNotNone(wt.match("sync", rendered.replace("\n", "\r\n"))) + + +class ReadmeTests(unittest.TestCase): + """The README's copy-and-paste blocks are the templates, or they drift. + + They already had: the README's review workflow lacked the `closed` trigger, so anyone + setting up by hand got a workflow that ran an in-flight review to completion after the + pull request was closed. Nobody noticed, because nothing compared them. + """ + + def test_the_copyable_workflows_are_the_templates(self) -> None: + readme = (ROOT / "README.md").read_text(encoding="utf-8") + for anchor, kind in ( + ("Create `.github/workflows/codeboarding.yml`", "review"), + ("Create `.github/workflows/codeboarding-sync.yml`", "sync"), + ): + with self.subTest(kind=kind): + start = readme.index("```yaml", readme.index(anchor)) + len("```yaml\n") + block = readme[start : readme.index("```", start)] + expected = wt.render(kind, branch="main", tier="hosted", delivery="push") + self.assertEqual(block, expected, "regenerate the README from templates/") + + +class BundleTests(unittest.TestCase): + """The published bundle must be the templates, not a copy that drifts from them. + + The webview cannot read these files: its generator is bundled into a browser build. So + the action publishes them as data and the webview vendors that. This is the assertion + that keeps the authored .yml the thing under review, rather than a decorative original + beside the JSON everyone actually uses. + """ + + def setUp(self) -> None: + self.bundle = json.loads((ROOT / "templates" / "bundle.json").read_text(encoding="utf-8")) + + def test_the_committed_bundle_is_current(self) -> None: + self.assertEqual( + self.bundle, + wt.bundle(), + "run `python3 scripts/action/workflow_templates.py` to rebuild templates/bundle.json", + ) + + def test_the_bundle_renders_what_the_templates_render(self) -> None: + """Rendering from the bundle alone, the way a consumer will, reaches the same file. + + Against `render`, not the fixtures, because the fixtures are the webview's older + output and the OIDC change above is a deliberate departure from them. + """ + for name, (branch, tier, delivery) in CASES.items(): + for kind in ("review", "sync"): + with self.subTest(case=name, kind=kind): + holes = { + "BRANCH": wt.yaml_scalar(branch), + "CREDENTIALS": self.bundle["credentials"][tier], + "SYNC_PR_GUARD": self.bundle["delivery"][delivery]["sync_pr_guard"], + "OIDC_PERMISSION": self.bundle["oidc"]["review"][ + "byok" if tier.startswith("byok") else "hosted" + ], + "EXTRA_PERMISSIONS": self.bundle["delivery"][delivery]["permission"] + + self.bundle["oidc"]["sync"]["byok" if tier.startswith("byok") else "hosted"], + "DELIVERY_INPUT": self.bundle["delivery"][delivery]["input"], + } + rendered = wt.HOLE.sub(lambda m: holes[m.group(1)], self.bundle["templates"][kind]) + self.assertEqual(rendered, wt.render(kind, branch=branch, tier=tier, delivery=delivery)) + + +class ChangelogTests(unittest.TestCase): + def setUp(self) -> None: + self.log = json.loads((ROOT / "templates" / "CHANGELOG.json").read_text(encoding="utf-8")) + + def test_every_version_composes_and_reads_as_a_sentence(self) -> None: + for entry in self.log["versions"]: + with self.subTest(version=entry["version"]): + self.assertIn(entry["type"], ("update", "replace")) + self.assertTrue(entry["summary"].endswith(".")) + self.assertGreater(len(entry["summary"]), 30, "this string is shown to users") + + def test_the_current_version_exists_and_is_the_newest(self) -> None: + versions = [e["version"] for e in self.log["versions"]] + self.assertEqual(sorted(versions, reverse=True), versions, "newest first") + self.assertIn(self.log["current"], versions) + self.assertEqual(self.log["current"], max(versions)) + + def test_the_oldest_entry_replaces_rather_than_updates(self) -> None: + """Rendering walks back to the last `replace`. Without one at the bottom, a + repository older than every entry would be described by nothing.""" + self.assertEqual(min(self.log["versions"], key=lambda e: e["version"])["type"], "replace") + + +if __name__ == "__main__": + unittest.main()