From f2a24be330d621cfc9ca65a2d9d3921b1ae098f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Va=C5=A1ko?= Date: Fri, 7 Aug 2026 09:31:34 +0200 Subject: [PATCH] feat(skill): add kbagent-promotion-pipeline skill for source->dest project promotion Adds a from-scratch generator (plugins/kbagent/skills/kbagent-promotion-pipeline/) for a GitHub Actions pipeline that promotes Keboola configs between two distinct projects (e.g. dev -> prod), for the "one repo covers the whole org" pattern. Mechanic (kbagent-native, no git-branching or GH-Environment-per-branch magic needed): a pull workflow fetches the SOURCE project into a shared directory and opens one PR against main; a validate workflow runs `sync push --dry-run` against the DESTINATION project on that PR, showing exactly what would change there; a push workflow (environment-gated) ships it once the PR merges. Every step uses KBAGENT_PROJECT_FROM_ENV=1 + --project __env__, so no token ever touches disk. A single repo can host several independent pipelines via a JSON config (one entry per source/dest pair). Generator is stdlib-only, mirrors the kbagent-cicd-migration skill's structure; verified by generating both single- and multi-pipeline configs and inline sanity assertions on the rendered YAML. --- .../kbagent-promotion-pipeline/SKILL.md | 157 +++++++ .../references/env-injection.md | 37 ++ .../references/secrets-setup.md | 59 +++ .../scripts/generate_promotion_pipeline.py | 394 ++++++++++++++++++ 4 files changed, 647 insertions(+) create mode 100644 plugins/kbagent/skills/kbagent-promotion-pipeline/SKILL.md create mode 100644 plugins/kbagent/skills/kbagent-promotion-pipeline/references/env-injection.md create mode 100644 plugins/kbagent/skills/kbagent-promotion-pipeline/references/secrets-setup.md create mode 100644 plugins/kbagent/skills/kbagent-promotion-pipeline/scripts/generate_promotion_pipeline.py diff --git a/plugins/kbagent/skills/kbagent-promotion-pipeline/SKILL.md b/plugins/kbagent/skills/kbagent-promotion-pipeline/SKILL.md new file mode 100644 index 00000000..655ad4ab --- /dev/null +++ b/plugins/kbagent/skills/kbagent-promotion-pipeline/SKILL.md @@ -0,0 +1,157 @@ +--- +name: kbagent-promotion-pipeline +description: > + Use when setting up a from-scratch GitHub Actions pipeline that promotes + Keboola configurations from a SOURCE project (e.g. dev) to a DESTINATION + project (e.g. prod) using kbagent sync -- one GitHub repo covering the + whole org, main branch as the reviewable source of truth. Covers: PR-based + promotion (pull from source opens a PR, merging pushes to destination), + cross-project diff before merge, multi-pipeline repos (several independent + source/destination pairs in one repo), and GitHub secrets/environment + setup. Triggers: promote config between projects, dev to prod pipeline, + source project destination project, propagate changes between Keboola + projects, kbagent promotion workflow, cross-project sync GitHub Actions, + set up project promotion CI/CD. +--- + +# kbagent Source -> Destination Promotion Pipeline + +Generates a **from-scratch** GitHub Actions setup (not a migration -- see +[kbagent-cicd-migration](../kbagent-cicd-migration/SKILL.md) for porting an +existing `kbc` repo) that promotes Keboola configuration changes from a named +**source project** to a named **destination project**, with a human-reviewed +PR gate in between. + +## The mechanic + +kbagent's `sync` targets one registered project alias per invocation +(`--project ALIAS`) -- it has no "this git branch is bound to that project" +magic the way some kbc-era setups do. This skill builds the promotion loop +directly out of that primitive, using one shared directory per pipeline and +two Storage API tokens (source, destination): + +1. **Pull** (`kbagent-promote-pull.yml`, manual + optional schedule) runs + `sync pull --project __env__ --directory --force` against the + **source** project's token, for every configured pipeline, then opens (or + updates) **one PR** against `main` with the combined diff via + [`peter-evans/create-pull-request`](https://github.com/peter-evans/create-pull-request). +2. **Validate** (`kbagent-promote-validate.yml`, on the PR) runs + `sync push --dry-run --project __env__ --directory ` against the + **destination** project's token, for every pipeline touched by the PR -- + this is the cross-project diff: *if this PR merges, here is exactly what + changes in the destination project.* Read this before approving. +3. **Push** (`kbagent-promote-push.yml`, on push to `main`) runs + `sync push --project __env__ --directory ` against the + **destination** project's token, gated by the `prod` GitHub Environment + (add required reviewers there for manual approval even though the trigger + is an automatic push-on-merge, not `workflow_dispatch`). + +`main` therefore always represents "the last thing approved and pushed to +every destination project" -- the reviewable source of truth the whole repo +is built around. A promotion is: pull opens a PR -> validate shows the +destination-side diff -> a human approves and merges -> push ships it. + +Every step uses `KBAGENT_PROJECT_FROM_ENV=1` + the reserved `--project __env__` +alias (kbagent's headless/CI auth model) -- no token is ever written to +`config.json` or committed to the repo. See +[references/env-injection.md](references/env-injection.md) if you need the +background on why this exists. + +## One repo, multiple independent pipelines + +A single repo can host several unrelated promotion pipelines (e.g. one per +data source, or one per business unit) -- each is a +`{name, directory, source_stack_url, dest_stack_url}` tuple, all generated +into the same three workflow files as extra per-pipeline steps. Use `--config +pipelines.json` (a JSON list of these tuples) for more than one; the +single-pipeline CLI flags (`--name`/`--directory`/`--source-stack-url`/ +`--dest-stack-url`) are a shortcut for exactly one. + +## How to run this -- ask the customer, don't auto-pilot + +Same discipline as every other skill that touches a customer's live +Keboola projects and their CI/CD: **stop and ask** before you: +- Pick the version pin (Step 2) -- prod vs. scratch lane changes the answer. +- Run `--write` (Step 3) -- show the dry-run inventory first. +- Perform the one-time bootstrap (Step 4) against a real destination + project -- confirm which project is genuinely production before seeding + `main` from it. +- Set up secrets/environments (Step 5) -- these are the customer's + credentials, not yours to generate blindly. + +## Workflow + +### Step 1 -- Gather the pipeline definition(s) +For each pipeline: a name, the directory to sync, and the source + destination +projects' stack URLs (usually the same stack, different project ids -- the +project id itself comes from the token, not a CLI flag). Ask for a config +file up front if there's more than one pipeline; it's much easier to review +as a single JSON list than to re-run the generator repeatedly. + +### Step 2 -- Pick a version pin (decide before generating) +Same guidance as the migration skill: `--version X.Y.Z` (PyPI) pinned for a +prod lane, unpinned only for a scratch/experiment repo. + +### Step 3 -- Generate the workflows (dry-run first) +```bash +# Inspect what would be generated: +python /scripts/generate_promotion_pipeline.py /path/to/repo \ + --name SALESFORCE --directory salesforce \ + --source-stack-url connection.keboola.com \ + --dest-stack-url connection.keboola.com + +# Then, once reviewed, write the files: +python /scripts/generate_promotion_pipeline.py /path/to/repo --write \ + --config pipelines.json --version X.Y.Z --schedule "0 6 * * 1" +``` +Produces `.github/workflows/kbagent-promote-{pull,validate,push}.yml` and +prints the exact `gh secret set` / `gh api` commands for Step 5. + +### Step 4 -- Bootstrap `main` from the destination project (one-time, per pipeline) +`main` should start out representing what's *already live* in the +destination project, not an empty tree -- otherwise the first promotion PR +would show every single config as "new," which is both wrong and a scary +first review. Locally, with the destination project's token: +```bash +export KBAGENT_PROJECT_FROM_ENV=1 KBC_TOKEN= KBC_STORAGE_API_URL= +kbagent sync init --project __env__ --directory +kbagent sync pull --project __env__ --directory +git add && git commit -m "Bootstrap from destination project" && git push +``` +Do this directly on `main`, not through a PR -- there is nothing to review +yet, it's just establishing the starting baseline. + +### Step 5 -- Set up GitHub secrets and the `prod` environment +Two Storage API token secrets per pipeline (`KBC_TOKEN__SOURCE`, +`KBC_TOKEN__DEST`) plus the `prod` GitHub Environment with required +reviewers -- the generator prints the exact `gh` commands. See +[references/secrets-setup.md](references/secrets-setup.md). + +### Step 6 -- Run a promotion end-to-end +1. Trigger `kbagent-promote-pull.yml` (`workflow_dispatch`, or wait for the + schedule) -- it opens/updates the `promote/update` PR against `main`. +2. Read the `kbagent-promote-validate.yml` check's dry-run output on that + PR -- confirm it matches what you expect to land in each destination + project. +3. Merge the PR. `kbagent-promote-push.yml` fires, waits for `prod` + environment approval, then pushes to every pipeline's destination + project. + +## Guardrails (state these to the user) +- **Never** add `--allow-plaintext-on-encrypt-failure` to the push workflow -- + it silently uploads `#`-secrets in cleartext if the Encryption API is down. +- The `prod` environment's required-reviewer gate applies to `push`-triggered + jobs too, not just `workflow_dispatch` -- confirm the reviewers are actually + configured, since a repo without them makes the "gate" a no-op. +- One PR covers every pipeline pulled in that run (`branch: promote/update`). + If pipelines are unrelated and reviewed by different people, consider + splitting them into separate repos or separate pull workflows instead of + forcing one combined review. +- `peter-evans/create-pull-request` is a third-party action -- pin it to a + full commit SHA (not just `@v7`) for a security-sensitive prod pipeline, + and mention this to the customer rather than silently leaving the tag pin. + +## Reference material +- [references/secrets-setup.md](references/secrets-setup.md) -- GitHub secrets/environment setup with `gh` commands. +- [references/env-injection.md](references/env-injection.md) -- why `KBAGENT_PROJECT_FROM_ENV`/`__env__` exists and how it differs from a registered `project add`. +- `scripts/generate_promotion_pipeline.py` -- the generator (stdlib only). diff --git a/plugins/kbagent/skills/kbagent-promotion-pipeline/references/env-injection.md b/plugins/kbagent/skills/kbagent-promotion-pipeline/references/env-injection.md new file mode 100644 index 00000000..1a986ef0 --- /dev/null +++ b/plugins/kbagent/skills/kbagent-promotion-pipeline/references/env-injection.md @@ -0,0 +1,37 @@ +# Why every step uses `KBAGENT_PROJECT_FROM_ENV` / `__env__` + +kbagent's normal mode of operation is a **registered project**: `kbagent +project add --project ALIAS --url URL --token TOKEN` writes the token into +`~/.config/keboola-agent-cli/config.json`, and every later command references +that alias. That's the right model for a developer's own machine, but wrong +for CI: it means a token would have to be written to disk (or the config +file would have to be committed, which is worse -- a secret in git history). + +Since 0.50.0, kbagent supports a headless alternative purpose-built for this: +set `KBAGENT_PROJECT_FROM_ENV=1` together with `KBC_TOKEN` and +`KBC_STORAGE_API_URL`, and kbagent synthesizes an **in-memory** project under +the reserved alias `__env__` for that process only -- no `project add`, no +`config.json` write, nothing to clean up afterward. Every command in this +skill's generated workflows passes `--project __env__` for exactly this +reason. + +## Two tokens, two projects, same alias name + +Because `__env__` is resolved from whatever `KBC_TOKEN` / +`KBC_STORAGE_API_URL` happen to be set in the current step's `env:` block, +the **same alias name** (`__env__`) can point at two completely different +physical Keboola projects across two steps in the same job -- the pull step +sets the source project's token, the validate/push steps set the destination +project's token. There is no conflict because each step's environment is +isolated; kbagent never persists what `__env__` resolved to. + +## What this buys you + +- The token is a GitHub Actions secret, masked in logs, never written to a + file kbagent (or a subsequent step) could accidentally commit. +- No `project add`/`project remove` housekeeping in CI -- the "project" + exists only for the duration of one step. +- The same generated workflow works identically whether the source and + destination happen to be on the same Keboola stack or different ones -- + `KBC_STORAGE_API_URL` is set explicitly per step from the pipeline + definition, not inferred from a registered project's stored URL. diff --git a/plugins/kbagent/skills/kbagent-promotion-pipeline/references/secrets-setup.md b/plugins/kbagent/skills/kbagent-promotion-pipeline/references/secrets-setup.md new file mode 100644 index 00000000..950a876e --- /dev/null +++ b/plugins/kbagent/skills/kbagent-promotion-pipeline/references/secrets-setup.md @@ -0,0 +1,59 @@ +# GitHub secrets / environment setup + +Each pipeline needs **two** Storage API token secrets -- one for the source +project, one for the destination project -- plus one shared `prod` +GitHub Environment used for push approval gating across every pipeline. + +## Per pipeline + +| Secret | Used by | Project | +|---|---|---| +| `KBC_TOKEN__SOURCE` | `kbagent-promote-pull.yml` | Source (e.g. dev) | +| `KBC_TOKEN__DEST` | `kbagent-promote-validate.yml`, `kbagent-promote-push.yml` | Destination (e.g. prod) | + +`` is the pipeline's `name`, uppercased and sanitized to +`[A-Za-z0-9_]` (the generator's `Pipeline.label` property) -- it always +matches what `generate_promotion_pipeline.py` prints in its secrets report, +so copy-paste from there rather than re-deriving it by hand. + +## Setup with `gh` + +```bash +REPO=/ + +# Per pipeline (repeat for each): +gh secret set KBC_TOKEN_SALESFORCE_SOURCE --repo "$REPO" # paste the dev project's token +gh secret set KBC_TOKEN_SALESFORCE_DEST --repo "$REPO" # paste the prod project's token + +# Shared push-approval environment (once per repo): +gh api -X PUT "repos/$REPO/environments/prod" +``` + +Then in the GitHub UI (or via the environments API): +1. Add **required reviewers** to the `prod` environment. This is what + actually makes `kbagent-promote-push.yml` block on approval -- the + `environment: prod` line in the generated workflow is a no-op without + reviewers configured. +2. Optionally restrict the `prod` environment to the `main` branch only. +3. Scope the `*_DEST` secrets to the `prod` environment if your org's policy + requires environment-scoped secrets (recommended for genuinely + production-facing tokens). + +## Why no token in config.json + +kbagent can read a committed `.kbagent/config.json` with registered project +aliases, but that file stores tokens on disk -- unsafe to commit. Every +generated workflow step instead sets `KBAGENT_PROJECT_FROM_ENV=1` + +`KBC_TOKEN` + `KBC_STORAGE_API_URL` for that one step only, so the token +exists solely as a masked GitHub secret in the runner's environment, never +written to a file. + +## Security guardrails + +- Do **not** commit `.kbagent/config.json` with tokens. +- Do **not** pass `--allow-plaintext-on-encrypt-failure` in CI. +- Prefer environment-scoped `*_DEST` secrets and required reviewers for any + pipeline whose destination is a genuinely production project. +- Pin `peter-evans/create-pull-request` to a full commit SHA, not just a + version tag, for a prod-adjacent pipeline (third-party action supply-chain + hygiene). diff --git a/plugins/kbagent/skills/kbagent-promotion-pipeline/scripts/generate_promotion_pipeline.py b/plugins/kbagent/skills/kbagent-promotion-pipeline/scripts/generate_promotion_pipeline.py new file mode 100644 index 00000000..935c0114 --- /dev/null +++ b/plugins/kbagent/skills/kbagent-promotion-pipeline/scripts/generate_promotion_pipeline.py @@ -0,0 +1,394 @@ +#!/usr/bin/env python3 +"""Generate a kbagent-native source-project -> destination-project promotion pipeline. + +This is a from-scratch generator (no existing repo to migrate) for the "one GitHub +repo covers the whole org" pattern: one or more named pipelines, each syncing a +directory between a SOURCE Keboola project (e.g. dev) and a DESTINATION project +(e.g. prod). It emits three GitHub Actions workflows: + + 1. kbagent-promote-pull.yml (workflow_dispatch + optional schedule) + Pulls every pipeline's directory from its SOURCE project and opens/updates + one PR against the main branch with the combined diff. + 2. kbagent-promote-validate.yml (pull_request against main) + For every pipeline, runs `sync push --dry-run` against the DESTINATION + project -- this is the cross-project diff: "if this PR merges, here is + exactly what changes in the destination project." + 3. kbagent-promote-push.yml (push to main, environment-gated) + Pushes every pipeline's directory to its DESTINATION project once the PR + has merged. + +Each pipeline needs two Storage API token secrets (`KBC_TOKEN__SOURCE` / +`KBC_TOKEN__DEST`) and uses kbagent's `KBAGENT_PROJECT_FROM_ENV=1` / +`__env__` env-injection model -- no token is ever committed to the repo. + +Stdlib only. Dry-run by default; pass ``--write`` to write files. + +Usage: + # Single pipeline via flags: + python generate_promotion_pipeline.py --write \\ + --name SALESFORCE --directory salesforce \\ + --source-stack-url https://connection.keboola.com \\ + --dest-stack-url https://connection.keboola.com \\ + --version X.Y.Z + + # Multiple pipelines (whole-org repo) via a JSON config: + python generate_promotion_pipeline.py --write --config pipelines.json --version X.Y.Z + +pipelines.json shape: + [ + {"name": "SALESFORCE", "directory": "salesforce", + "source_stack_url": "https://connection.keboola.com", + "dest_stack_url": "https://connection.keboola.com"}, + {"name": "GA4", "directory": "ga4", + "source_stack_url": "https://connection.keboola.com", + "dest_stack_url": "https://connection.keboola.com"} + ] +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from dataclasses import dataclass +from pathlib import Path + +# --------------------------------------------------------------------------- # +# Pipeline definition +# --------------------------------------------------------------------------- # + + +@dataclass +class Pipeline: + """One source-project -> destination-project promotion pipeline.""" + + name: str + directory: str + source_stack_url: str + dest_stack_url: str + + @property + def label(self) -> str: + return re.sub(r"[^A-Za-z0-9]+", "_", self.name).strip("_").upper() or "PIPELINE" + + @property + def source_token_secret(self) -> str: + return f"KBC_TOKEN_{self.label}_SOURCE" + + @property + def dest_token_secret(self) -> str: + return f"KBC_TOKEN_{self.label}_DEST" + + +def _load_pipelines(args: argparse.Namespace) -> list[Pipeline]: + if args.config: + data = json.loads(Path(args.config).read_text(encoding="utf-8")) + return [ + Pipeline( + name=str(p["name"]), + directory=str(p["directory"]), + source_stack_url=_normalize_url(str(p["source_stack_url"])), + dest_stack_url=_normalize_url(str(p["dest_stack_url"])), + ) + for p in data + ] + missing = [ + flag + for flag, val in ( + ("--name", args.name), + ("--directory", args.directory), + ("--source-stack-url", args.source_stack_url), + ("--dest-stack-url", args.dest_stack_url), + ) + if not val + ] + if missing: + print( + f"error: --config or all of {', '.join(missing)} must be provided", + file=sys.stderr, + ) + sys.exit(2) + return [ + Pipeline( + name=args.name, + directory=args.directory, + source_stack_url=_normalize_url(args.source_stack_url), + dest_stack_url=_normalize_url(args.dest_stack_url), + ) + ] + + +def _normalize_url(host: str) -> str: + host = host.strip() + if host.startswith(("http://", "https://")): + return host + return f"https://{host}" + + +# --------------------------------------------------------------------------- # +# Workflow generation +# --------------------------------------------------------------------------- # + + +def _install_steps(version: str | None, git_ref: str | None) -> str: + if git_ref: + spec = f"git+https://github.com/keboola/cli@{git_ref}" + elif version: + spec = f"keboola-cli=={version}" + else: + spec = "keboola-cli" + return ( + " - name: Install uv\n" + " uses: astral-sh/setup-uv@v5\n" + " - name: Install kbagent\n" + f" run: uv tool install '{spec}'\n" + " - name: Show version\n" + " run: kbagent version\n" + ) + + +def _pipeline_step( + p: Pipeline, + step_name: str, + command: str, + token_secret: str, + stack_url: str, + json_output: bool = False, +) -> str: + prefix = "kbagent --json " if json_output else "kbagent " + return ( + f" - name: {step_name} ({p.name})\n" + " env:\n" + ' KBAGENT_PROJECT_FROM_ENV: "1"\n' + f" KBC_TOKEN: ${{{{ secrets.{token_secret} }}}}\n" + f" KBC_STORAGE_API_URL: {stack_url}\n" + " run: |\n" + f" {prefix}sync {command} --project __env__ --directory '{p.directory}'\n" + ) + + +def gen_pull(pipelines: list[Pipeline], schedule: str | None, main_branch: str) -> str: + on_block = " workflow_dispatch:\n" + if schedule: + on_block += f" schedule:\n - cron: '{schedule}'\n" + steps = "".join( + _pipeline_step(p, "Pull", "pull --force", p.source_token_secret, p.source_stack_url) + for p in pipelines + ) + paths = ", ".join(p.directory for p in pipelines) + return ( + "# Generated by kbagent-promotion-pipeline. Pulls every pipeline's SOURCE\n" + "# project and opens/updates one PR against the main branch.\n" + "name: kbagent promote - pull\n" + "on:\n" + f"{on_block}" + "permissions:\n" + " contents: write\n" + " pull-requests: write\n" + "jobs:\n" + " pull:\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - uses: actions/checkout@v4\n" + f"{_INSTALL_TOKEN}" + f"{steps}" + " - name: Open promotion PR\n" + " uses: peter-evans/create-pull-request@v7\n" + " with:\n" + " branch: promote/update\n" + f" base: {main_branch}\n" + ' commit-message: "kbagent promote: pull latest config from source project(s)"\n' + ' title: "Promote: pull latest config from source project(s)"\n' + " body: |\n" + " Automated pull from the source project(s) for:\n" + f" {paths}\n\n" + " Review the diff, then merge to push it to the destination\n" + " project(s) -- see the validate check on this PR for the exact\n" + " destination-side change preview.\n" + ) + + +def gen_validate(pipelines: list[Pipeline]) -> str: + steps = "".join( + _pipeline_step( + p, + "Destination dry-run", + "push --dry-run", + p.dest_token_secret, + p.dest_stack_url, + json_output=True, + ) + for p in pipelines + ) + paths = "\n".join(f" - '{p.directory}/**'" for p in pipelines) + return ( + "# Generated by kbagent-promotion-pipeline. Cross-project diff: shows\n" + "# exactly what merging this PR would change in each DESTINATION project.\n" + "name: kbagent promote - validate\n" + "on:\n" + " pull_request:\n" + " paths:\n" + f"{paths}\n" + "permissions:\n" + " contents: read\n" + "jobs:\n" + " validate:\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - uses: actions/checkout@v4\n" + f"{_INSTALL_TOKEN}" + f"{steps}" + ) + + +def gen_push(pipelines: list[Pipeline], main_branch: str) -> str: + steps = "".join( + _pipeline_step(p, "Push", "push", p.dest_token_secret, p.dest_stack_url) for p in pipelines + ) + paths = "\n".join(f" - '{p.directory}/**'" for p in pipelines) + return ( + "# Generated by kbagent-promotion-pipeline. Pushes every pipeline's\n" + "# directory to its DESTINATION project once merged to main.\n" + "# Gated by the 'prod' GitHub Environment -- add required reviewers there\n" + "# for manual approval even though the trigger is an automatic push.\n" + "name: kbagent promote - push\n" + "on:\n" + " push:\n" + f" branches: [{main_branch}]\n" + " paths:\n" + f"{paths}\n" + "permissions:\n" + " contents: read\n" + "jobs:\n" + " push:\n" + " environment: prod\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - uses: actions/checkout@v4\n" + f"{_INSTALL_TOKEN}" + " # `sync push` encrypts #-secrets fail-closed by default. Do NOT add\n" + " # --allow-plaintext-on-encrypt-failure in CI.\n" + f"{steps}" + ) + + +_INSTALL_TOKEN = "@@INSTALL@@\n" + + +# --------------------------------------------------------------------------- # +# Secrets checklist +# --------------------------------------------------------------------------- # + + +def secrets_report(pipelines: list[Pipeline], repo_slug: str) -> str: + lines: list[str] = [] + lines.append("Required GitHub secrets (one SOURCE + one DEST token per pipeline):") + for p in pipelines: + lines.append( + f" gh secret set {p.source_token_secret} --repo {repo_slug} # {p.name} source project" + ) + lines.append( + f" gh secret set {p.dest_token_secret} --repo {repo_slug} # {p.name} destination project" + ) + lines.append("") + lines.append("Required GitHub Environment (for push approval gating):") + lines.append(f" gh api -X PUT repos/{repo_slug}/environments/prod") + lines.append(" # Then add required reviewers to 'prod' in the GitHub UI.") + return "\n".join(lines) + + +def _guess_repo_slug(repo: Path) -> str: + config = repo / ".git" / "config" + if config.exists(): + m = re.search( + r"github\.com[:/]([^/\s]+/[^/\s]+?)(?:\.git)?\s*$", + config.read_text(errors="ignore"), + re.MULTILINE, + ) + if m: + return m.group(1) + return "/" + + +# --------------------------------------------------------------------------- # +# Orchestration +# --------------------------------------------------------------------------- # + + +def run(args: argparse.Namespace) -> int: + repo = Path(args.repo_dir).resolve() + if not repo.is_dir(): + print(f"error: {repo} is not a directory", file=sys.stderr) + return 2 + + pipelines = _load_pipelines(args) + + print(f"{len(pipelines)} promotion pipeline(s):") + for p in pipelines: + print(f" - {p.name:<12} directory={p.directory}") + print(f" source: {p.source_stack_url} (secret {p.source_token_secret})") + print(f" dest: {p.dest_stack_url} (secret {p.dest_token_secret})") + + install = _install_steps(args.version, args.git_ref) + files = { + ".github/workflows/kbagent-promote-pull.yml": gen_pull( + pipelines, args.schedule, args.main_branch + ), + ".github/workflows/kbagent-promote-validate.yml": gen_validate(pipelines), + ".github/workflows/kbagent-promote-push.yml": gen_push(pipelines, args.main_branch), + } + files = {k: v.replace(_INSTALL_TOKEN, install) for k, v in files.items()} + + print(f"\nGenerated workflows ({'WRITING' if args.write else 'dry-run, use --write'}):") + for rel, content in files.items(): + target = repo / rel + print(f" - {rel} ({len(content.splitlines())} lines)") + if args.write: + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + + print("\n" + "=" * 70) + print(secrets_report(pipelines, _guess_repo_slug(repo))) + print("=" * 70) + + if not args.write: + print("\nDry-run only. Re-run with --write to create the files above.") + return 0 + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + ap.add_argument("repo_dir", help="Path to the git repo to generate workflows into") + ap.add_argument( + "--write", action="store_true", help="Write the generated workflows (default: dry-run)" + ) + ap.add_argument("--config", help="JSON file with a list of pipeline definitions") + ap.add_argument("--name", help="Pipeline name (single-pipeline mode)") + ap.add_argument("--directory", help="Directory to sync (single-pipeline mode)") + ap.add_argument( + "--source-stack-url", help="Source project's stack URL/host (single-pipeline mode)" + ) + ap.add_argument( + "--dest-stack-url", help="Destination project's stack URL/host (single-pipeline mode)" + ) + grp = ap.add_mutually_exclusive_group() + grp.add_argument("--version", help="Pin kbagent to this PyPI version, e.g. X.Y.Z") + grp.add_argument("--git-ref", help="Pin kbagent to a git tag/ref, e.g. vX.Y.Z") + ap.add_argument( + "--main-branch", + default="main", + help="Branch promotion PRs merge into and that triggers the push workflow (default: main)", + ) + ap.add_argument( + "--schedule", + default=None, + help="Cron for scheduled pulls, e.g. '0 6 * * 1' (default: none)", + ) + return run(ap.parse_args(argv)) + + +if __name__ == "__main__": + raise SystemExit(main())