diff --git a/_blocks/ci-forgejo-actions.md b/_blocks/ci-forgejo-actions.md new file mode 100644 index 0000000..8b01504 --- /dev/null +++ b/_blocks/ci-forgejo-actions.md @@ -0,0 +1,61 @@ +# CI — Forgejo Actions (self-hosted, Tailscale-only admin) + +Forgejo Actions is GitHub-Actions compatible at the workflow-syntax layer (derived from Gitea Actions, which re-uses the `actions/*` runtime via `act`). A workflow that runs on GH usually runs on Forgejo with only the runner labels and registry URLs changed. Pair with RULE 0.1 — KeiGit repos MUST stay on private Forgejo, never mirror to github.com. + +## Layout + +Workflows live under `.forgejo/workflows/*.yml` (primary) — `.gitea/workflows/` also works for legacy repos. Keep the same narrow split as GH: + +- `ci.yml` — build + test +- `release.yml` — tag-driven +- `security.yml` — scheduled scanners + +## Self-hosted runner + +Forgejo has no SaaS runner fleet — you provide the compute. Install `forgejo-runner` [VERIFIED: https://code.forgejo.org/forgejo/runner] on a node that is reachable ONLY over Tailscale. + +Registration: + +```bash +forgejo-runner register \ + --no-interactive \ + --instance http://100.91.246.53:3000 \ + --name kgl-runner-01 \ + --labels "self-hosted,linux,x64,docker" \ + --token "$FORGEJO_RUNNER_TOKEN" # from secrets/runner.env (RULE 0.8) +``` + +`FORGEJO_RUNNER_TOKEN` stays in `secrets/runner.env` — reference via env name only, never paste the literal value. + +Target in workflow: + +```yaml +jobs: + build: + runs-on: [self-hosted, linux, x64] +``` + +## GitHub-compat surface + +Works out of the box: `actions/checkout@v4`, `actions/cache@v4`, `actions/setup-node@v4`, `Swatinem/rust-cache@v2`, shell/docker steps, matrix, reusable workflows (`uses: ///.forgejo/workflows/@`). + +Does NOT work: `permissions:` block (Forgejo token is scoped at the runner level, not per-job), OIDC federation to AWS/GCP (no JWKS endpoint served by Forgejo), GitHub-Marketplace actions that call `api.github.com` directly. + +Workaround for OIDC: for cloud deploys from Forgejo, prefer short-lived STS tokens minted by a bastion that has an IAM role, passed into the runner via a sealed env file rotated daily. + +## Tailscale-only admin posture + +Forgejo Web UI is http://100.91.246.53:3000, SSH is `ssh://git@100.91.246.53:2222/...`. Both on Tailscale CGNAT. NEVER bind Forgejo to a public IP — runner tokens, PATs, and repo contents are unfiled patent IP (RULE 0.1). + +Key fingerprint for the existing KeiGit host: `SHA256:TxHcs7YuEZiy4Gu0yZOoVidVqlvj8TPC+QgUGjmh0Mw` labelled `macbook`. + +## Secrets + +Forgejo repo secrets (`Repo → Settings → Actions → Secrets`) mirror GH secrets syntactically: `${{ secrets.FOO }}`. Organisation-scope secrets also supported. Every secret still references the canonical `~/.claude/secrets/.env` / `secrets/*.env` source — repo secrets are cache copies, rotated when the source rotates. + +## Forbidden + +- Exposing Forgejo port 3000 or 2222 on a public IP +- Running `forgejo-runner` on a host that is also a production application node +- Mirroring a KeiGit repo to github.com to "get free CI" (RULE 0.1) +- Hard-coded runner tokens in workflow YAML (always `${{ secrets.* }}`) diff --git a/_blocks/ci-github-actions.md b/_blocks/ci-github-actions.md new file mode 100644 index 0000000..c21b663 --- /dev/null +++ b/_blocks/ci-github-actions.md @@ -0,0 +1,95 @@ +# CI — GitHub Actions (OIDC, matrix, cache, reusable workflows) + +Pipeline platform for code hosted on (or mirrored to) github.com. This block ships the defaults; pair with `ci-security-gate.md` for scanners and `ci-release-automation.md` for tags. + +## Workflow layout + +Keep workflow files narrow: ONE responsibility each under `.github/workflows/`. + +- `ci.yml` — build + test on every push/PR +- `release.yml` — tag-driven release automation (see `ci-release-automation.md`) +- `security.yml` — scheduled scanners (see `ci-security-gate.md`) +- `deploy-*.yml` — per-environment deploys, each behind a GitHub Environment with required reviewers + +## OIDC — cloud deploy WITHOUT long-lived keys + +GitHub Actions mints a short-lived JWT per run; the cloud provider trusts `token.actions.githubusercontent.com` and issues temporary credentials. **Never** store `AWS_SECRET_ACCESS_KEY` / `GCP_SA_KEY` in repo secrets. + +```yaml +permissions: + id-token: write # mandatory for OIDC + contents: read +jobs: + deploy: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 # [VERIFIED: https://github.com/actions/checkout] + - uses: aws-actions/configure-aws-credentials@v4 # [VERIFIED: https://github.com/aws-actions/configure-aws-credentials] + with: + role-to-assume: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/gha-deployer + aws-region: eu-north-1 +``` + +Cloud-side role trust policy pins `repo:/:ref:refs/heads/main` — wildcards invite cross-repo impersonation. + +## Least-privilege GITHUB_TOKEN + +Default token permissions at the workflow level, then widen per-job: + +```yaml +permissions: + contents: read # read-only at top level +jobs: + build: + # inherits read-only + release: + permissions: + contents: write # only the release job gets write + id-token: write +``` + +Org-level default should be `read` (Settings → Actions → Workflow permissions). Any job requiring write must opt in explicitly. + +## Matrix builds + +Fan out across OS × language version × target; `fail-fast: false` prevents one red cell from cancelling the whole matrix. + +```yaml +strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-14] + rust: [stable, 1.80] # MSRV pin +``` + +## Cache hygiene + +- Lock-file as key, never branch name: `key: cargo-${{ hashFiles('**/Cargo.lock') }}`. +- `restore-keys` is a PREFIX fallback — safe for cold PRs. +- `actions/cache@v4` [VERIFIED: https://github.com/actions/cache] for generic; language-specific actions (`actions/setup-node@v4`, `Swatinem/rust-cache@v2`) manage cache internally — don't double-cache. +- Cache POISONING check: never cache directories that contain your built artefacts alongside downloaded deps. + +## Reusable workflows + +Shared logic lives in one repo and is called by `uses: //.github/workflows/.yml@`. Pin by SHA, not tag — tags are mutable. `workflow_call` contract: + +```yaml +on: + workflow_call: + inputs: + rust-version: { required: true, type: string } + secrets: + CARGO_TOKEN: { required: false } +``` + +## Pinning third-party actions + +Pin by full commit SHA, not tag: `uses: foo/bar@3a4b5c6d7e8f9012...` with a comment `# v2.1.0`. Dependabot updates SHAs the same way — supply-chain hijack via tag-overwrite is a documented class (e.g. `tj-actions/changed-files` 2025). [E2] + +## Forbidden + +- `secrets.AWS_SECRET_ACCESS_KEY` in any workflow (use OIDC) +- `permissions: write-all` at workflow level +- Third-party action pinned by tag +- `pull_request_target` with `checkout` of PR head + secrets access (classic pwn-request) +- Caching `target/` or `node_modules/` alongside `.git` or user config diff --git a/_blocks/ci-release-automation.md b/_blocks/ci-release-automation.md new file mode 100644 index 0000000..1ffdc4e --- /dev/null +++ b/_blocks/ci-release-automation.md @@ -0,0 +1,80 @@ +# CI — Release automation (SemVer, changelog, tagging) + +Automates "merge to main → versioned release" so the next step (build artefact, publish, deploy) has a predictable trigger. Picks ONE tool per repo — mixing release-please with cargo-release creates duplicate tags. Pair with `ci-github-actions.md` / `ci-forgejo-actions.md` for the workflow shell. + +## Tool picks per ecosystem + +| Stack | Tool | Trigger | Changelog source | +|---|---|---|---| +| Monorepo / polyglot / apps | release-please [VERIFIED: https://github.com/googleapis/release-please] | merge to main | Conventional Commits | +| JS/TS packages (npm publish) | changesets [VERIFIED: https://github.com/changesets/changesets] | merge of `.changeset/*.md` | Explicit changeset files | +| Rust crates (crates.io) | cargo-release [VERIFIED: https://github.com/crate-ci/cargo-release] | manual `cargo release` | git log + Conventional Commits | +| Go modules | goreleaser [VERIFIED: https://github.com/goreleaser/goreleaser] | tag push | git log + `.goreleaser.yaml` | + +## SemVer contract + +- `MAJOR` — breaking change to public API, wire format, on-disk schema, config file keys +- `MINOR` — additive feature, no breakage, new optional fields +- `PATCH` — bug fix, performance, docs, dep bump without API change + +Conventional Commits mapping: `feat!:` / `BREAKING CHANGE:` → MAJOR; `feat:` → MINOR; `fix:` / `perf:` / `refactor:` → PATCH; `checkpoint:` / `audit:` / `chore:` → no-bump (ignored by release-please). + +## release-please minimal config + +`.github/workflows/release.yml` (or `.forgejo/workflows/release.yml`): + +```yaml +on: + push: + branches: [main] +permissions: + contents: write # create tags + releases + pull-requests: write # update the Release-PR +jobs: + release-please: + runs-on: ubuntu-24.04 + steps: + - uses: googleapis/release-please-action@v4 # [VERIFIED: https://github.com/googleapis/release-please-action] + with: + release-type: rust # or node, python, go, simple, etc. + token: ${{ secrets.GITHUB_TOKEN }} +``` + +release-please opens a long-lived "Release PR" that updates `CHANGELOG.md` + version file on every main merge; merging that PR creates the tag and GitHub Release. No human writes the changelog. + +## changesets minimal config (JS/TS monorepo) + +```yaml +- uses: changesets/action@v1 # [VERIFIED: https://github.com/changesets/action] + with: + publish: pnpm release # runs `changeset publish` + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} +``` + +Each PR that changes a package ships a `.changeset/.md` describing the bump. CI blocks merge without one (`changeset status --since=origin/main`). + +## cargo-release minimal config (Rust crates.io) + +`release.toml` at repo root: + +```toml +sign-tag = true +push = true +tag-message = "{{crate_name}} {{version}}" +pre-release-commit-message = "release: {{version}}" +``` + +Publish workflow runs on tag push: `cargo publish --token "$CARGO_REGISTRY_TOKEN"` where the token is minted just-in-time from the `ci-security-gate.md` trusted-publishing flow. + +## Lock-file discipline + +`Cargo.lock` / `package-lock.json` / `pnpm-lock.yaml` / `pubspec.lock` / `go.sum` — ALWAYS committed (RULE git-conventions). Release workflows must FAIL if the lock file is stale: `cargo update --locked --dry-run`, `pnpm install --frozen-lockfile`, `go mod verify`. + +## Forbidden + +- Manual `git tag vX.Y.Z && git push --tags` when a release tool is configured (drift between CHANGELOG and tag) +- Two release tools in the same repo (release-please + cargo-release both tagging) +- Publishing from a `pull_request` trigger (never — only from `push` to main or `workflow_dispatch`) +- Forcing a tag with `git push --force origin refs/tags/*` — breaks every consumer that pinned by SHA +- Stale lock files passing CI (must be a hard fail, not a warning) diff --git a/_blocks/ci-security-gate.md b/_blocks/ci-security-gate.md new file mode 100644 index 0000000..9680854 --- /dev/null +++ b/_blocks/ci-security-gate.md @@ -0,0 +1,82 @@ +# CI — Security gate (secrets, SCA, SBOM, semgrep, licenses) + +Every PR passes through this gate before merge. Every scheduled run re-scans `main`. Pair with `ci-github-actions.md` / `ci-forgejo-actions.md` (the shell) and RULE 0.8 (secrets SSoT) / RULE 0.1 (no-github-push) — the gate enforces both. + +## Scanner set (one job each, matrix is fine) + +| Concern | Tool | Trigger | Fail threshold | +|---|---|---|---| +| Leaked secrets | gitleaks [VERIFIED: https://github.com/gitleaks/gitleaks] | PR + push | any finding | +| Rust SCA | cargo-audit [VERIFIED: https://github.com/rustsec/rustsec] | PR + cron daily | any `Vulnerability` | +| Node SCA | `npm audit` / `pnpm audit` (native) | PR + cron | `high` and above | +| Python SCA | pip-audit [VERIFIED: https://github.com/pypa/pip-audit] | PR + cron | any CVE | +| SBOM generation | syft [VERIFIED: https://github.com/anchore/syft] | release only | CycloneDX JSON as artefact | +| SAST / patterns | semgrep [VERIFIED: https://github.com/semgrep/semgrep] | PR | any `ERROR` severity | +| License policy | cargo-deny [VERIFIED: https://github.com/EmbarkStudios/cargo-deny] (Rust) / license-checker (JS) | PR | disallowed SPDX ID | + +## gitleaks — secrets scan (always first) + +Runs before any build step so that a detected secret aborts the job without ever shipping a binary that used it. + +```yaml +- uses: gitleaks/gitleaks-action@v2 # [VERIFIED: https://github.com/gitleaks/gitleaks-action] + env: + GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} # orgs only; free for ≤25 users +``` + +Custom rules in `.gitleaks.toml` at repo root — mirror the patterns from `~/.claude/rules/secrets-single-source.md` (sk-, ghp_, sk-ant-, Telegram bot, AWS access key, etc.). Any hit FAILS the run. No "informational" severity for secrets. + +## cargo-audit / pip-audit / npm audit + +Daily cron to catch CVEs published after merge. Fail-fast on HIGH/CRITICAL; report MEDIUM to a tracking issue rather than blocking the PR. + +```yaml +- run: cargo audit --deny warnings --deny unmaintained --deny yanked +``` + +Pin the advisory-DB commit in vendored copies; upstream can get taken down. + +## SBOM via syft + +Generate CycloneDX JSON for every published artefact. Attach to the GitHub Release (see `ci-release-automation.md`) and to the container image as an OCI annotation. + +```yaml +- uses: anchore/sbom-action@v0 # [VERIFIED: https://github.com/anchore/sbom-action] + with: + format: cyclonedx-json + artifact-name: sbom.cdx.json +``` + +SLSA provenance (`slsa-framework/slsa-github-generator`) is an optional upgrade; required when shipping to any customer under a supply-chain contract. + +## semgrep — SAST + +`p/default` + `p/secrets` + `p/owasp-top-ten` + any language pack relevant to the repo. Custom rules under `.semgrep/*.yaml` for project-specific patterns (e.g. "no `unwrap()` in request handlers"). + +```yaml +- uses: semgrep/semgrep-action@v1 # [VERIFIED: https://github.com/semgrep/semgrep-action] + env: + SEMGREP_RULES: p/default p/secrets p/owasp-top-ten +``` + +## License policy + +`cargo-deny` `deny.toml` declares allowed SPDX identifiers (`MIT`, `Apache-2.0`, `BSD-3-Clause`, `ISC`, `Unicode-DFS-2016`). Anything else FAILS the PR. GPL / AGPL / SSPL in a commercial repo = hard stop. For JS, `license-checker --failOn 'GPL;AGPL;SSPL'`. + +## Scheduling + +```yaml +on: + pull_request: + push: { branches: [main] } + schedule: + - cron: "17 3 * * *" # daily 03:17 UTC — off-hour, avoids global burst +``` + +## Forbidden + +- Running the security gate AFTER build/test (secret must block before the secret-using binary exists) +- Allowing "informational" severity on secrets scans (gitleaks = binary; 0 or 1) +- Skipping `cargo-audit` / `pip-audit` on release workflows (a CVE published yesterday ships today without it) +- Uploading SBOM to a public artefact store from a RULE-0.1 repo (internal artefact store only) +- Copy-pasting a secret detected by gitleaks into the chat to "discuss" — rotate at provider FIRST, then discuss diff --git a/_primitives/kei-ci-lint.sh b/_primitives/kei-ci-lint.sh new file mode 100755 index 0000000..48304ea --- /dev/null +++ b/_primitives/kei-ci-lint.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env sh +# kei-ci-lint — validate GitHub Actions / Forgejo Actions workflow YAML. +# POSIX sh, requires yq (v4+, Go impl — mikefarah/yq). +# +# Checks (one rule per check, exits non-zero on any violation unless --warn): +# R1 required fields present (name, on, jobs) +# R2 least-privilege permissions (top-level permissions set, not write-all) +# R3 OIDC vs long-lived token (id-token:write → no AWS_*_KEY secrets) +# R4 cache-hit hygiene (keys use hashFiles, not branch) +# R5 action pinning (uses: pinned by SHA, not mutable tag) +# R6 deprecated actions (set-output, save-state, node12/16) +# R7 pwn-request pattern (pull_request_target + checkout of head) +# +# Usage: +# kei-ci-lint [file2.yml ...] +# kei-ci-lint --dir .github/workflows +# kei-ci-lint --dir .forgejo/workflows --warn +# +# Exit: 0 clean, 1 violation(s), 2 usage/missing-dep. + +set -eu + +WARN=0 +FILES="" +FAIL=0 + +usage() { + cat <<'EOF' +Usage: kei-ci-lint [file2.yml ...] + kei-ci-lint --dir [--warn] +Validates GitHub / Forgejo Actions workflow YAML. +EOF +} + +need() { + command -v "$1" >/dev/null 2>&1 || { echo "kei-ci-lint: missing $1 (install: $2)" >&2; exit 2; } +} + +need yq "brew install yq" + +# Argument parse +if [ $# -eq 0 ]; then usage; exit 2; fi +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) usage; exit 0 ;; + --warn) WARN=1; shift ;; + --dir) [ -d "${2:-}" ] || { echo "kei-ci-lint: not a dir: ${2:-}" >&2; exit 2; } + FILES="$FILES $(find "$2" -maxdepth 2 -type f \( -name '*.yml' -o -name '*.yaml' \) 2>/dev/null)" + shift 2 ;; + *) [ -f "$1" ] || { echo "kei-ci-lint: not a file: $1" >&2; exit 2; } + FILES="$FILES $1"; shift ;; + esac +done + +report() { + # $1=file $2=rule $3=message + if [ "$WARN" = "1" ]; then + printf "WARN %s %s %s\n" "$1" "$2" "$3" + else + printf "FAIL %s %s %s\n" "$1" "$2" "$3" + FAIL=$((FAIL+1)) + fi +} + +check_file() { + F="$1" + # R1 required fields + for key in name on jobs; do + yq -e ".$key" "$F" >/dev/null 2>&1 || report "$F" R1 "missing top-level: $key" + done + + # R2 least-privilege + TOP_PERMS=$(yq '.permissions' "$F" 2>/dev/null || echo "null") + case "$TOP_PERMS" in + null) report "$F" R2 "no top-level permissions — default is write-all on classic repos" ;; + write-all|"'write-all'") report "$F" R2 "permissions: write-all at workflow level" ;; + esac + + # R3 OIDC ↔ long-lived keys + HAS_OIDC=$(yq '.permissions."id-token" // (.jobs.*.permissions."id-token" // "")' "$F" 2>/dev/null | grep -c write || true) + HAS_AWS_KEY=$(grep -E 'secrets\.AWS_(ACCESS_KEY_ID|SECRET_ACCESS_KEY)' "$F" 2>/dev/null | wc -l || echo 0) + if [ "$HAS_OIDC" -gt 0 ] && [ "$HAS_AWS_KEY" -gt 0 ]; then + report "$F" R3 "OIDC enabled AND long-lived AWS secrets present — pick one" + fi + if [ "$HAS_OIDC" = "0" ] && [ "$HAS_AWS_KEY" -gt 0 ]; then + report "$F" R3 "uses long-lived AWS_* secrets — prefer OIDC (id-token:write)" + fi + + # R4 cache-hit hygiene + BAD_CACHE=$(grep -nE 'key:\s*.*github\.ref(_name)?' "$F" 2>/dev/null || true) + if [ -n "$BAD_CACHE" ]; then + report "$F" R4 "cache key uses github.ref (branch-scoped) — use hashFiles() instead" + fi + + # R5 action pinning by SHA + # Extract "uses:" values and check for SHA (40-hex) vs tag. + yq '.jobs.*.steps[].uses // empty' "$F" 2>/dev/null | while IFS= read -r USES; do + [ -z "$USES" ] && continue + case "$USES" in + ./*|../*|docker://*) continue ;; # local/docker refs + esac + REF="${USES##*@}" + # SHA if 40 hex chars + if ! echo "$REF" | grep -qE '^[0-9a-f]{40}$'; then + report "$F" R5 "action pinned by tag, not SHA: $USES" + fi + done + + # R6 deprecated surface + for PAT in '::set-output' '::save-state' 'node12' 'actions/checkout@v[12]' 'actions/cache@v[12]'; do + if grep -qE "$PAT" "$F" 2>/dev/null; then + report "$F" R6 "deprecated: matches /$PAT/" + fi + done + + # R7 pwn-request: pull_request_target + checkout of PR head + if yq -e '.on.pull_request_target' "$F" >/dev/null 2>&1; then + if grep -qE 'ref:\s*\$\{\{\s*github\.event\.pull_request\.head\.sha' "$F" 2>/dev/null; then + report "$F" R7 "pull_request_target + checkout of PR head SHA (pwn-request surface)" + fi + fi +} + +for f in $FILES; do check_file "$f"; done + +if [ "$FAIL" -gt 0 ]; then + echo "kei-ci-lint: $FAIL violation(s)" >&2 + exit 1 +fi +echo "kei-ci-lint: OK" +exit 0 diff --git a/skills/ci-scaffold/SKILL.md b/skills/ci-scaffold/SKILL.md new file mode 100644 index 0000000..46a5955 --- /dev/null +++ b/skills/ci-scaffold/SKILL.md @@ -0,0 +1,89 @@ +--- +name: ci-scaffold +description: Hub-and-spoke pipeline that produces a production-grade CI/CD plan and scaffolds the workflow files for a new or existing repo — platform choice (GitHub Actions vs Forgejo Actions), build matrix, OIDC-vs-token secrets posture, release automation, and a security gate — via pure-click decisions across five phases. Emits `.github/workflows/*.yml` or `.forgejo/workflows/*.yml`, a secrets-env scaffold (RULE 0.8), and runs `kei-ci-lint` before handing off. Never writes secret values. +argument-hint: +--- + +# CI-Scaffold — CI/CD Pipeline Generator (index) + +You are converting "I need CI for repo X" into a reviewable, concrete plan plus generated workflow files: which platform (GH Actions vs Forgejo), what build matrix, how secrets flow (OIDC vs PAT), which release tool, and which security scanners block merge. Every decision is a click; the only typed input is the Phase 1 intake paragraph. + +This skill scaffolds workflow YAML. It does NOT commit on the user's behalf and NEVER writes secret values. After Phase 5 it runs `_primitives/kei-ci-lint.sh` and walks the user through any violations via AskUserQuestion (fix / skip / abort). + +The skill reads four companion blocks heavily — every phase references at least one: + +- `_blocks/ci-github-actions.md` — GH Actions: OIDC, matrix, cache, reusable, least-privilege token. +- `_blocks/ci-forgejo-actions.md` — Forgejo (GH-compat) self-hosted runner, Tailscale-only admin. +- `_blocks/ci-release-automation.md` — release-please / changesets / cargo-release / goreleaser. +- `_blocks/ci-security-gate.md` — gitleaks, cargo-audit, npm/pip-audit, syft SBOM, semgrep, licenses. + +--- + +## Pipeline overview (5 phases, ≥5 AskUserQuestion calls) + +| Phase | File | Purpose | AskUserQuestion | +|---|---|---|---| +| 1 | [phase-1-intake.md](phase-1-intake.md) | Platform / languages / deploy target / release strategy | 4× | +| 2 | [phase-2-matrix.md](phase-2-matrix.md) | Build matrix: OS × version × target | 1× | +| 3 | [phase-3-workflows.md](phase-3-workflows.md) | Generate `.github/workflows/*.yml` or `.forgejo/workflows/*.yml` | 1× | +| 4 | [phase-4-secrets.md](phase-4-secrets.md) | OIDC vs PAT; RULE 0.8 env-var scaffold | 1× | +| 5 | [phase-5-verify.md](phase-5-verify.md) | Run `kei-ci-lint`; fix/skip/abort on each finding | 1× per finding (≥0) | + +Minimum AskUserQuestion count across a full session: **8** (4 Phase 1 + 1 each Phases 2–5). Exceeds the ≥5 hub-and-spoke contract. Phase 5 adds one AskUserQuestion PER lint finding — typically 0–3. + +--- + +## Variables the pipeline produces + +| Name | Set in | Meaning | +|---|---|---| +| `REPO` | Phase 1 | Free-text one-liner: stack + deploy target | +| `PLATFORM` | Phase 1 | github-actions / forgejo-actions / both | +| `LANGS` | Phase 1 | subset of {rust, node, python, go, flutter, swift} | +| `DEPLOY` | Phase 1 | none / aws-oidc / gcp-oidc / cloudflare / modal / docker-registry / custom | +| `RELEASE` | Phase 1 | release-please / changesets / cargo-release / goreleaser / none | +| `MATRIX` | Phase 2 | {os, lang-version, target} tuple list | +| `WORKFLOWS` | Phase 3 | list of generated YAML filenames | +| `SECRETS` | Phase 4 | env var NAMES + storage path; NEVER values | +| `LINT` | Phase 5 | pass / warn-with-overrides / fail | + +--- + +## Final report (emit after Phase 5) + +``` +=== CI-SCAFFOLD REPORT === +Repo: +Platform: +Languages: +Deploy: +Release: +Matrix: × × = N cells +Workflows: +Secrets: env VAR names written to secrets/ci.env scaffold (RULE 0.8) +Lint: () +Next: review diff → commit → push to feat/-ci branch +``` + +--- + +## Rules (apply throughout) + +- **Pure-click contract.** Only Phase 1 intake is typed. Every other decision is `AskUserQuestion`. +- **RULE 0.8 Secrets SSoT.** Emit env VARIABLE NAMES only (`AWS_ROLE_ARN`, `CARGO_REGISTRY_TOKEN`, ...). NEVER echo a token value. Storage path is `/secrets/ci.env` per `_blocks/domain-has-secrets.md`. +- **RULE 0.4 NO HALLUCINATION.** Every `uses:` value cites a real repo — tags used are those actually published on the action's release page at scaffold time (`actions/checkout@v4`, `actions/cache@v4`, `Swatinem/rust-cache@v2`, etc.). If unsure, prefer pin-by-SHA with a comment; never invent a version. +- **RULE 0.1 NO GITHUB PUSH.** If `PLATFORM=forgejo-actions` the skill REFUSES to also emit `.github/workflows/` files. Mixed posture allowed only with explicit user confirmation. +- **NO DOWNGRADE.** If a Phase-5 finding blocks, the skill returns 2–3 constructive fixes (not "skip it"). +- **Fail-closed default.** Unknown stack → no matrix generated until user clicks; missing OIDC role → block deploy job scaffold with a typed TODO. +- **Surgical scope.** Writes ONLY under `.github/workflows/` or `.forgejo/workflows/` and prints the `secrets/ci.env` scaffold to chat (never writes `secrets/*.env` itself). + +--- + +## References + +- `_blocks/ci-github-actions.md`, `_blocks/ci-forgejo-actions.md`, + `_blocks/ci-release-automation.md`, `_blocks/ci-security-gate.md`. +- `_blocks/domain-has-secrets.md` — storage path + loading convention. +- `_blocks/rule-pre-dev-gate.md` — analogue check before inventing a new workflow. +- `_primitives/kei-ci-lint.sh` — workflow YAML validator (R1–R7 rules). +- Evidence grade [E2] — mirrors GitHub Actions security hardening guide + Forgejo Actions docs as of 2026-04-21. diff --git a/skills/ci-scaffold/phase-1-intake.md b/skills/ci-scaffold/phase-1-intake.md new file mode 100644 index 0000000..09e2c6a --- /dev/null +++ b/skills/ci-scaffold/phase-1-intake.md @@ -0,0 +1,115 @@ +# Phase 1 — Intake (platform, languages, deploy, release) + +One free-text paragraph, then four click batches. This is the only phase that accepts typed input. + +## 1a — Ask for the repo description + +Emit a regular message (NOT AskUserQuestion): + +> Describe the repo in one paragraph: what it builds, what language/stack, where it deploys (if anywhere), how releases are tagged today, and any constraint I should know (monorepo, regulated, RULE 0.1 KeiGit-only, etc.). Reply in one message. + +Store the reply verbatim as `REPO`. + +## 1b — Platform click (AskUserQuestion, single-select) + +```json +{ + "questions": [ + { + "question": "CI platform?", + "header": "Platform", + "multiSelect": false, + "options": [ + {"label": "GitHub Actions", "description": "github.com-hosted runners; OIDC to AWS/GCP/CF; see _blocks/ci-github-actions.md"}, + {"label": "Forgejo Actions (self-hosted)", "description": "RULE 0.1 — KeiGit patent IP. Self-hosted runner on Tailscale. GH-compat; see _blocks/ci-forgejo-actions.md"}, + {"label": "Both (mirror main → GH, CI on Forgejo only)", "description": "Rare; explicit sign-off required — RULE 0.1 forbids pushing patent IP to GitHub"}, + {"label": "Neither / unsure", "description": "Skill defaults to Forgejo (safer for unfiled IP); override later"} + ] + } + ] +} +``` + +Store as `PLATFORM`. If `Both` is selected, emit a one-line confirm: "You understand RULE 0.1 — only non-patent code ever pushes to GitHub?" and wait for a `y` typed reply before proceeding. + +## 1c — Languages click (AskUserQuestion, multi-select) + +```json +{ + "questions": [ + { + "question": "Which language toolchains must CI build + test?", + "header": "Languages", + "multiSelect": true, + "options": [ + {"label": "Rust", "description": "cargo build/test + Swatinem/rust-cache@v2 + cargo-audit + cargo-deny"}, + {"label": "Node / TypeScript", "description": "pnpm or npm; actions/setup-node@v4 with cache; npm audit / pnpm audit"}, + {"label": "Python", "description": "actions/setup-python@v5 + pip cache; pip-audit; hatch/poetry/uv as lock source"}, + {"label": "Go", "description": "actions/setup-go@v5 + cache; go vet + govulncheck; goreleaser for release"}, + {"label": "Flutter","description": "subosito/flutter-action@v2; flutter analyze + flutter test before any build"}, + {"label": "Swift", "description": "SPM on macos-14 runner (GH) or self-hosted mac (Forgejo); codesign outside CI"}, + {"label": "Docker image only", "description": "No language toolchain in CI; buildx builds the image + SBOM"} + ] + } + ] +} +``` + +Store as `LANGS`. Empty selection → re-ask. + +## 1d — Deploy target click (AskUserQuestion, single-select) + +```json +{ + "questions": [ + { + "question": "Where does CI deploy the artefact?", + "header": "Deploy", + "multiSelect": false, + "options": [ + {"label": "None — CI only tests", "description": "Skip all deploy jobs; still run build + security gate"}, + {"label": "AWS via OIDC", "description": "aws-actions/configure-aws-credentials@v4; role trust policy pinned to repo+ref"}, + {"label": "GCP via OIDC (WIF)", "description": "google-github-actions/auth@v2 + Workload Identity Federation"}, + {"label": "Cloudflare (Workers/Pages/R2)","description": "wrangler deploy; CLOUDFLARE_API_TOKEN with scopes from self-sufficiency.md"}, + {"label": "Modal (GPU)", "description": "modal deploy; cost tiers enforced (see _blocks/deploy-modal.md + RULE api-cost-guard)"}, + {"label": "Container registry (GHCR / ECR / GAR / Forgejo)", "description": "Build + push image, optionally sign with cosign; SBOM attached"}, + {"label": "Custom / on-prem via SSH", "description": "appleboy/ssh-action@v1 with an ephemeral key minted per run"} + ] + } + ] +} +``` + +Store as `DEPLOY`. + +## 1e — Release strategy click (AskUserQuestion, single-select) + +```json +{ + "questions": [ + { + "question": "Release / versioning tool?", + "header": "Release", + "multiSelect": false, + "options": [ + {"label": "release-please", "description": "Conventional Commits → Release-PR; polyglot; recommended monorepo default"}, + {"label": "changesets", "description": "JS/TS; per-PR .changeset/*.md; best for npm publishing"}, + {"label": "cargo-release", "description": "Rust crates.io; sign-tag, cargo publish with trusted-publishing token"}, + {"label": "goreleaser", "description": "Go; tag push → build matrix + archives + checksums + SBOM"}, + {"label": "Manual tags / none", "description": "No release automation; CI builds + tests only"} + ] + } + ] +} +``` + +Store as `RELEASE`. + +## Verify-criterion + +- `REPO` non-empty. +- `PLATFORM` exactly one label. +- `LANGS` has ≥1 entry (or exactly `Docker image only`). +- `DEPLOY`, `RELEASE` each exactly one label. +- If `PLATFORM = Both`, explicit user `y` confirm captured (RULE 0.1). +- If `DEPLOY = AWS via OIDC` (or GCP/WIF) and `PLATFORM = Forgejo`, warn: "Forgejo has no OIDC JWKS — the AWS role must be assumed via a bastion. Continue?" and offer NO DOWNGRADE alternatives before proceeding. diff --git a/skills/ci-scaffold/phase-2-matrix.md b/skills/ci-scaffold/phase-2-matrix.md new file mode 100644 index 0000000..00778ee --- /dev/null +++ b/skills/ci-scaffold/phase-2-matrix.md @@ -0,0 +1,83 @@ +# Phase 2 — Build matrix (OS × version × target) + +Decide how the build fans out. Matrix minimum: OS × primary-language version. Max reasonable: 3 OS × 3 versions × 3 targets = 27 cells — beyond that CI time-to-green kills iteration speed. + +## 2a — Matrix click (AskUserQuestion, multi-select across three axes) + +The question encodes three axes in one screen to keep the click contract tight. Each selection is stored as a set; the cartesian product becomes `MATRIX`. + +```json +{ + "questions": [ + { + "question": "Build OS?", + "header": "OS", + "multiSelect": true, + "options": [ + {"label": "ubuntu-24.04", "description": "GH-hosted default; also available as self-hosted label on Forgejo"}, + {"label": "ubuntu-22.04", "description": "Older glibc; pick if targeting older prod"}, + {"label": "macos-14", "description": "Apple Silicon (M1); required for Swift/iOS/macOS builds"}, + {"label": "macos-13", "description": "Intel macOS; x86_64 test matrix on Apple software"}, + {"label": "windows-2022", "description": "Only when a .exe / MSVC artefact is shipped"}, + {"label": "self-hosted (Forgejo runner)", "description": "Labels from ci-forgejo-actions.md: self-hosted,linux,x64,docker"} + ] + }, + { + "question": "Language / toolchain versions (picks combine with every OS)?", + "header": "Versions", + "multiSelect": true, + "options": [ + {"label": "Rust stable + MSRV 1.80", "description": "rust: [stable, 1.80] — MSRV pin catches accidental newer-feature use"}, + {"label": "Node 20 LTS + 22 LTS", "description": "node-version: [20, 22] — covers current + upcoming LTS"}, + {"label": "Python 3.11 + 3.12 + 3.13", "description": "python-version: ['3.11','3.12','3.13'] — matches supported pip-audit range"}, + {"label": "Go 1.22 + 1.23", "description": "go-version: ['1.22','1.23'] — current + previous minor"}, + {"label": "Flutter stable", "description": "Single version; pin via flutter-version-file"}, + {"label": "Swift 6.0 (Xcode 16)", "description": "xcode-select on macos-14; one version per OS cell"}, + {"label": "Single version only", "description": "Matrix collapses on the version axis; OS axis still fans out"} + ] + }, + { + "question": "Cross-compile targets (Rust / Go only; skip otherwise)?", + "header": "Targets", + "multiSelect": true, + "options": [ + {"label": "host (no cross)", "description": "Default; one per OS"}, + {"label": "aarch64-unknown-linux-gnu", "description": "ARM64 server deploy; use cross or native ARM runner"}, + {"label": "wasm32-unknown-unknown", "description": "Browser / edge Worker target"}, + {"label": "x86_64-pc-windows-gnu", "description": "MinGW Windows from Linux build"}, + {"label": "aarch64-apple-darwin", "description": "Apple Silicon; native on macos-14, cross on ubuntu"} + ] + } + ] +} +``` + +Store the three sets as `MATRIX.os`, `MATRIX.versions`, `MATRIX.targets`. The scaffold uses the cartesian product. + +## 2b — Sanity check (no AskUserQuestion) + +Compute `N = |os| × |versions| × |targets|`. Print inline: + +``` +Matrix cells: N (os=<...>) × (versions=<...>) × (targets=<...>) +Estimated runtime: ~ minutes wall-clock (parallel) +``` + +If `N > 12`, warn once: "Matrix is wide. Consider dropping one axis or using `strategy.fail-fast: true` for PR-time feedback. Continue?" + +If `MATRIX.os` includes both `macos-*` and `MATRIX.targets` includes only `host`, collapse the targets axis silently — host-on-mac is the only useful combination. + +## 2c — Fail-fast click inferred (inline, no extra AskUserQuestion) + +- PR matrix: `fail-fast: false` (user wants to see ALL failing cells at once). +- Scheduled cron + release matrix: `fail-fast: true` (first failure is enough to trigger remediation). + +Emitted in Phase 3 `ci.yml` / `release.yml` accordingly. + +## Verify-criterion + +- `MATRIX.os` has ≥1 entry. +- `MATRIX.versions` has ≥1 entry (or "Single version only"). +- `MATRIX.targets` has ≥1 entry (defaults to `host`). +- `N ≤ 27` or explicit user override recorded in the final report. +- If `LANGS = {Swift}` then `MATRIX.os` MUST include a `macos-*` entry (fail-closed otherwise). diff --git a/skills/ci-scaffold/phase-3-workflows.md b/skills/ci-scaffold/phase-3-workflows.md new file mode 100644 index 0000000..de52564 --- /dev/null +++ b/skills/ci-scaffold/phase-3-workflows.md @@ -0,0 +1,113 @@ +# Phase 3 — Workflow generation + +Scaffold the YAML files under `.github/workflows/` (if `PLATFORM = GitHub Actions`) or `.forgejo/workflows/` (if `PLATFORM = Forgejo Actions`). Uses `_blocks/ci-github-actions.md` and `_blocks/ci-forgejo-actions.md` as the template source; uses `_blocks/ci-release-automation.md` for the release workflow; uses `_blocks/ci-security-gate.md` for the scanner workflow. + +## 3a — Confirm generation scope (AskUserQuestion, multi-select) + +```json +{ + "questions": [ + { + "question": "Which workflow files to generate?", + "header": "Workflows", + "multiSelect": true, + "options": [ + {"label": "ci.yml — build + test + lint (from MATRIX)", "description": "Runs on push + PR; uses fail-fast:false for PRs"}, + {"label": "security.yml — gitleaks + SCA + semgrep", "description": "From _blocks/ci-security-gate.md; PR trigger + daily cron"}, + {"label": "release.yml — tag / publish (from RELEASE)", "description": "Only if RELEASE != 'Manual tags / none'"}, + {"label": "deploy.yml — per DEPLOY target", "description": "Only if DEPLOY != 'None — CI only tests'; guarded by GitHub Environment + reviewers"}, + {"label": "sbom.yml — syft CycloneDX on release", "description": "Attaches SBOM artefact to release; from ci-security-gate.md"} + ] + } + ] +} +``` + +Store as `WORKFLOWS.selected`. Default-include the first two if the user clicks "nothing selected" — fail-closed. + +## 3b — Scaffold ci.yml + +Platform-specific base directory: + +- `PLATFORM = GitHub Actions` → `.github/workflows/ci.yml` +- `PLATFORM = Forgejo Actions` → `.forgejo/workflows/ci.yml` + +Template (filled from `MATRIX`, `LANGS`): + +```yaml +name: ci +on: + push: + branches: [main] + pull_request: +permissions: + contents: read # least-privilege top-level (ci-github-actions.md R2) +jobs: + build-test: + strategy: + fail-fast: false + matrix: + os: [] + # version axis injected per language + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 # [VERIFIED: https://github.com/actions/checkout] + # language setup steps injected from LANGS + - name: Test + run: +``` + +Per-language step injection (one `setup-*` + one cache strategy each): + +| Lang | setup action | cache action | test command | +|---|---|---|---| +| Rust | `actions-rust-lang/setup-rust-toolchain@v1` [VERIFIED: https://github.com/actions-rust-lang/setup-rust-toolchain] | `Swatinem/rust-cache@v2` [VERIFIED: https://github.com/Swatinem/rust-cache] | `cargo test --workspace --locked` | +| Node | `actions/setup-node@v4` [VERIFIED: https://github.com/actions/setup-node] | built-in `cache: pnpm` | `pnpm install --frozen-lockfile && pnpm test` | +| Python | `actions/setup-python@v5` [VERIFIED: https://github.com/actions/setup-python] | built-in `cache: pip` | `pip install -e .[test] && pytest` | +| Go | `actions/setup-go@v5` [VERIFIED: https://github.com/actions/setup-go] | built-in (>=setup-go v4) | `go test ./...` | +| Flutter | `subosito/flutter-action@v2` [VERIFIED: https://github.com/subosito/flutter-action] | built-in | `flutter analyze && flutter test` | +| Swift | `maxim-lobanov/setup-xcode@v1` [VERIFIED: https://github.com/maxim-lobanov/setup-xcode] | n/a | `swift test` | + +All `uses:` tags above correspond to published versions on the linked repos — RULE 0.4: never invent a tag. If the repo's latest major is unknown at scaffold time, pin by SHA with a `# v` comment. + +## 3c — Scaffold security.yml + +Uses `_blocks/ci-security-gate.md` as the authoritative template. Emits one job per selected scanner: + +- `secrets-scan` — gitleaks (first job, before build) +- `sca-` — one job per `LANGS` entry (`cargo audit`, `pnpm audit`, `pip-audit`, `govulncheck`) +- `sast` — semgrep with `p/default p/secrets p/owasp-top-ten` +- `licenses` — cargo-deny (Rust) or `license-checker --failOn 'GPL;AGPL;SSPL'` (Node) + +Trigger: `on: { pull_request:, push: { branches: [main] }, schedule: [{ cron: '17 3 * * *' }] }`. + +## 3d — Scaffold release.yml + +Template per `RELEASE`: + +- `release-please` → `googleapis/release-please-action@v4` [VERIFIED: https://github.com/googleapis/release-please-action] +- `changesets` → `changesets/action@v1` [VERIFIED: https://github.com/changesets/action] +- `cargo-release` → step runs `cargo publish --locked`, token via OIDC trusted-publishing (or `CARGO_REGISTRY_TOKEN` from Phase 4) +- `goreleaser` → `goreleaser/goreleaser-action@v6` [VERIFIED: https://github.com/goreleaser/goreleaser-action] + +Permissions set at job level only: `contents: write`, `id-token: write`, `pull-requests: write` (release-please). + +## 3e — Scaffold deploy.yml (per DEPLOY) + +- `aws-oidc` — `aws-actions/configure-aws-credentials@v4` with `role-to-assume: ${{ vars.AWS_ROLE_ARN }}`; environment `production` with required reviewer. +- `gcp-oidc` — `google-github-actions/auth@v2` [VERIFIED: https://github.com/google-github-actions/auth] with `workload_identity_provider`. +- `cloudflare` — `cloudflare/wrangler-action@v3` [VERIFIED: https://github.com/cloudflare/wrangler-action] with `apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}`. +- `modal` — `pip install modal && modal deploy` with `MODAL_TOKEN_ID` + `MODAL_TOKEN_SECRET` (Phase 4 registers the env names; RULE api-cost-guard before first run). + +## 3f — Write files, print diff + +Emit each file path + the generated content as a fenced code block. DO NOT commit. Append to chat: + +> Scaffold written. Review, then `git add ` + commit. Phase 5 will run `kei-ci-lint` before you push. + +## Verify-criterion + +- Every entry in `WORKFLOWS.selected` produced exactly one YAML file at the platform-correct path. +- Every `uses:` line has a VERIFIED cite in the surrounding block reference OR is pinned by 40-hex SHA. +- No `secrets.*` variable is populated with a literal in the YAML (Phase 4 owns names only). +- No workflow has `permissions: write-all` at top level. diff --git a/skills/ci-scaffold/phase-4-secrets.md b/skills/ci-scaffold/phase-4-secrets.md new file mode 100644 index 0000000..1c15ac0 --- /dev/null +++ b/skills/ci-scaffold/phase-4-secrets.md @@ -0,0 +1,98 @@ +# Phase 4 — Secrets posture (OIDC vs PAT; RULE 0.8 scaffold) + +Decides how CI obtains credentials. Default bias is OIDC (short-lived, no stored secret); fall back to PAT only when the provider has no OIDC (e.g. Forgejo → AWS, npm trusted-publishing not configured, custom SSH deploy). Every chosen secret is referenced by NAME ONLY per RULE 0.8 — this skill NEVER writes a value. + +## 4a — Posture click (AskUserQuestion, single-select) + +```json +{ + "questions": [ + { + "question": "Credential posture for CI?", + "header": "Secrets", + "multiSelect": false, + "options": [ + {"label": "OIDC-first (recommended)", + "description": "Cloud roles trust token.actions.githubusercontent.com; no long-lived keys stored. Requires DEPLOY ∈ {aws-oidc, gcp-oidc} and PLATFORM = GitHub Actions."}, + {"label": "PAT fallback (when OIDC unavailable)", + "description": "Long-lived scoped tokens stored in repo secrets. Rotation schedule mandatory (30–90 days). Used for Cloudflare, npm, DockerHub, custom SSH."}, + {"label": "Hybrid — OIDC where possible, PAT elsewhere", + "description": "Most real setups. Skill emits both sections of the scaffold."}, + {"label": "No secrets (public CI tests only)", + "description": "ci.yml + security.yml do not need credentials. deploy.yml / release.yml skipped."} + ] + } + ] +} +``` + +Store as `SECRETS.posture`. + +If `PLATFORM = Forgejo Actions` and the user picked `OIDC-first`, warn: "Forgejo does not serve a JWKS endpoint. Use the bastion pattern from `_blocks/ci-forgejo-actions.md` OR switch to PAT-fallback." Offer both constructive paths (NO DOWNGRADE) and re-ask. + +## 4b — Enumerate required secrets (no AskUserQuestion; derived from DEPLOY + RELEASE) + +Walk the matrix below. For each hit, add to `SECRETS.required`. + +| DEPLOY / RELEASE | OIDC posture | PAT fallback posture | +|---|---|---| +| `aws-oidc` | `AWS_ROLE_ARN` (repo var, not secret); `AWS_REGION` | `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` (last-resort, rotate 30d) | +| `gcp-oidc` | `GCP_WORKLOAD_IDENTITY_PROVIDER` + `GCP_SERVICE_ACCOUNT` | `GCP_SA_KEY` JSON (avoid; Google deprecates static keys 2026) | +| `cloudflare` | (Workers OIDC preview; most prod still token) | `CLOUDFLARE_API_TOKEN` (scopes per `self-sufficiency.md`); `CLOUDFLARE_ACCOUNT_ID` | +| `modal` | n/a (Modal has its own token model) | `MODAL_TOKEN_ID` + `MODAL_TOKEN_SECRET`; cost tier check pre-launch | +| `registry (GHCR)` | built-in `GITHUB_TOKEN` write-packages | — | +| `registry (ECR)` | Uses AWS OIDC role | `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` | +| `registry (Forgejo)` | `FORGEJO_TOKEN` (built-in at runner) | — | +| `custom SSH` | — | `SSH_PRIVATE_KEY` (ed25519, generated fresh per repo), `SSH_HOST`, `SSH_USER` | +| `RELEASE=cargo-release` | crates.io trusted publishing (2025+) | `CARGO_REGISTRY_TOKEN` | +| `RELEASE=changesets` | npm trusted publishing (2026 preview) | `NPM_TOKEN` | + +## 4c — Emit `secrets/ci.env` scaffold (inline; no file write) + +Print as a fenced code block. Example when posture is OIDC-first + cargo-release: + +```bash +# secrets/ci.env — paths and NAMES only. chmod 600 + .gitignore before writing values. +# RULE 0.8: reference by env-var name. NEVER paste a literal here. + +# OIDC (no secrets stored; vars on the provider side) +AWS_ROLE_ARN= # arn:aws:iam:::role/gha-deployer — set as repo VAR, not secret +AWS_REGION= # eu-north-1 + +# Release publishing +CARGO_REGISTRY_TOKEN= # trusted-publishing preferred; fallback PAT only if TP unavailable +``` + +Append the reminder once: + +> `secrets/ci.env` must be `chmod 600` AND listed in `.gitignore` BEFORE the first write. See `_blocks/domain-has-secrets.md`. Repo-level "Secrets and variables → Actions" is the deployment copy — rotate source `.env` when repo secret rotates, not the other way around. + +## 4d — Confirm repo-side secret registration (AskUserQuestion, multi-select) + +```json +{ + "questions": [ + { + "question": "For each name I listed, confirm it is REGISTERED on the platform (Settings → Actions → Secrets or Repo Variables):", + "header": "Registered", + "multiSelect": true, + "options": [ + {"label": "All names present and current (rotated within the last 90 days)", "description": "Proceed to Phase 5"}, + {"label": "Some names missing — I will register now and re-run", "description": "Skill exits; re-enter after registration"}, + {"label": "I use a secrets manager (Vault / 1Password CLI / Doppler) that syncs to the platform", "description": "Acceptable; confirm sync is green"}, + {"label": "None registered yet — show me the platform link", "description": "Emit link per PLATFORM and exit"} + ] + } + ] +} +``` + +Store the answer as `SECRETS.registration_status`. Any answer other than the first pauses Phase 5. + +## Verify-criterion + +- `SECRETS.posture` is exactly one choice. +- `SECRETS.required` is fully enumerated from `DEPLOY` + `RELEASE`; no `TODO` placeholders. +- The printed scaffold has NO literal values — every `=` is followed by whitespace or a `#` comment. +- Forgejo + OIDC combination has either the bastion pattern documented or the user opted into PAT-fallback. +- `SECRETS.registration_status` non-empty. diff --git a/skills/ci-scaffold/phase-5-verify.md b/skills/ci-scaffold/phase-5-verify.md new file mode 100644 index 0000000..b90a31c --- /dev/null +++ b/skills/ci-scaffold/phase-5-verify.md @@ -0,0 +1,98 @@ +# Phase 5 — Verify via kei-ci-lint, then final report + +Close the pipeline by validating every generated workflow with `_primitives/kei-ci-lint.sh`. The lint has seven rules (R1–R7); each finding drives one AskUserQuestion for fix/skip/abort. + +## 5a — Run the linter + +Execute: + +``` +sh _primitives/kei-ci-lint.sh --dir .github/workflows +# or, if PLATFORM = Forgejo Actions: +sh _primitives/kei-ci-lint.sh --dir .forgejo/workflows +``` + +Capture stdout + stderr. Parse output — one line per finding, format `FAIL ` or `WARN …`. + +## 5b — Per-finding triage (AskUserQuestion, single-select per finding) + +For EACH `FAIL` line, emit: + +```json +{ + "questions": [ + { + "question": "Lint finding: in . Action?", + "header": "Lint", + "multiSelect": false, + "options": [ + {"label": "Fix now (skill applies the recommended patch)", + "description": "Skill edits the YAML file inline; next lint run must show clean"}, + {"label": "Skip (add to allowlist with justification)", + "description": "Skill prompts for a 1-line justification; stored as a YAML comment + ledger line"}, + {"label": "Abort (stop the pipeline; user investigates manually)", + "description": "Scaffold stays in place, but final report marks the skill run as INCOMPLETE"} + ] + } + ] +} +``` + +Store each answer under `LINT.triage[]`. + +## 5c — Fix recipes (applied inline when user picks "Fix now") + +| Rule | Fix | +|---|---| +| R1 missing `name:` / `on:` / `jobs:` | Insert the field with a sensible default (`name` from filename; `on: { pull_request: , push: { branches: [main] } }`) | +| R2 no top-level `permissions:` | Insert `permissions: { contents: read }` at top level | +| R2 `permissions: write-all` | Replace with `contents: read`; move `write` to the single job that needs it | +| R3 OIDC + AWS keys present | Ask AskUserQuestion: "Which one do you keep? OIDC / keys" — remove the other | +| R4 `key: github.ref` | Replace with `key: -${{ hashFiles('') }}` | +| R5 action pinned by tag | Look up the tag's commit SHA on the action's repo, replace `@vX.Y` with the full 40-hex SHA, add `# vX.Y` comment. If lookup fails, leave the tag with a TODO comment and ABORT rather than inventing a SHA (RULE 0.4) | +| R6 `::set-output` / `::save-state` | Replace with `$GITHUB_OUTPUT` / `$GITHUB_STATE` redirect (GH docs 2023+) | +| R6 `actions/checkout@v1` / `v2` | Upgrade to `@v4` (and re-run R5 pin-by-SHA) | +| R7 `pull_request_target` + PR-head checkout | Either remove `pull_request_target` (prefer) OR remove the `ref: ${{ github.event.pull_request.head.sha }}` line. Present both to user | + +## 5d — Re-run linter after fixes + +After all fixes applied, re-run `kei-ci-lint` once. If still failing, enter the 3-Level Escalation (dev-workflow.md): after 2 automatic fix attempts, STOP and escalate — present the remaining findings to the user with a numbered plan (NO DOWNGRADE: alternative scaffolds, not "accept the violation"). + +## 5e — Emit final report + +Template (from SKILL.md): + +``` +=== CI-SCAFFOLD REPORT === +Repo: +Platform: +Languages: +Deploy: +Release: +Matrix: <|os|> × <|versions|> × <|targets|> = cells +Workflows: +Secrets: <|SECRETS.required|> env names scaffolded to secrets/ci.env (posture: ) +Lint: | FAIL-> — // +Next: git diff → review → commit on feat/-ci → PR + +Citations used (RULE 0.4): + - actions/checkout@v4 [VERIFIED: https://github.com/actions/checkout] + - actions/cache@v4 [VERIFIED: https://github.com/actions/cache] + - +``` + +## 5f — Handoff + +If `LINT` is `PASS` or `WARN-only`, advise: + +> Scaffold complete. Next: `git add .github/ .forgejo/ secrets/ci.env` (NOT the secret values — just the scaffold), commit on a `feat/ci-*` branch, push, and request review. + +If `LINT = FAIL` after 2 fix passes, advise the user to invoke `compose-solution` with the remaining findings as new components — the meta-orchestrator may find missing `_blocks/` or suggest a new primitive. + +## Verify-criterion + +- `kei-ci-lint` was executed against the generated files. +- Every `FAIL` line produced exactly one AskUserQuestion triage. +- No action tag was invented to satisfy R5 — unresolvable SHA lookups must ABORT with a TODO (RULE 0.4 hard). +- Final report lists every citation used in every generated workflow. +- `Next:` line tells the user exactly what to stage, where to branch, and where to PR.