Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 36 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand All @@ -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.
Expand Down
266 changes: 266 additions & 0 deletions scripts/action/workflow_templates.py
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
Svilen-Stefanov marked this conversation as resolved.


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
Comment on lines +151 to +153

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve the current version from the live template

When a consumer passes the advertised current version (1 from CHANGELOG.json or bundle.json) to render(..., version=1) or match(..., version=1), this always reads templates/history/v1/..., but that directory does not exist, so both APIs raise FileNotFoundError for the only valid version currently published. Route the current version to the live template or archive v1 so explicit version-based iteration can recognize the current workflow.

Useful? React with 👍 / 👎.



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"]
Comment on lines +197 to +199

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Decode escaped branch names before returning them

When a sync workflow targets a valid ref containing an apostrophe, such as release/o'neil, rendering correctly stores it as release/o''neil, but matching returns that YAML-escaped representation verbatim. The advertised render-to-match round trip therefore changes the branch; a consumer that uses the result to update or re-render the workflow doubles the quotes again and targets a different ref. Unescape the captured single-quoted scalar before exposing it as branch.

Useful? React with 👍 / 👎.


fills = credential_fills(version)
tier = next(
(t for t, body in fills.items() if _same(body, captured.get("CREDENTIALS", ""))),
None,
)
Comment on lines +201 to +205

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate the captured review OIDC permission

When a hosted review workflow has its id-token: write line removed or changed, OIDC_PERMISSION is captured but never compared with the fill required by the resolved tier; conversely, an added OIDC grant on a BYOK review is also accepted. Both edited files therefore still match as generated—the hosted variant then fails credential preflight, while a later template update may overwrite either permission edit—so compare this capture with the tier-specific oidc.review.*.yml fill before returning a match.

Useful? React with 👍 / 👎.

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())
Loading
Loading