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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,13 @@ ahead and how deep it may go. A final step reports whether a map was produced.
checked: own-key runs still call `/run/start` and count the same, with no token ceiling,
since the model bill is yours. A job without `id-token: write` skips the check with a
warning.
- **At the wall the check stays green.** A run over its allowance is not analysed: the
review comment says so in one sentence, for example "CodeBoarding map not drawn: m.koch
has used 5 of 5 free runs this week (resets Monday 00:00 UTC). Pro gives 40 a week; a
Team plan covers everyone in acme-corp.", the job summary repeats it, and the job exits
0. The same happens when a hosted run reaches its weekly token ceiling mid-analysis. The
wall payload is uploaded as the `codeboarding-wall` artifact (`wall.json`), so the web app
shows the same sentence on the pull request. A baseline sync at its ceiling skips silently.
- **CodeBoarding down is never your problem.** If the proxy cannot be reached, the run goes
ahead at up to 3 levels with a warning.
- **Update pinned versions.** Action versions from before this release do not start runs
Expand Down
45 changes: 39 additions & 6 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,7 @@ runs:

- name: Deliver baseline
id: sync_commit
if: steps.guard.outputs.skip != 'true' && steps.preflight.outputs.allowed != 'false' && steps.guard.outputs.mode == 'sync'
if: steps.guard.outputs.skip != 'true' && steps.preflight.outputs.allowed != 'false' && steps.sync_analyze.outputs.walled != 'true' && steps.guard.outputs.mode == 'sync'
shell: bash
env:
ACTION_PATH: ${{ github.action_path }}
Expand Down Expand Up @@ -583,7 +583,7 @@ runs:

- name: Render review diagram
id: review_render
if: steps.guard.outputs.skip != 'true' && steps.preflight.outputs.allowed != 'false' && steps.guard.outputs.mode == 'review'
if: steps.guard.outputs.skip != 'true' && steps.preflight.outputs.allowed != 'false' && steps.review_analyze.outputs.walled != 'true' && steps.guard.outputs.mode == 'review'
shell: bash
env:
ACTION_PATH: ${{ github.action_path }}
Expand All @@ -593,7 +593,7 @@ runs:

- name: Build review artifact
id: review_artifact
if: steps.guard.outputs.skip != 'true' && steps.preflight.outputs.allowed != 'false' && steps.guard.outputs.mode == 'review'
if: steps.guard.outputs.skip != 'true' && steps.preflight.outputs.allowed != 'false' && steps.review_analyze.outputs.walled != 'true' && steps.guard.outputs.mode == 'review'
shell: bash
env:
ANALYSIS_PATH: ${{ steps.review_analyze.outputs.analysis_path }}
Expand All @@ -614,7 +614,7 @@ runs:

- name: Upload review artifact
id: upload_review_artifact_dotcom
if: steps.guard.outputs.skip != 'true' && steps.preflight.outputs.allowed != 'false' && steps.guard.outputs.mode == 'review' && github.server_url == 'https://github.com'
if: steps.guard.outputs.skip != 'true' && steps.preflight.outputs.allowed != 'false' && steps.review_analyze.outputs.walled != 'true' && steps.guard.outputs.mode == 'review' && github.server_url == 'https://github.com'
uses: actions/upload-artifact@v4
with:
name: codeboarding-review-${{ github.run_id }}-${{ github.run_attempt }}
Expand All @@ -628,7 +628,7 @@ runs:

- name: Build review comment
id: review_body
if: steps.guard.outputs.skip != 'true' && steps.preflight.outputs.allowed != 'false' && steps.guard.outputs.mode == 'review'
if: steps.guard.outputs.skip != 'true' && steps.preflight.outputs.allowed != 'false' && steps.review_analyze.outputs.walled != 'true' && steps.guard.outputs.mode == 'review'
shell: bash
env:
DIAGRAM: ${{ steps.review_render.outputs.diagram_md }}
Expand All @@ -645,7 +645,7 @@ runs:

- name: Post review comment
id: review_comment
if: steps.guard.outputs.skip != 'true' && steps.preflight.outputs.allowed != 'false' && steps.guard.outputs.mode == 'review'
if: steps.guard.outputs.skip != 'true' && steps.preflight.outputs.allowed != 'false' && steps.review_analyze.outputs.walled != 'true' && steps.guard.outputs.mode == 'review'
uses: marocchino/sticky-pull-request-comment@v2
with:
header: ${{ steps.guard.outputs.comment_id }}
Expand All @@ -666,6 +666,39 @@ runs:
BODY: ${{ steps.review_body.outputs.path }}
run: cat "$BODY" >> "$GITHUB_STEP_SUMMARY"

# The plan stopped this run, at the preflight or with a 402 mid-run: say so in the same
# sticky comment and the job summary, in the plan's own words, and end green. A failed
# check would punish the author for the plan. The artifact carries the wall payload so
# the web app can show the same sentence on the pull request's page.
- name: Explain the plan's wall
id: wall
if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && (steps.preflight.outputs.allowed == 'false' || steps.review_analyze.outputs.walled == 'true')
continue-on-error: true
shell: bash
env:
PR_NUMBER: ${{ steps.guard.outputs.pr_number }}
run: python3 "$GITHUB_ACTION_PATH/scripts/action/run_meter.py" wall

- name: Post the plan's wall
if: steps.wall.outputs.path != '' && steps.guard.outputs.pr_number != ''
continue-on-error: true
uses: marocchino/sticky-pull-request-comment@v2
with:
header: ${{ steps.guard.outputs.comment_id }}
number: ${{ steps.guard.outputs.pr_number }}
GITHUB_TOKEN: ${{ inputs.github_token }}
path: ${{ steps.wall.outputs.path }}

- name: Upload the plan's wall
if: steps.wall.outputs.path != '' && github.server_url == 'https://github.com'
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: codeboarding-wall
path: ${{ runner.temp }}/codeboarding-wall/wall.json
if-no-files-found: ignore
retention-days: 14

# Skipped when the run stopped on a credential problem: that path already replaced
# this same sticky comment with the input and secret to fix, and "see the workflow
# logs" posted over the top of it would send the reader hunting for what they had
Expand Down
2 changes: 2 additions & 0 deletions scripts/action/configure-auth.sh
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ if [ -s "$AUTH_DIR/license.txt" ]; then
fi
# Written by the preflight when the proxy gave this run an id; read per request.
RELAY_ARGS+=(--run-id-file "$AUTH_DIR/run-id")
# Outside the auth directory, which goes with the analysis step: the wall outlives it.
RELAY_ARGS+=(--wall-file "$RUNNER_TEMP/codeboarding-wall/wall.json")

python3 "$ACTION_PATH/scripts/oidc_relay.py" "${RELAY_ARGS[@]}" > "$LOG" 2>&1 &
echo $! > "$PID"
Expand Down
74 changes: 70 additions & 4 deletions scripts/action/run_meter.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
this step's outputs, and its run id is written where the relay packs it into every hosted
call.

``wall`` turns a refusal, from the preflight or from a 402 mid-run, into the neutral pull
request comment and job summary. The run exits 0: a plan is never a red check.

``finish`` runs last, on every outcome, and reports ``POST /run/finish``: ``produced``
charges the run, anything else releases its hold. Success is the map, not the exit code.

Expand All @@ -21,6 +24,7 @@
import json
import os
import re
import shutil
import sys
from pathlib import Path
from urllib.request import Request, urlopen
Expand Down Expand Up @@ -85,6 +89,8 @@ def start(environ: dict[str, str]) -> tuple[dict[str, str], int]:
auth_dir = Path(environ["RUNNER_TEMP"]) / "codeboarding-auth"
for stale in (Path(environ["RUNNER_TEMP"]) / "codeboarding-map", auth_dir / "run-id"):
stale.unlink(missing_ok=True)
wall_file = Path(environ["RUNNER_TEMP"]) / "codeboarding-wall" / "wall.json"
shutil.rmtree(wall_file.parent, ignore_errors=True)
outputs = {
"allowed": "true",
"depth_cap": str(depth),
Expand Down Expand Up @@ -124,14 +130,14 @@ def start(environ: dict[str, str]) -> tuple[dict[str, str], int]:
return outputs, 0

run_id = answer.get("run_id") or ""
wall = answer.get("wall") or {}
refusal = answer.get("wall") or {}
outputs.update(
{
"allowed": str(allowed).lower(),
"depth_cap": str(min(depth, cap)),
"run_id": run_id,
"full_analysis": str(answer.get("full_analysis") is True).lower(),
"wall_message": " ".join(str(wall.get("message", "")).split()),
"wall_message": " ".join(str(refusal.get("message", "")).split()),
"mode": str(answer.get("mode") or ""),
}
)
Expand All @@ -145,9 +151,48 @@ def start(environ: dict[str, str]) -> tuple[dict[str, str], int]:
print(f"::notice title=CodeBoarding depth::{answer['depth_reason']}")
if not allowed:
print(f"::notice title=CodeBoarding::{outputs['wall_message'] or 'This run is over the plan allowance.'}")
if refusal:
wall_file.parent.mkdir(parents=True)
wall_file.write_text(json.dumps(refusal), encoding="utf-8")
return outputs, 0


def wall(environ: dict[str, str]) -> dict[str, str]:
"""The neutral comment for a run the plan stopped, written once for the PR and the summary.

wall.json is the wall payload exactly as the proxy sent it; the step after this uploads
it as the `codeboarding-wall` artifact, which is how the web app shows the same sentence
on the pull request's page.
"""
runner_temp = Path(environ["RUNNER_TEMP"])
try:
payload = json.loads((runner_temp / "codeboarding-wall" / "wall.json").read_text(encoding="utf-8"))
except (OSError, ValueError):
payload = {}
message = payload.get("message") or "CodeBoarding map not drawn: this run is over the plan's allowance."
links = [
f"[{label}]({payload[key]})"
for key, label in (("plans_url", "Plans"), ("upgrade_url", "Your plan"))
if key in payload
]
run_url = f"{environ['GITHUB_SERVER_URL']}/{environ['GITHUB_REPOSITORY']}/actions/runs/{environ['GITHUB_RUN_ID']}"
platform_url = f"https://app.codeboarding.org/{environ['GITHUB_REPOSITORY']}/pull/{environ.get('PR_NUMBER', '')}"
body = "\n\n".join(
[
"### CodeBoarding review · map not drawn",
message,
*([" · ".join(links)] if links else []),
f"<sub>run [{environ['GITHUB_RUN_ID']}]({run_url})</sub>\n"
f"<!-- codeboarding: platform_url={platform_url} wall={payload.get('reason', 'unknown')} -->",
]
)
path = runner_temp / "wall-comment.md"
path.write_text(body + "\n", encoding="utf-8")
with open(environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as summary:
summary.write(body + "\n")
return {"path": str(path)}


def map_written(environ: dict[str, str]) -> bool:
"""Whether the analysis this run set out to write exists.

Expand Down Expand Up @@ -175,9 +220,30 @@ def outcome(environ: dict[str, str]) -> str:
return "failed"


#: The engine's own refusals, which analyze_repository.py records when it stops on one. A 402
#: without a `wall` (a quota the plan did not explain) is still a quota, and says so here.
ENGINE_ERRORS = {"llm_quota_exhausted": "quota_exhausted", "llm_auth": "llm_auth_rejected"}


def failure_reason(environ: dict[str, str]) -> str | None:
"""The plan's wall reason when there is one, else the engine's recorded refusal."""
runner_temp = Path(environ["RUNNER_TEMP"])
try:
return json.loads((runner_temp / "codeboarding-wall" / "wall.json").read_text(encoding="utf-8"))["reason"][:200]
except (OSError, ValueError, KeyError, TypeError):
pass
try:
kind = json.loads((runner_temp / "codeboarding-engine-error.json").read_text(encoding="utf-8"))["kind"]
return ENGINE_ERRORS.get(kind)
except (OSError, ValueError, KeyError, TypeError):
return None


def finish(environ: dict[str, str]) -> int:
"""Always 0: reporting the outcome must never be what fails the job."""
body = {"run_id": environ["RUN_ID"], "outcome": outcome(environ), "error": None}
if body["outcome"] != "produced":
body["error"] = failure_reason(environ)
try:
answer = post(environ, "/run/finish", body)
except Exception as exc: # noqa: BLE001 - an unreported run is released after the stale-hold timeout
Expand All @@ -189,12 +255,12 @@ def finish(environ: dict[str, str]) -> int:

def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("command", choices=["start", "finish"])
parser.add_argument("command", choices=["start", "wall", "finish"])
args = parser.parse_args(argv)
environ = dict(os.environ)
if args.command == "finish":
return finish(environ)
outputs, code = start(environ)
outputs, code = start(environ) if args.command == "start" else (wall(environ), 0)
with open(environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as handle:
handle.writelines(f"{key}={value}\n" for key, value in outputs.items())
return code
Expand Down
15 changes: 14 additions & 1 deletion scripts/action/with-auth.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# Runs one command with the resolved provider credentials, then removes them.
set -euo pipefail
AUTH_DIR="${RUNNER_TEMP}/codeboarding-auth"
# shellcheck disable=SC2329 # run by the EXIT trap, which shellcheck misses once every path exits
cleanup() {
if [ -s "$AUTH_DIR/relay.pid" ]; then
kill "$(cat "$AUTH_DIR/relay.pid")" 2>/dev/null || true
Expand Down Expand Up @@ -41,4 +42,16 @@ if [ -n "${MODEL:-}" ]; then
fi
[ -z "${AGENT_MODEL_INPUT:-}" ] || export AGENT_MODEL="$AGENT_MODEL_INPUT"
[ -z "${PARSING_MODEL_INPUT:-}" ] || export PARSING_MODEL="$PARSING_MODEL_INPUT"
"$@"
if "$@"; then
exit 0
else
status=$?
fi
# The relay kept a 402's wall: the run stopped at the plan, which is not a failure of the
# job. The action ends it neutral instead of red, and a sync skips silently.
if [ -s "$RUNNER_TEMP/codeboarding-wall/wall.json" ]; then
echo "::notice title=CodeBoarding::The map was not drawn: this run reached the plan's limit."
echo "walled=true" >> "${GITHUB_OUTPUT:-/dev/null}"
exit 0
fi
exit "$status"
24 changes: 22 additions & 2 deletions scripts/oidc_relay.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class RelayConfig:
id_token_request_token: str
license_file: Path | None = None
run_id_file: Path | None = None
wall_file: Path | None = None


def _with_audience(url: str) -> str:
Expand Down Expand Up @@ -146,12 +147,28 @@ def _handle(self) -> None:
with urlopen(request, timeout=310) as response: # nosec B310 - configured proxy URL
self._send(response.status, dict(response.headers.items()), response.read())
except HTTPError as response:
self._send(response.code, dict(response.headers.items()), response.read())
body = response.read()
if response.code == 402:
self._keep_wall(body)
self._send(response.code, dict(response.headers.items()), body)
except (RuntimeError, URLError, OSError) as exc:
body = json.dumps({"error": {"message": "CodeBoarding OIDC relay request failed."}}).encode()
self._send(502, {"Content-Type": "application/json"}, body)
print(f"OIDC relay request failed: {exc}", file=sys.stderr)

def _keep_wall(self, body: bytes) -> None:
"""A 402 mid-run (the token ceiling, no live run) is the plan's wall, not a fault. The
engine only sees an error, so the wall is kept for the action to end the run neutral."""
if self.config.wall_file is None:
return
try:
wall = json.loads(body).get("wall")
except (ValueError, AttributeError):
return
if isinstance(wall, dict):
self.config.wall_file.parent.mkdir(parents=True, exist_ok=True)
self.config.wall_file.write_text(json.dumps(wall), encoding="utf-8")

do_GET = _handle
do_POST = _handle
do_PUT = _handle
Expand All @@ -174,6 +191,7 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("--ready-file", required=True, type=Path)
parser.add_argument("--license-file", type=Path)
parser.add_argument("--run-id-file", type=Path)
parser.add_argument("--wall-file", type=Path)
args = parser.parse_args(argv)

request_url = os.environ.get("ACTIONS_ID_TOKEN_REQUEST_URL", "")
Expand All @@ -183,7 +201,9 @@ def main(argv: list[str] | None = None) -> int:
return 2

server = RelayServer(
RelayConfig(args.upstream_base_url, request_url, request_token, args.license_file, args.run_id_file)
RelayConfig(
args.upstream_base_url, request_url, request_token, args.license_file, args.run_id_file, args.wall_file
)
)
args.ready_file.write_text(str(server.server_port), encoding="utf-8")
try:
Expand Down
4 changes: 4 additions & 0 deletions tests/contracts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ JSON Schemas (draft 2020-12) for the wire shapes licensing-aws answers and accep
| `run-start.request.schema.json`, `run-start.response.schema.json` | `POST /run/start` on gha_proxy (OIDC) and license_proxy (extension token) |
| `run-finish.request.schema.json`, `run-finish.response.schema.json` | `POST /run/finish` on both |
| `meter-consume.request.schema.json`, `meter-consume.response.schema.json` | `POST /meter/consume` |
| `billing-checkout.request.schema.json`, `billing-checkout.response.schema.json` | `POST /billing/checkout` |
| `billing-portal.request.schema.json`, `billing-portal.response.schema.json` | `POST /billing/portal` |
| `legacy-link.request.schema.json`, `legacy-link.response.schema.json` | `POST /legacy/link` (always 200; only `linked: true` is a link) |
| `session-extension.request.schema.json` | body of `POST /session/extension` (its answer is `session.schema.json`) |
| `wall.schema.json` | the refusal inside the answers above, and the body of a 402 from either proxy (`{"error": {"message", "type"}, "wall": …}`) |
| `meter.schema.json` | one weekly allowance, used by the others |

Expand Down
30 changes: 30 additions & 0 deletions tests/contracts/billing-checkout.request.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://codeboarding.org/schemas/licensing/v1/billing-checkout.request.schema.json",
"title": "Body of POST /billing/checkout (the webview server, for the signed-in person)",
"type": "object",
"properties": {
"interval": {
"enum": [
"month",
"year"
]
},
"success_url": {
"type": "string",
"format": "uri",
"description": "Where Stripe sends the person after paying; must be on the app origin (APP_BASE_URL, and http://localhost:3000 on the dev stack)"
},
"cancel_url": {
"type": "string",
"format": "uri",
"description": "Where Stripe sends the person when they leave Checkout; the same origin rule"
}
},
"required": [
"interval",
"success_url",
"cancel_url"
],
"additionalProperties": false
}
17 changes: 17 additions & 0 deletions tests/contracts/billing-checkout.response.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://codeboarding.org/schemas/licensing/v1/billing-checkout.response.schema.json",
"title": "Answer of POST /billing/checkout: the Stripe Checkout page to send the person to. Refusals: 400 (bad interval or a URL off the app origin), 409 {reason: subscribed} (already paying for Pro), 502 (Stripe did not answer), 503 (prices not configured)",
"type": "object",
"properties": {
"url": {
"type": "string",
"format": "uri",
"pattern": "^https://"
}
},
"required": [
"url"
],
"additionalProperties": false
}
Loading
Loading