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
25 changes: 22 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,30 @@ jobs:
- name: Verify plan outputs
env:
MISSING: ${{ steps.plan.outputs.plan-missing }}
MEMORY: ${{ steps.plan.outputs.plan-memory-exact }}
MEMORY_DEPRECATED: ${{ steps.plan.outputs.plan-tm-exact }}
TOKENS: ${{ steps.plan.outputs.plan-token-estimate }}
run: |
echo "plan: missing=${MISSING} tokens=${TOKENS}"
echo "plan: missing=${MISSING} memory=${MEMORY} tokens=${TOKENS}"
test "${MISSING}" -gt 0
test "${TOKENS}" -gt 0
test "${MEMORY}" -ge 0
# plan-tm-exact is the deprecated name of plan-memory-exact.
test "${MEMORY_DEPRECATED}" = "${MEMORY}"

test-plan-outputs:
name: "up --plan outputs for kapi 1.2 and 1.3 (kapi stub)"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

# kapi 1.3 reports the exact content-memory count as memoryExact, and
# kapi 1.2 as tmExact. The harness runs the plan step on each and checks
# the outputs, the job summary and the PR comment.
- name: Run the plan harness
run: |
yq --version
test/plan-outputs.sh

test-check-gate:
name: "check --ship (exit-3 contract)"
Expand Down Expand Up @@ -383,8 +402,8 @@ jobs:
test -f test/fixture/content/en_qps.json
test "${STATUS}" = "no-changes"

# The README sends callers to setup-kapi's cache-tm for caching rather than a
# cache step of their own. The save job runs the action and finds kapi's parse
# The README sends callers to setup-kapi's parse cache rather than a cache
# step of their own. The save job runs the action and finds kapi's parse
# cache where setup-kapi saves it, and nothing at the retired .kapi/cache path;
# the restore job must find that cache in place before the action runs again.
test-parse-cache-save:
Expand Down
27 changes: 14 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Kapi Action

A GitHub Action that runs [kapi](https://github.com/neokapi/neokapi) commands — catch up translations, gate content quality, plan cost — and delivers the results — as a commit, a pull request, or a report on the PR that caused the work.
A GitHub Action that runs [kapi](https://github.com/neokapi/neokapi) commands that bring a project's content and its translations up to date, gate content quality and plan the cost of a change. It reports what changed to a delivery step you own (a commit or a pull request), and can post a report on the pull request that caused the work.

## Prerequisites

Expand Down Expand Up @@ -67,26 +67,26 @@ Parked is the interesting one: partial progress is real progress, so the default

`kapi up` treats the recipe as the desired state — the languages the project targets, and the ship gates that define *shippable* — and reconciles the content toward it. Each pass, for every language behind its gate:

1. **Reuse** — exact translation-memory matches fill first, for free.
2. **Translate** — the configured AI provider fills what remains, with the project's terminology and brand context.
3. **Check** — deterministic checks run over what was produced (placeholder integrity, inline tags, do-not-translate terms, untranslated text). A unit with a failing finding counts as *drafted*, not translated — it cannot clear a gate until fixed.
1. **Reuse**: exact content-memory matches fill first, for free.
2. **Translate**: the configured AI provider fills what remains, with the project's terminology and voice context.
3. **Check**: deterministic checks run over what was produced (placeholder integrity, inline tags, do-not-translate terms, untranslated text). A unit with a failing finding still counts as *translated*, and the finding holds its locale out of shipping until it is fixed.

Passes repeat until every language clears its gate, a pass makes no progress, or the pass cap is reached.

```mermaid
flowchart LR
S[source changes] --> U[kapi up]
subgraph PASS ["each pass, per language behind its gate"]
TM["1 · reuse<br/>TM exact matches"] --> AI["2 · translate<br/>AI + terminology"] --> CK["3 · check<br/>placeholders · terms · tags"]
MEM["1 · reuse<br/>content memory exact matches"] --> AI["2 · translate<br/>AI + terminology"] --> CK["3 · check<br/>placeholders · terms · tags"]
end
U --> PASS
CK -->|every gate met| CV["up to date<br/>changes ready to deliver"]
CK -->|needs a person| PK["parked<br/>the review queue"]
PK --> RV["review & approve<br/>committed under .kapi/state"]
PK --> RV["review & approve<br/>recorded in the project's context"]
RV -.->|next run sees it| U
```

**Parked work is the review queue, not an error.** What the machine couldn't decide waits for a person: review the wording, approve or fix it, and `kapi commit` records the decision under `.kapi/state/`, or the connected server records it. Approvals raise the `reviewed` coverage the ship gate measures, so the next run and the next gate see them. `kapi check --ship` (see [Gate pull requests](#gate-pull-requests-on-content-quality)) is what enforces the bar at release time.
**Parked work is the review queue, not an error.** What the machine couldn't decide waits for a person: review the wording, approve or fix it, and the approval is recorded in the project's context (with `kapi apply` or the Review page of Kapi Desktop), or the connected server records it. Push it with `kapi context push`, and a run with `context-sync` takes it in. Approvals raise the `established` coverage the ship gate measures, so the next run and the next gate see them. `kapi check --ship` (see [Gate pull requests](#gate-pull-requests-on-content-quality)) is what enforces the bar at release time.

The kapi up report (outcome, passes, parked locales) is always written to the job summary. Under the hood the Action runs `kapi up --json`, an NDJSON stream — one convergence event per line, closed by a single `{"type":"result", ...}` record. That record is the contract; the events are the log. It becomes the `outcome`, `passes`, and `parked-locales` outputs.

Expand Down Expand Up @@ -134,7 +134,7 @@ App token instead (both delivery actions above accept a `token:` input).

### Plan mode: the cost of a change, on its PR

`plan: "true"` dry-runs the kapi loop — pending work, TM leverage, and a token estimate, with no writes and no provider calls (so it needs no API keys). With `pr-comment: "true"` on a pull-request event, the plan lands as one sticky comment that re-runs update in place:
`plan: "true"` dry-runs the kapi loop and reports pending work, content-memory leverage and a token estimate, with no writes and no provider calls (so it needs no API keys). With `pr-comment: "true"` on a pull-request event, the plan lands as one sticky comment that re-runs update in place:

```yaml
name: Translation plan
Expand All @@ -157,7 +157,7 @@ jobs:
pr-comment: "true"
```

> "This change leaves **42 unit(s)** of pending translation work: 30 recoverable from TM, 12 for AI (~450 tokens estimated)."
> "This change leaves **42 unit(s)** of pending translation work: 30 recoverable from content memory, 12 for AI (~450 tokens estimated)."

### Gate pull requests on content quality

Expand Down Expand Up @@ -287,9 +287,9 @@ context, settles, and pushes the rules it established.

### Caching

`neokapi/setup-kapi@v1` carries kapi's parse cache (`.kapi/work/cache/docs`) between runs. Its `cache-tm` input is on by default for a project with a `kapi.yaml`, so this Action needs no cache step of its own. Set setup-kapi's `project-dir` when the recipe is not at the repository root.
`neokapi/setup-kapi@v1` carries kapi's parse cache (`.kapi/work/cache/docs`) between runs. Its `parse-cache` input is on by default for a project with a `kapi.yaml`, so this Action needs no cache step of its own. Older setup-kapi releases name the input `cache-tm`, and newer ones still accept that name with a deprecation warning. Set setup-kapi's `project-dir` when the recipe is not at the repository root.

Leave the rest of `.kapi/work/` out of any cache. Its store holds the targets and review state a run produced, and a restored copy changes what `kapi status`, `kapi check --ship` and `kapi up` report.
Leave the rest of `.kapi/work/` out of any cache. Its store holds the targets a run produced, and a restored copy changes what `kapi status`, `kapi check --ship` and `kapi up` report.

## Inputs

Expand All @@ -298,7 +298,7 @@ Leave the rest of `.kapi/work/` out of any cache. Its store holds the targets an
| `command` | `up` | Kapi subcommand to execute |
| `args` | | Additional arguments |
| `project` | | Path to the `kapi.yaml` recipe (`-p` flag) |
| `plan` | `false` | With `command: up`: dry run — pending work, TM leverage, token estimate; no writes, no provider calls |
| `plan` | `false` | With `command: up`: a dry run reporting pending work, content-memory leverage and a token estimate; no writes, no provider calls |
| `fail-on-parked` | `false` | With `command: up`, fail the workflow when the run parks instead of reporting partial progress |
| `pr-comment` | `false` | Sticky report comment on pull-request events, including for a failed gate or a check that did not run |
| `token` | `${{ github.token }}` | Token for the sticky PR comment |
Expand All @@ -316,7 +316,8 @@ Leave the rest of `.kapi/work/` out of any cache. Its store holds the targets an
| `gate` | With `command: check`: `pass` or `fail` (empty when the check did not run or errored) |
| `result` | With `command: check`: `passed` (exit 0), `failed` (exit 3), `did_not_run` (exit 4), or `error` (any other exit code) |
| `did-not-run-cause` | With `command: check`, when `result` is `did_not_run`: `checker_invalid`, `nothing_to_check`, `content_not_checked`, or `unknown` |
| `plan-missing` / `plan-tm-exact` / `plan-ai-remaining` / `plan-token-estimate` | With `plan: true`: the plan totals |
| `plan-missing` / `plan-memory-exact` / `plan-ai-remaining` / `plan-token-estimate` | With `plan: true`: the plan totals; `plan-memory-exact` counts the units recoverable from exact content-memory matches |
| `plan-tm-exact` | Deprecated: use `plan-memory-exact`, which holds the same value |
| `has-changes` | Whether the run left changes in the working tree for your delivery step |
| `changed-files` | Newline-separated paths the run changed |

Expand Down
40 changes: 23 additions & 17 deletions action.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: "Kapi Run"
description: "Run kapi commands — catch up translations, gate content quality, plan cost — and report what changed. Delivery (commit, push, PR) is your workflow's own step"
description: "Run kapi commands that bring a project's content and its translations up to date, gate content quality and plan cost, and report what changed. Delivery (commit, push, PR) is your workflow's own step"
branding:
icon: "refresh-cw"
color: "blue"
Expand All @@ -19,8 +19,9 @@ inputs:
default: ""
plan:
description: >-
With `command: up`, dry-run instead: report pending work, TM leverage,
and a token estimate without writing anything or calling a provider.
With `command: up`, dry-run instead: report pending work, content-memory
leverage, and a token estimate without writing anything or calling a
provider.
Pairs with `pr-comment` to post the cost of a change on its PR.
required: false
default: "false"
Expand Down Expand Up @@ -87,9 +88,12 @@ outputs:
plan-missing:
description: "With `plan: true`: pending units across all locales"
value: ${{ steps.run-kapi.outputs.plan-missing }}
plan-memory-exact:
description: "With `plan: true`: units recoverable from exact content-memory matches"
value: ${{ steps.run-kapi.outputs.plan-memory-exact }}
plan-tm-exact:
description: "With `plan: true`: units recoverable from TM exact hits"
value: ${{ steps.run-kapi.outputs.plan-tm-exact }}
description: "Deprecated: use `plan-memory-exact`, which holds the same value"
value: ${{ steps.run-kapi.outputs.plan-memory-exact }}
plan-ai-remaining:
description: "With `plan: true`: units left for AI translation"
value: ${{ steps.run-kapi.outputs.plan-ai-remaining }}
Expand Down Expand Up @@ -146,28 +150,30 @@ runs:
printf 'Running:'; printf ' %q' "${cmd[@]}"; echo
"${cmd[@]}" > "${RUNNER_TEMP}/kapi-plan.json"
MISSING=$(jq -r '.totals.missingTarget // 0' "${RUNNER_TEMP}/kapi-plan.json")
TM=$(jq -r '.totals.tmExact // 0' "${RUNNER_TEMP}/kapi-plan.json")
# kapi 1.3 names the exact content-memory count memoryExact; kapi 1.2
# wrote the same number as tmExact.
MEMORY=$(jq -r '.totals.memoryExact // .totals.tmExact // 0' "${RUNNER_TEMP}/kapi-plan.json")
AI=$(jq -r '.totals.aiRemaining // 0' "${RUNNER_TEMP}/kapi-plan.json")
TOKENS=$(jq -r '.totals.tokenEstimate // 0' "${RUNNER_TEMP}/kapi-plan.json")
{
echo "mode=plan"
echo "plan-missing=${MISSING}"
echo "plan-tm-exact=${TM}"
echo "plan-memory-exact=${MEMORY}"
echo "plan-ai-remaining=${AI}"
echo "plan-token-estimate=${TOKENS}"
} >> "$GITHUB_OUTPUT"
{
echo "## kapi up — plan"
echo
echo "This change leaves **${MISSING} unit(s)** of pending translation work: ${TM} recoverable from TM, ${AI} for AI (~${TOKENS} tokens estimated)."
echo "This change leaves **${MISSING} unit(s)** of pending translation work: ${MEMORY} recoverable from content memory, ${AI} for AI (~${TOKENS} tokens estimated)."
echo
echo "| Locale | Pending | TM exact | AI remaining | Token estimate |"
echo "| Locale | Pending | Content memory exact | AI remaining | Token estimate |"
echo "|---|---|---|---|---|"
jq -r '.scopes[] | "| \(.locale) | \(.missingTarget) | \(.tmExact) | \(.aiRemaining) | \(.tokenEstimate) |"' "${RUNNER_TEMP}/kapi-plan.json"
jq -r '.scopes[] | "| \(.locale) | \(.missingTarget) | \(.memoryExact // .tmExact // 0) | \(.aiRemaining) | \(.tokenEstimate) |"' "${RUNNER_TEMP}/kapi-plan.json"
echo
echo "_Estimate is source-chars/4; no provider calls were made._"
} >> "$GITHUB_STEP_SUMMARY"
echo "Plan: ${MISSING} unit(s) pending, ${TM} from TM, ${AI} for AI (~${TOKENS} tokens)."
echo "Plan: ${MISSING} unit(s) pending, ${MEMORY} from content memory, ${AI} for AI (~${TOKENS} tokens)."
exit 0
fi

Expand Down Expand Up @@ -197,7 +203,7 @@ runs:
{
echo "## kapi check"
echo
echo "❌ **Gate: unmet** — a brand, terminology, QA, or coverage gate failed. See the findings in the job log."
echo "❌ **Gate: unmet**. A voice, terminology, QA or coverage gate failed. See the findings in the job log."
} >> "$GITHUB_STEP_SUMMARY"
echo "::error::kapi gate unmet (exit 3) — content quality gates failed; read the findings and fix them. This is 'not on-spec yet', not a crash."
exit 3
Expand Down Expand Up @@ -380,7 +386,7 @@ runs:
if [ -z "$PATHS" ]; then
# No paths specified — check the entire working tree. `kapi init`
# gitignores the .kapi/ state directory, so what shows up here is the
# localized content the run produced, including brand-new locale files
# target-language content the run produced, including brand-new locale files
# (which a tracked-files-only diff would miss).
DIRT="$(git status --porcelain)"
else
Expand Down Expand Up @@ -426,7 +432,7 @@ runs:
CAUSE: ${{ steps.run-kapi.outputs.did-not-run-cause }}
DID_NOT_RUN_SUMMARY: ${{ steps.run-kapi.outputs.did-not-run-summary }}
PLAN_MISSING: ${{ steps.run-kapi.outputs.plan-missing }}
PLAN_TM: ${{ steps.run-kapi.outputs.plan-tm-exact }}
PLAN_MEMORY: ${{ steps.run-kapi.outputs.plan-memory-exact }}
PLAN_AI: ${{ steps.run-kapi.outputs.plan-ai-remaining }}
PLAN_TOKENS: ${{ steps.run-kapi.outputs.plan-token-estimate }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
Expand All @@ -435,13 +441,13 @@ runs:
MARKER="<!-- kapi-report -->"

if [ "$MODE" = "plan" ]; then
TABLE=$(jq -r '.scopes[] | "| \(.locale) | \(.missingTarget) | \(.tmExact) | \(.aiRemaining) | \(.tokenEstimate) |"' "${RUNNER_TEMP}/kapi-plan.json" 2>/dev/null || true)
TABLE=$(jq -r '.scopes[] | "| \(.locale) | \(.missingTarget) | \(.memoryExact // .tmExact // 0) | \(.aiRemaining) | \(.tokenEstimate) |"' "${RUNNER_TEMP}/kapi-plan.json" 2>/dev/null || true)
BODY="${MARKER}
## kapi up — plan

This change leaves **${PLAN_MISSING:-0} unit(s)** of pending translation work: ${PLAN_TM:-0} recoverable from TM, ${PLAN_AI:-0} for AI (~${PLAN_TOKENS:-0} tokens estimated).
This change leaves **${PLAN_MISSING:-0} unit(s)** of pending translation work: ${PLAN_MEMORY:-0} recoverable from content memory, ${PLAN_AI:-0} for AI (~${PLAN_TOKENS:-0} tokens estimated).

| Locale | Pending | TM exact | AI remaining | Token estimate |
| Locale | Pending | Content memory exact | AI remaining | Token estimate |
|---|---|---|---|---|
${TABLE}

Expand Down
2 changes: 1 addition & 1 deletion test/check-outcomes.sh
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ comment_case() {
GATE="$(output_value gate "$out")" RESULT="$(output_value result "$out")" \
CAUSE="$(output_value did-not-run-cause "$out")" \
DID_NOT_RUN_SUMMARY="$(output_value did-not-run-summary "$out")" \
PLAN_MISSING="" PLAN_TM="" PLAN_AI="" PLAN_TOKENS="" \
PLAN_MISSING="" PLAN_MEMORY="" PLAN_AI="" PLAN_TOKENS="" \
RUNNER_TEMP="$dir/tmp" \
bash "$work/comment.sh" > "$dir/comment.stdout" 2>&1 || rc=$?

Expand Down
30 changes: 30 additions & 0 deletions test/plan-output/kapi-1.2.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"flow": "default (built-in)",
"flowDrafts": true,
"flowCallsProvider": true,
"scopes": [
{
"locale": "de",
"collection": "app",
"missingTarget": 3,
"tmExact": 1,
"aiRemaining": 2,
"tokenEstimate": 7
},
{
"locale": "fr",
"collection": "app",
"missingTarget": 3,
"tmExact": 2,
"aiRemaining": 1,
"tokenEstimate": 4
}
],
"totals": {
"missingTarget": 6,
"tmExact": 3,
"aiRemaining": 3,
"tokenEstimate": 11
},
"note": "content-memory leverage counts exact-hash hits only; token estimate is source chars / 4 for the remaining units (no tokenizer, no provider calls)."
}
30 changes: 30 additions & 0 deletions test/plan-output/kapi-1.3.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"flow": "default (built-in)",
"flowDrafts": true,
"flowCallsProvider": true,
"scopes": [
{
"locale": "de",
"collection": "app",
"missingTarget": 3,
"memoryExact": 1,
"aiRemaining": 2,
"tokenEstimate": 7
},
{
"locale": "fr",
"collection": "app",
"missingTarget": 3,
"memoryExact": 2,
"aiRemaining": 1,
"tokenEstimate": 4
}
],
"totals": {
"missingTarget": 6,
"memoryExact": 3,
"aiRemaining": 3,
"tokenEstimate": 11
},
"note": "content-memory leverage counts exact-hash hits only; token estimate is source chars / 4 for the remaining units (no tokenizer, no provider calls)."
}
Loading
Loading