Skip to content
Merged
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
5 changes: 1 addition & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ jobs:
with:
plan: "true"
project: "test/fixture/fixture.kapi"
commit: "false"

- name: Verify plan outputs
env:
Expand Down Expand Up @@ -68,7 +67,6 @@ jobs:
command: check
args: "--ship"
project: "test/fixture/fixture.kapi"
commit: "false"

- name: Verify gate contract
env:
Expand Down Expand Up @@ -96,7 +94,6 @@ jobs:
with:
command: check
args: "test/fixture/content/en.json"
commit: "false"

- name: Verify pass
env:
Expand All @@ -122,7 +119,7 @@ jobs:
args: "test/fixture/content/en.json --target-lang qps"

# The produced en_qps.json is gitignored by the fixture, so the action
# must report no-changes rather than trying to commit.
# must report no-changes with nothing for a delivery step.
- name: Verify no-changes path
env:
STATUS: ${{ steps.pseudo.outputs.status }}
Expand Down
84 changes: 52 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ jobs:
- uses: neokapi/setup-kapi@v1

- uses: neokapi/kapi-action@v1

# The action reports; delivery is your step. Any commit action works —
# or plain git. See "Delivering the changes" for the PR-based recipe.
- uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "chore: update translations via kapi"
```

### Outcomes
Expand All @@ -39,11 +45,11 @@ A `kapi up` run ends in one of three states, and the Action treats them differen

| Run state | What it means | What the Action does |
|---|---|---|
| **converged** | Every gated scope cleared its ship gate — the project is up to date | Delivers the produced translations |
| **parked** | Work remains that the loop could not carry to the gate (a failing check, an unreachable gate) | Delivers what it *did* catch up, and annotates the run with the parked locales. This is normal pending work, not a failure |
| **failed / canceled** | The run broke (a provider outage, a server error, a cancel) | `kapi up` exits non-zero, the step fails, **nothing is delivered** |
| **converged** | Every gated scope cleared its ship gate — the project is up to date | Reports the produced translations (`has-changes`, `changed-files`) for your delivery step |
| **parked** | Work remains that the loop could not carry to the gate (a failing check, an unreachable gate) | Reports what it *did* catch up, and annotates the run with the parked locales. This is normal pending work, not a failure |
| **failed / canceled** | The run broke (a provider outage, a server error, a cancel) | `kapi up` exits non-zero, the step fails, **`has-changes` never reports** — a broken run must not hand your delivery step partial work |

Parked is the interesting one: partial progress is real progress, so the default is to deliver it and warn rather than throw it away. To block instead:
Parked is the interesting one: partial progress is real progress, so the default is to report it and warn rather than throw it away. To block instead:

```yaml
- uses: neokapi/kapi-action@v1
Expand All @@ -68,7 +74,7 @@ flowchart LR
TM["1 · reuse<br/>TM 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/>PR with translations"]
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/>recorded in .kapi-state.json"]
RV -.->|next run sees it| U
Expand All @@ -78,22 +84,47 @@ flowchart LR

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.

### Deliver as a pull request
### Delivering the changes

By default the Action commits to the current branch. With `create-pull-request: "true"` it delivers a branch + PR instead — the reviewable unit, and the path that works with branch protection on the default branch:
The action never commits, pushes, or opens PRs. It leaves the produced
translations in the working tree and reports them (`has-changes`,
`changed-files`), so delivery is a step you own — which also means the token,
the authorship, and the review policy are yours, stated in your workflow
instead of hidden in ours.

Straight commit to the current branch:

```yaml
permissions:
contents: write
pull-requests: write
- uses: neokapi/kapi-action@v1
id: kapi

steps:
- uses: neokapi/kapi-action@v1
with:
create-pull-request: "true"
- uses: stefanzweifel/git-auto-commit-action@v5
if: steps.kapi.outputs.has-changes == 'true'
with:
commit_message: "chore: update translations via kapi"
```

As a pull request — the reviewable unit, and the shape that respects the
"machine proposes, a person decides" model:

```yaml
- uses: neokapi/kapi-action@v1
id: kapi

- uses: peter-evans/create-pull-request@v7
if: steps.kapi.outputs.has-changes == 'true'
with:
commit-message: "chore: update translations via kapi"
title: "Translations: kapi up"
branch: kapi/up
```

The created PR carries the kapi up report in its description. `pr-title`, `pr-labels`, `pr-base`, and `branch-prefix` tune it; labels are applied best-effort (a label that doesn't exist in the repo never fails the run). The PR URL lands in the `pull-request-url` output.
**One GitHub behavior to know:** anything pushed with the workflow-provided
`GITHUB_TOKEN` triggers **no workflows** — no CI on the commit, no checks on
the created PR, no deploy when it lands on main. That is GitHub loop
prevention, not a kapi limitation. If CI or deploys should react to the
delivered translations, give the delivery step a fine-grained PAT or a GitHub
App token instead (both delivery actions above accept a `token:` input).

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

Expand Down Expand Up @@ -162,7 +193,6 @@ Ordinary builds never fail on target-language drift — a locale that is behind
args: "translate"
project: "kapi.yaml"
paths: "src/locales/"
commit-message: "chore: update translations"
```

This runs `kapi run -p kapi.yaml translate`.
Expand All @@ -189,19 +219,10 @@ Server-connected projects don't need this — the project state lives on the ser
| `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 |
| `fail-on-parked` | `false` | With `command: up`, fail the workflow when the run parks instead of committing partial progress |
| `commit` | `true` | Whether to commit changes |
| `commit-message` | `chore: update translations via kapi` | Commit message |
| `create-pull-request` | `false` | Deliver as a branch + PR instead of pushing the current branch |
| `pr-title` | `chore: update translations via kapi` | Title for the created PR |
| `pr-labels` | `translations,kapi` | Comma-separated labels (best-effort) |
| `pr-base` | | Base branch for the PR (empty = repository default) |
| `branch-prefix` | `kapi/up` | Branch prefix for PR delivery |
| `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 |
| `token` | `${{ github.token }}` | Token for PR creation and comments |
| `git-user-name` | `Kapi Bot` | Git committer name |
| `git-user-email` | `bot@kapi.dev` | Git committer email |
| `paths` | | Space-separated paths to stage for commit (all changes if empty) |
| `token` | `${{ github.token }}` | Token for the sticky PR comment |
| `paths` | | Space-separated paths to scan for changes (whole working tree if empty) |

## Outputs

Expand All @@ -213,13 +234,12 @@ Server-connected projects don't need this — the project state lives on the ser
| `parked-locales` | With `command: up`: comma-separated locales still short of their gate |
| `gate` | With `command: check`: `pass` or `fail` |
| `plan-missing` / `plan-tm-exact` / `plan-ai-remaining` / `plan-token-estimate` | With `plan: true`: the plan totals |
| `committed` | `true` or `false` |
| `commit-sha` | SHA of the created commit (empty if no commit) |
| `pull-request-url` | URL of the created PR (empty if none) |
| `has-changes` | Whether the run left changes in the working tree for your delivery step |
| `changed-files` | Newline-separated paths the run changed |

## Permissions

`permissions: contents: write` for commit/push delivery; add `pull-requests: write` for `create-pull-request` and `pr-comment`.
The action itself needs no write permissions. Your delivery step needs `contents: write` (plus `pull-requests: write` for PR delivery); `pr-comment` needs `pull-requests: write`.

## License

Expand Down
159 changes: 20 additions & 139 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 deliver the results as a commit or pull request"
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"
branding:
icon: "refresh-cw"
color: "blue"
Expand Down Expand Up @@ -28,40 +28,10 @@ inputs:
description: >-
With `command: up`, fail the workflow when the run parks (work remains that
the loop could not carry to the ship gate). Parked is normal pending work,
not a broken run, so the default is to commit what it caught up and
not a broken run, so the default is to report what it caught up and
annotate the rest.
required: false
default: "false"
commit:
description: "Whether to commit translation changes"
required: false
default: "true"
commit-message:
description: "Commit message"
required: false
default: "chore: update translations via kapi"
create-pull-request:
description: >-
Deliver changes as a pull request instead of pushing to the current
branch. Needs `permissions: pull-requests: write` (plus contents: write).
required: false
default: "false"
pr-title:
description: "Title for the created pull request"
required: false
default: "chore: update translations via kapi"
pr-labels:
description: "Comma-separated labels for the created pull request"
required: false
default: "translations,kapi"
pr-base:
description: "Base branch for the created pull request (empty = repository default branch)"
required: false
default: ""
branch-prefix:
description: "Branch name prefix for pull-request delivery"
required: false
default: "kapi/up"
pr-comment:
description: >-
On pull-request events, post one sticky comment with the report (plan,
Expand All @@ -70,19 +40,11 @@ inputs:
required: false
default: "false"
token:
description: "GitHub token for PR creation and comments"
description: "GitHub token for the sticky PR comment"
required: false
default: ${{ github.token }}
git-user-name:
description: "Git committer name"
required: false
default: "Kapi Bot"
git-user-email:
description: "Git committer email"
required: false
default: "bot@kapi.dev"
paths:
description: "Space-separated paths to stage for commit"
description: "Space-separated paths to scan for changes (empty = whole working tree)"
required: false
default: ""

Expand Down Expand Up @@ -114,15 +76,12 @@ outputs:
plan-token-estimate:
description: "With `plan: true`: estimated tokens for the remaining units"
value: ${{ steps.run-kapi.outputs.plan-token-estimate }}
committed:
description: "Whether a commit was created"
value: ${{ steps.set-outputs.outputs.committed }}
commit-sha:
description: "SHA of the created commit (empty if no commit)"
value: ${{ steps.set-outputs.outputs.commit-sha }}
pull-request-url:
description: "URL of the created pull request (empty if none)"
value: ${{ steps.deliver.outputs.pr-url }}
has-changes:
description: "Whether the run left changes in the working tree for your delivery step"
value: ${{ steps.check-changes.outputs.has_changes }}
changed-files:
description: "Newline-separated paths the run changed (git status --porcelain paths)"
value: ${{ steps.check-changes.outputs.changed_files }}

runs:
using: "composite"
Expand Down Expand Up @@ -229,8 +188,9 @@ runs:

printf 'Running:'; printf ' %q' "${cmd[@]}"; echo

# A failed or canceled run exits non-zero: this step fails, and nothing
# below it commits. A broken run must never quietly commit partial work.
# A failed or canceled run exits non-zero: this step fails, and
# has-changes never reports — a broken run must never hand a delivery
# step partial work as if it were a finished one.
if [ -n "$STREAM" ]; then
if ! "${cmd[@]}" | tee "$STREAM"; then
echo "run_status=failed" >> "$GITHUB_OUTPUT"
Expand Down Expand Up @@ -282,8 +242,8 @@ runs:

echo "Run ${OUTCOME} after ${PASSES} pass(es)."
if [ "$OUTCOME" = "parked" ]; then
# Parked is pending work, not a broken run: what it caught up is real
# progress and still gets committed below.
# Parked is pending work, not a broken run: what it caught up is
# real progress and still reaches has-changes/changed-files.
echo "::warning::Translations parked${PARKED:+ for: ${PARKED}} — work remains that the loop could not carry to the ship gate."
if [ "$FAIL_ON_PARKED" = "true" ]; then
echo "::error::fail-on-parked is set and the run parked"
Expand Down Expand Up @@ -324,82 +284,11 @@ runs:
echo "has_changes=false" >> "$GITHUB_OUTPUT"
else
echo "has_changes=true" >> "$GITHUB_OUTPUT"
fi

- name: Commit and deliver changes
id: deliver
if: inputs.plan != 'true' && inputs.commit == 'true' && steps.check-changes.outputs.has_changes == 'true'
shell: bash
env:
GIT_USER_NAME: ${{ inputs.git-user-name }}
GIT_USER_EMAIL: ${{ inputs.git-user-email }}
COMMIT_MESSAGE: ${{ inputs.commit-message }}
PATHS: ${{ inputs.paths }}
CREATE_PR: ${{ inputs.create-pull-request }}
PR_TITLE: ${{ inputs.pr-title }}
PR_LABELS: ${{ inputs.pr-labels }}
PR_BASE: ${{ inputs.pr-base }}
BRANCH_PREFIX: ${{ inputs.branch-prefix }}
GH_TOKEN: ${{ inputs.token }}
RUN_ID: ${{ github.run_id }}
OUTCOME: ${{ steps.run-kapi.outputs.outcome }}
PASSES: ${{ steps.run-kapi.outputs.passes }}
PARKED: ${{ steps.run-kapi.outputs.parked-locales }}
run: |
set -euo pipefail
git config user.name "$GIT_USER_NAME"
git config user.email "$GIT_USER_EMAIL"

if [ -z "$PATHS" ]; then
git add -A
else
# shellcheck disable=SC2086 # deliberate word-splitting over the path list
for p in $PATHS; do
if [ -e "$p" ]; then
git add -A "$p"
fi
done
fi

# Staging can still come up empty (everything the run touched is
# gitignored); committing nothing would fail the step for no reason.
if git diff --cached --quiet; then
echo "Nothing staged after all — skipping commit."
exit 0
fi

if [ "$CREATE_PR" != "true" ]; then
git commit -m "$COMMIT_MESSAGE"
git push
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
exit 0
fi

BRANCH="${BRANCH_PREFIX}-${RUN_ID}"
git checkout -b "$BRANCH"
git commit -m "$COMMIT_MESSAGE"
git push -u origin "$BRANCH"
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"

BODY="<!-- kapi-convergence-report -->
## kapi up report

| | |
|---|---|
| Outcome | **${OUTCOME:-—}** |
| Passes | ${PASSES:-—} |
| Parked locales | ${PARKED:-—} |

Produced by [kapi-action](https://github.com/neokapi/kapi-action). Parked scopes need a person — see the run log for the gate each one is short of."
PR_ARGS=(--title "$PR_TITLE" --body "$BODY" --head "$BRANCH")
[ -n "$PR_BASE" ] && PR_ARGS+=(--base "$PR_BASE")
PR_URL=$(gh pr create "${PR_ARGS[@]}")
echo "pr-url=${PR_URL}" >> "$GITHUB_OUTPUT"
echo "Opened ${PR_URL}"
# Labels are best-effort: gh fails when a label doesn't exist in the
# repo, and a missing label must never fail a delivered run.
if [ -n "$PR_LABELS" ]; then
gh pr edit "$PR_URL" --add-label "$PR_LABELS" || echo "::notice::could not add labels '${PR_LABELS}' (create them in the repo to label kapi PRs)"
{
echo "changed_files<<KAPI_EOF"
awk '{ print substr($0, 4) }' <<< "$DIRT"
echo "KAPI_EOF"
} >> "$GITHUB_OUTPUT"
fi

- name: Sticky PR comment
Expand Down Expand Up @@ -475,18 +364,10 @@ runs:
shell: bash
env:
HAS_CHANGES: ${{ steps.check-changes.outputs.has_changes }}
COMMIT_SHA: ${{ steps.deliver.outputs.sha }}
run: |
set -euo pipefail
if [ "$HAS_CHANGES" = "true" ]; then
echo "status=success" >> "$GITHUB_OUTPUT"
else
echo "status=no-changes" >> "$GITHUB_OUTPUT"
fi
if [ -n "$COMMIT_SHA" ]; then
echo "committed=true" >> "$GITHUB_OUTPUT"
echo "commit-sha=${COMMIT_SHA}" >> "$GITHUB_OUTPUT"
else
echo "committed=false" >> "$GITHUB_OUTPUT"
echo "commit-sha=" >> "$GITHUB_OUTPUT"
fi
Loading