feat: kbc→kbagent CI/CD migration skill + sync CLI behavior tests - #402
feat: kbc→kbagent CI/CD migration skill + sync CLI behavior tests#402Matovidlo wants to merge 12 commits into
Conversation
|
@claude review |
There was a problem hiding this comment.
Pull request overview
This PR adds a new kbagent CI/CD migration skill (docs + a stdlib-only workflow generator) intended to help customers migrate legacy kbc GitHub Actions pipelines to kbagent sync, and it adds behavioral CLI tests to lock down key sync safety guards (explicit project selection and dry-run propagation).
Changes:
- Added
kbagent-cicd-migrationskill documentation + reference materials underplugins/kbagent/skills/. - Added
scripts/migrate_cicd.pygenerator to detect legacykbcworkflows and emitkbagent-native validate/pull/push workflows. - Added
tests/test_sync_cli_behavior.pyto pin CLI guard behavior for project selection andpush --dry-run.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 14 comments.
Show a summary per file
| File | Description |
|---|---|
tests/test_sync_cli_behavior.py |
Adds CLI behavior regression tests for project selection guards and dry-run propagation. |
plugins/kbagent/skills/kbagent-cicd-migration/SKILL.md |
Migration skill runbook explaining the breaking JSON→YAML conversion and CI/CD cutover steps. |
plugins/kbagent/skills/kbagent-cicd-migration/scripts/migrate_cicd.py |
Analyzer + generator that discovers projects and emits GitHub Actions workflows + secrets checklist. |
plugins/kbagent/skills/kbagent-cicd-migration/references/secrets-setup.md |
Documents GitHub secrets/environments setup for the generated workflows. |
plugins/kbagent/skills/kbagent-cicd-migration/references/migration-runbook.md |
Ordered PR sequence / operational runbook for a safe migration. |
plugins/kbagent/skills/kbagent-cicd-migration/references/command-mapping.md |
Reference mapping between kbc and kbagent commands/flags/env vars. |
plugins/kbagent/skills/kbagent-cicd-migration/references/branching-model.md |
Decision guide for single-branch vs git-branching workflow models. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…ry-run safety Pin the behaviors that make sync safe and that distinguish kbagent's orchestrator model from kbc's cwd-per-folder model: sync pull/diff/push require --project or --all-projects (and the two are mutually exclusive), --branch is per-project, and push --dry-run propagates the dry-run flag without writing. 6 tests via CliRunner with a mocked SyncService. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the kbagent-cicd-migration skill: a guided, evidence-based runbook plus a stdlib-only generator (scripts/migrate_cicd.py) that discovers projects from .keboola/manifest.json, detects the legacy kbc CI it replaces, and emits clean kbagent-native validate/pull/push GitHub workflows using uv tool install + kbagent sync with the KBAGENT_PROJECT_FROM_ENV auth model. References cover the kbc<->kbagent command/flag/env mapping, GitHub secrets/environments setup, the one-time breaking-conversion runbook (verified against a live project: the adopt-existing 136-delete footgun and the orphaned config.json cleanup), and the single-branch vs git-branching decision guide. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ran the recommended migration path end-to-end against project 153 (fresh kbc init pull + kbagent v0.80.0) to verify the skill's own claims: - migrate_cicd.py generated a `push` workflow using a nonexistent `--allow-delete` flag; kbagent's actual flag is `--force`. Fixed the generator and the command-mapping table. - Plain `sync init` (the recommended no-adopt-existing mechanic) cannot run verbatim against a directory straight out of `kbc pull` -- kbc and kbagent share the same manifest path (`.keboola/manifest.json`), so it fails fast with "Manifest already exists" until that one file (not the config.json/meta.json tree) is deleted first. Folded the missing `rm` step into SKILL.md and migration-runbook.md. - The "delete now-empty kbc-only type folders" cleanup step was a no-op: app/processor/_shared still hold description.md + code bodies kbagent never reads, so `find -empty -delete` matches nothing. Replaced with a full subtree removal, confirmed to leave `sync diff`/`sync status` unaffected. - Re-confirmed the `--adopt-existing` phantom-rows bug reproduces exactly as documented (9 added / 1 deleted) on a side-by-side copy of the same tree, validating the plain-init recommendation. - Added a Prerequisites section (repo path, kbagent install, per-project storage host+token, which project first) that the skill previously assumed rather than stated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ponytail pass over the skill: SKILL.md had accumulated three chronological layers of investigation history (2026-06, then two 2026-08 rounds) that each superseded the last without removing it, plus a full duplicate of migration-runbook.md's per-project conversion procedure and its git-history-rewrite warning. Collapsed to a single current-state finding and pointed Step 3b at the runbook instead of re-deriving it -- same information, 383 -> 262 lines. Also clarified the Prerequisites auth bullet: the generated CI workflows always need a static per-project Storage API token (`kbagent auth login` is browser-based and can't run unattended in CI), but the local/interactive Step 3b conversion can use an already browser-authenticated + registered project alias instead of a raw token, skipping the KBAGENT_PROJECT_FROM_ENV env-injection entirely. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
88a84da to
ee02e27
Compare
… layout - Drop the `sync init --adopt-existing ... || true` step from every generated CI run: the manifest is already committed post-conversion and checked out by actions/checkout, so init was both redundant and silently swallowing real failures (wrong token/project mismatch) via `|| true`. - Fix `--json` placement: it's a global option, not a per-subcommand flag (`kbagent --json sync diff ...`, not `sync diff --json`). - Fix wrong pull flag names in command-mapping.md (--no-storage/--no-jobs/ --with-samples, not --skip-storage/--skip-jobs/--with-table-samples). - Add the missing required --project to the sync init mapping row. - Remove the unused --git-branching flag from the generator (it never affected generation; git-branching is a per-project runtime choice made via `sync init --git-branching` + `branch-link`, documented in branching-model.md). - Fix the Step 6 "tiny diff" claim, which contradicted the reality-check's "expect a massive reformatting diff" -- clarify it's checking for drift since the conversion commit, not comparing against the original kbc tree. - De-duplicate the branching-model decision table (was in both migration-runbook.md and branching-model.md). - Explicitly document that per-project directory layout (project-id-named, L0/L1-labeled, or flat single-project) is preserved verbatim -- the generator never renames or reorganizes it, and --all-projects must not be used for exactly this reason.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (6)
plugins/kbagent/skills/kbagent-cicd-migration/scripts/migrate_cicd.py:321
_guess_repo_slugfails to parse repo names containing dots because the regex excludes.in the repo segment, which can produce an incorrect--repo <owner>/<repo>in the generatedghcommands. Capture the full slug and strip an optional.gitsuffix instead.
if config.exists():
m = re.search(r"github\.com[:/]([^/]+/[^/\s.]+)", config.read_text(errors="ignore"))
if m:
return m.group(1)
plugins/kbagent/skills/kbagent-cicd-migration/scripts/migrate_cicd.py:104
discover_projectsaccepts manifests with missingproject.id/project.apiHost, which later yields invalid workflow env (e.g.KBC_STORAGE_API_URL: https://) and misleading secrets output. Treat these fields as required and skip or error on invalid manifests.
proj = data.get("project", {})
projects.append(
Project(
alias=_alias_from_dir(rel),
directory=rel,
plugins/kbagent/skills/kbagent-cicd-migration/SKILL.md:33
- The code reference
constants.py:425is incorrect (it currently points at unrelated OTLP constants), which undermines the “verified against the code” claim. Prefer stable file/symbol references (or just file paths) instead of brittle line numbers.
- `kbagent` writes per config: **`_config.yml`** (YAML, with `name`/`description`/
`parameters` hoisted + a `_configuration_extra` block) + extracted code files
(`constants.py:425`, `sync/config_format.py`).
- The first `kbagent sync pull` therefore **rewrites every configuration** into a
plugins/kbagent/skills/kbagent-cicd-migration/references/command-mapping.md:34
- The file/line references here are stale:
constants.py:163andconfig_store.py:193don’t correspond to the env-injection mechanism. Use stable symbol/file references (e.g.,constants.pyENV_PROJECT_FROM_ENVandConfigStore._inject_env_project) rather than line numbers.
| *(implicit)* | `KBAGENT_PROJECT_FROM_ENV=1` | **Required** opt-in so kbagent synthesizes an ephemeral project from the env in CI (no `config.json` on disk). See `constants.py:163`, `config_store.py:193` |
plugins/kbagent/skills/kbagent-cicd-migration/references/secrets-setup.md:50
- The
.gitignorereference is incorrect:config_store.py:359is unrelated to gitignore creation. Point to the actual helper (or drop the line number) so readers can verify the claim reliably.
- Do **not** commit `.kbagent/config.json` with tokens (the new CLI auto-writes a
`.gitignore` for its config dir — `config_store.py:359`).
- Do **not** pass `--allow-plaintext-on-encrypt-failure` in CI.
plugins/kbagent/skills/kbagent-cicd-migration/SKILL.md:40
- The parenthetical reference
sync.py:67,495appears to be stale and doesn’t point to the sync guardrails described here. Removing the line numbers (or pointing at the correct module) will avoid misleading users as the code moves.
2. **kbagent sync is an ORCHESTRATOR, not cwd-per-folder.** `kbc pull` runs against
whatever directory you `cd` into. `kbagent sync pull` *requires* `--project ALIAS`
(resolved from a central config store) or `--all-projects` (`sync.py:67,495`). In
CI we bridge this with env-injection: `KBAGENT_PROJECT_FROM_ENV=1` synthesizes a
project under the reserved alias `__env__`, and every command passes
…pilot pass 2 - Replace brittle file:line citations (constants.py:425, constants.py:163, config_store.py:193, config_store.py:359) with stable symbol references -- all four had drifted from the code they were pointing at. - discover_projects now skips (with a warning) any manifest missing project.id or project.apiHost instead of silently emitting an invalid workflow (e.g. KBC_STORAGE_API_URL: https://). - _guess_repo_slug now handles repo names containing dots (e.g. "my.repo") by anchoring on end-of-line and stripping an optional .git suffix, instead of excluding "." from the repo-name character class.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Suppressed comments (4)
plugins/kbagent/skills/kbagent-cicd-migration/scripts/migrate_cicd.py:149
- The workflow generator pins
uv tool installto the legacy distribution namekeboola-agent-cli. In this repo, end-user installation and self-update are based on the currentkeboola-clidistribution name, so generated CI can end up installing the wrong package (or fail if the legacy name is not published for the requested version).
elif version:
spec = f"keboola-agent-cli=={version}"
else:
# Unpinned: only acceptable for non-production lanes. The skill warns.
spec = "keboola-agent-cli"
plugins/kbagent/skills/kbagent-cicd-migration/references/migration-runbook.md:21
- The version pin guidance references
keboola-agent-cli==X.Y.Z, but the repo’s current distribution name used for installs/updates iskeboola-cli. Aligning this avoids copy/paste installs that target the legacy package name.
- [ ] **Pick a kbagent version** and pin it (`keboola-agent-cli==X.Y.Z` or
`git+...@vX.Y.Z`). Never unpinned on a prod lane.
plugins/kbagent/skills/kbagent-cicd-migration/references/command-mapping.md:10
- The install mapping row uses
keboola-agent-cli==<ver>, but this repo’s documented install/update distribution name iskeboola-cli. Using the legacy name here can break reproducible installs or pin the wrong artifact.
| Download Go binary zip from `keboola/keboola-as-code` GitHub release, unzip to `/usr/local/bin/kbc` | `uv tool install keboola-agent-cli==<ver>` (PyPI) or `uv tool install 'git+https://github.com/keboola/cli@<tag>'` |
plugins/kbagent/skills/kbagent-cicd-migration/SKILL.md:108
- This install example uses
keboola-agent-cli==<ver>, but the repo’s installation docs and current distribution name arekeboola-cli. Using the legacy name here can lead to users pinning/installing the wrong package.
`uv tool install keboola-agent-cli==<ver>` (see Step 2 for version pin) or a
The current PyPI distribution is keboola-cli (pyproject.toml, APP_NAME_CANDIDATES in constants.py) -- keboola-agent-cli is the legacy pre-0.63 fallback name only. The generated CI, the runbook, and the command-mapping table were all pinning `uv tool install` to the legacy name, which risks installing the wrong package or failing outright once/if the legacy distribution stops being published.
…af dir _alias_from_dir took only Path(directory).name, so nested multi-project layouts (env/prod, other/prod) collided on the same KBC_TOKEN_<ALIAS> secret name and generated CI would push two different projects with one token. Sanitize the whole relative path instead.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (5)
plugins/kbagent/skills/kbagent-cicd-migration/SKILL.md:192
- This example command also hardcodes
--version 0.58.0. To keep the runbook evergreen, useX.Y.Z(or reference “current stable version”) instead of a specific historical version.
python <skill_dir>/scripts/migrate_cicd.py /path/to/repo --write \
--version 0.58.0 --main-branch main --schedule "0 * * * *"
**tests/test_sync_cli_behavior.py:83**
* Similarly, `sync push` has both `push` and `push_all` code paths. Adding an assertion that `push_all` wasn’t called makes this guard test fully cover the “must not reach the service” intent.
def test_push_without_project_is_usage_error(self, tmp_path: Path) -> None:
code, mock = _invoke(["sync", "push", "--directory", str(tmp_path)], tmp_path)
assert code == 2
mock.push.assert_not_called()
**plugins/kbagent/skills/kbagent-cicd-migration/SKILL.md:183**
* The migration guide hardcodes version `0.58.0` and states “PyPI, once published”, but this repo is already at 0.80.0. Using a stale example version can mislead users into pinning an unnecessarily old release (or thinking PyPI isn’t available). Prefer a placeholder (X.Y.Z) and optionally mention the current release as an example.
This issue also appears on line 190 of the same file.
- Pinned (recommended for prod lanes):
--version 0.58.0(PyPI, once published)
or--git-ref v0.58.0(git tag, until PyPI exists). Reproducible CI.
**plugins/kbagent/skills/kbagent-cicd-migration/scripts/migrate_cicd.py:35**
* The script docstring also hardcodes `0.58.0` / `v0.58.0` and mentions “no PyPI release yet”, which is likely to go stale. Use placeholders (`X.Y.Z` / `vX.Y.Z`) so copy/paste guidance stays accurate across releases.
python migrate_cicd.py <repo_dir> [--write] \\
[--version 0.58.0 | --git-ref vX.Y.Z] \\
[--main-branch main] [--schedule "0 * * * *"]
Examples:
# Inspect what would change (no writes):
python migrate_cicd.py ../CLI-based-sync-demo
# Generate workflows pinned to a published PyPI version:
python migrate_cicd.py ../CLI-based-sync-demo --write --version 0.58.0
# Pin to a git tag instead (no PyPI release yet):
python migrate_cicd.py ../CLI-based-sync-demo --write --git-ref v0.58.0
**tests/test_sync_cli_behavior.py:78**
* This test asserts the service method `diff` wasn’t called, but `sync diff` also has an `--all-projects` path that calls `diff_all`. Since the docstring says usage errors must not reach the service, it’s worth asserting neither `diff` nor `diff_all` was invoked so a regression can’t slip through by accidentally defaulting to the bulk path.
This issue also appears on line 79 of the same file.
def test_diff_without_project_is_usage_error(self, tmp_path: Path) -> None:
code, mock = _invoke(["sync", "diff", "--directory", str(tmp_path)], tmp_path)
assert code == 2
mock.diff.assert_not_called()
</details>
…ge-error tests - Replace the hardcoded --version 0.58.0 / v0.58.0 examples (and the "PyPI, once published" / "no PyPI yet" framing) across SKILL.md and migrate_cicd.py with an X.Y.Z placeholder -- keboola-cli has been on PyPI since well before this repo reached 0.80.0, so the old examples both pinned a stale version and implied PyPI wasn't available yet. - test_diff_without_project_is_usage_error / test_push_without_project_is_usage_error now also assert diff_all/push_all weren't called, matching the existing pull/pull_all guard test -- closes the gap where a regression routing a bad --project/--all-projects combo into the --all-projects code path could slip through undetected.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (5)
tests/test_sync_cli_behavior.py:142
- This invocation also benefits from an explicit
--config-dirso the CLI cannot resolve a real on-disk config via CWD traversal. (--config-diris a global option, so it needs to be placed before thesyncsubcommand.)
result = runner.invoke(
app,
[
"--json",
"sync",
"push",
"--project",
"prod",
"--dry-run",
"--directory",
str(tmp_path),
],
)
plugins/kbagent/skills/kbagent-cicd-migration/scripts/migrate_cicd.py:221
main_branchis unused ingen_pullas well. Rename it to_main_branch(or remove it and adjust call sites) so the interface doesn’t imply the output depends on this parameter.
def gen_pull(projects: list[Project], main_branch: str, schedule: str | None) -> str:
tests/test_sync_cli_behavior.py:62
- To keep these CLI tests hermetic and avoid accidentally picking up a developer’s real local/global config (via resolve_config_dir walking up from CWD), pass an explicit
--config-dirpointing at the tmp config directory in the invocation. This reduces flakiness and makes the test intent clearer (everything should run against the temp store).
This issue also appears on line 130 of the same file.
def _invoke(args: list[str], tmp_path: Path) -> tuple[int, MagicMock]:
"""Invoke the CLI with a mocked SyncService; return (exit_code, mock)."""
store = _store(tmp_path / "config")
mock_sync = MagicMock()
with (
patch("keboola_agent_cli.cli.ConfigStore") as MockStore,
patch("keboola_agent_cli.cli.ProjectService") as MockProj,
patch("keboola_agent_cli.cli.SyncService") as MockSync,
):
MockStore.return_value = store
MockProj.return_value = ProjectService(config_store=store)
MockSync.return_value = mock_sync
result = runner.invoke(app, args)
return result.exit_code, mock_sync
plugins/kbagent/skills/kbagent-cicd-migration/SKILL.md:40
- The inline code reference
sync.py:67,495is brittle and already out of date as line numbers drift; it makes future verification harder because readers may not find the referenced logic. Prefer a stable file reference (optionally with the symbol/command names) instead of line numbers.
2. **kbagent sync is an ORCHESTRATOR, not cwd-per-folder.** `kbc pull` runs against
whatever directory you `cd` into. `kbagent sync pull` *requires* `--project ALIAS`
(resolved from a central config store) or `--all-projects` (`sync.py:67,495`). In
CI we bridge this with env-injection: `KBAGENT_PROJECT_FROM_ENV=1` synthesizes a
project under the reserved alias `__env__`, and every command passes
plugins/kbagent/skills/kbagent-cicd-migration/scripts/migrate_cicd.py:192
main_branchis unused ingen_validate, which makes the signature misleading and may trigger unused-argument lint in stricter configurations. If it’s intentionally unused, rename it to_main_branchto document that and silence unused-arg checks.
This issue also appears on line 221 of the same file.
def gen_validate(projects: list[Project], main_branch: str) -> str:
- gen_validate/gen_pull no longer take an unused main_branch parameter -- only gen_push's environment-gating expression actually needs it. - Fix another stale line-number reference (sync.py:67,495) in SKILL.md, pointing at the project-selection guard by name instead. - Tests now pass an explicit --config-dir so the CLI invocation can't resolve a real on-disk config by walking up from CWD, keeping the suite hermetic regardless of the host environment it runs on.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (1)
plugins/kbagent/skills/kbagent-cicd-migration/references/command-mapping.md:24
- The command mapping table documents
sync pull/push/diffwithout--project/--all-projects, but the CLI enforces that one of those selectors must be provided (seesrc/keboola_agent_cli/commands/sync.py, which exits with usage error when neither is set). As written, these rows will fail if copy/pasted and also contradict the skill’s own “orchestrator” guardrail narrative.
| `kbc pull -d DIR --force` | `kbagent sync pull --directory DIR --force` | `--force` overrides local-vs-remote conflicts (3-way diff) |
| `kbc push -d DIR` | `kbagent sync push --directory DIR` | Encrypts `#`-secrets fail-closed before write |
| `kbc push -d DIR --force` | `kbagent sync push --directory DIR --force` | Push's `--force` removes remote configs deleted locally (there is no `--allow-delete` flag — same flag name as pull's `--force`, but a different meaning per command) |
| `kbc push --dry-run` / push-dry action | `kbagent sync push --dry-run --directory DIR` | Shows planned changes without writing |
| `kbc diff -d DIR` | `kbagent [--json] sync diff --directory DIR` | `--json` is a **global** option (before `sync`, not after `diff`); gives structured drift for CI gating |
…diff rows Those rows showed kbagent sync pull/push/diff with only --directory DIR, which the CLI rejects (--project ALIAS or --all-projects is required) -- copy-pasting them verbatim would fail with a usage error and contradicted the skill's own "orchestrator, not cwd-per-folder" guardrail.
Depends on #561 (kbagent auth pat-create/pat-revoke, v0.81.0). Updates the migration skill to recommend minting a scoped Personal Access Token (kbagent auth pat-create --project-id <id>) for each project's KBC_TOKEN_<ALIAS> secret instead of pasting a raw Storage token from the Keboola UI -- a PAT is scoped to one project, has a controllable expiry (--ttl-days), and revokes independently of the account (kbagent auth pat-revoke) without touching anything else that account can do. No change to the generated GitHub Actions YAML itself: a kbc_pat_... value is a drop-in for KBC_TOKEN under KBAGENT_PROJECT_FROM_ENV=1 (kbagent detects the prefix and sends it as Authorization: Bearer automatically), so this is purely a change in how the operator obtains the secret's value. The raw Storage token path remains documented as the fallback for stacks/accounts that can't complete auth login + TOTP step-up.
Summary
kbagent-cicd-migrationskill (plugins/kbagent/skills/kbagent-cicd-migration/): a guided, evidence-based runbook plus a stdlib-only generator (scripts/migrate_cicd.py) that migrates akbc(keboola-as-code) project-as-code GitHub CI/CD pipeline to the newkbagent syncengine..keboola/manifest.json, detects the legacykbcworkflows/actions it supersedes, and emits clean kbagent-nativevalidate/pull/pushGitHub workflows usinguv tool install+kbagent syncwith theKBAGENT_PROJECT_FROM_ENV=1auth model (per-project token secrets, GitHub-Environment approval gating, no committed tokens).tests/test_sync_cli_behavior.py): pin the project-selection guards that define kbagent's orchestrator model (--project/--all-projectsrequired and mutually exclusive,--branchis per-project) and thatpush --dry-runpropagates the dry-run flag without writing.Why
Customers run
kbc-based GitHub CI/CD (per-project pull/push PRs, multi-project promotion). The new CLI'ssyncis a manifest-compatible successor but not a drop-in: the on-disk format differs (config.json/meta.json→_config.yml) andsyncis an orchestrator over registered project aliases, not cwd-per-folder. This skill gives a safe, repeatable cutover, and the tests lock the guard behaviors that prevent wrong-target or whole-tree operations.Validated live against a real project: native pull round-trips to zero drift; the
adopt-existing → diff"136 to delete" footgun and the orphaned-config.jsoncleanup are documented in the runbook.Depends on #561 (
kbagent auth pat-create/pat-revoke, v0.81.0): after a follow-up discussion, the skill now recommends minting a scoped Personal Access Token for each project'sKBC_TOKEN_<ALIAS>CI secret instead of pasting a raw Storage API token. No generated-YAML change — akbc_pat_...value is a drop-in for the existingKBAGENT_PROJECT_FROM_ENV=1model, so this is purely a change in how the operator obtains the secret's value (a raw Storage token remains documented as the fallback).Change type
Feature — migration tooling + tests. No source/CLI-command changes, no version bump.
Impact analysis
plugins/kbagent/skills/and one test file undertests/.src/, no new CLI commands, no public API or behavior change.Test plan
tests/test_sync_cli_behavior.py— 6 tests, green.ty(no new diagnostics),command-sync-check(229 commands, none added),skill-check(main SKILL.md unchanged),changelog-check.CLI-based-sync-demorepo (2 projects discovered, 3 legacy actions flagged, 3 valid workflows generated).Deployment
Merge & automatic deploy. No migration.
Rollback plan
Revert of this PR.