From 8554295d339280bf84c3083bdcf8fd72a9bb4812 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:18:42 +0000 Subject: [PATCH 1/2] Use Pi for CLI SDK coverage updates --- .../scripts/cli-update-disallowed-paths.sh | 11 + .../tests/update-coverage-security.test.mjs | 141 ++++++ .github/workflows/ci.yml | 12 + .github/workflows/update-cli-coverage.yml | 477 +++++++++++++----- 4 files changed, 522 insertions(+), 119 deletions(-) create mode 100755 .github/scripts/cli-update-disallowed-paths.sh create mode 100644 .github/tests/update-coverage-security.test.mjs diff --git a/.github/scripts/cli-update-disallowed-paths.sh b/.github/scripts/cli-update-disallowed-paths.sh new file mode 100755 index 0000000..d8dbebf --- /dev/null +++ b/.github/scripts/cli-update-disallowed-paths.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Reads NUL-delimited kernel/hypeman-cli paths on stdin and prints, NUL-delimited, +# the ones the CLI coverage updater must not publish. +set -euo pipefail + +while IFS= read -r -d '' path; do + case "$path" in + go.mod | go.sum | cmd/*.go | pkg/*.go | lib/*.go) continue ;; + esac + printf '%s\0' "$path" +done diff --git a/.github/tests/update-coverage-security.test.mjs b/.github/tests/update-coverage-security.test.mjs new file mode 100644 index 0000000..f22cbd8 --- /dev/null +++ b/.github/tests/update-coverage-security.test.mjs @@ -0,0 +1,141 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const workflow = readFileSync(new URL('../workflows/update-cli-coverage.yml', import.meta.url), 'utf8'); +const jobs = Object.fromEntries( + workflow + .slice(workflow.indexOf('\njobs:\n')) + .split(/^ (?=[\w-]+:\n)/m) + .slice(1) + .map((job) => [job.slice(0, job.indexOf(':')), job.split(/(?=^ - )/m).slice(1)]), +); +const steps = Object.values(jobs).flat(); +const step = (job, name) => { + const value = jobs[job].find((entry) => entry.startsWith(` - name: ${name}\n`)); + assert.ok(value, `missing ${job} step: ${name}`); + return value; +}; +const disallowedPaths = (paths) => + spawnSync('bash', [new URL('../scripts/cli-update-disallowed-paths.sh', import.meta.url).pathname], { + input: paths.map((path) => `${path}\0`).join(''), + encoding: 'utf8', + }) + .stdout.split('\0') + .filter(Boolean); + +test('actions are pinned and checkouts do not persist credentials', () => { + for (const entry of steps.filter((entry) => entry.includes('uses: '))) { + assert.match(entry, /uses: [\w/-]+@[a-f0-9]{40}(?:\s|$)/); + if (entry.includes('uses: actions/checkout@')) { + assert.match(entry, /persist-credentials: false/); + assert.doesNotMatch(entry, /token:/); + } + } +}); + +test('generation only runs in the production SDK repository', () => { + // The staging mirror shares this file and must not publish unreleased SDK versions. + assert.match(workflow, /\n generate:\n if: github\.repository == 'kernel\/hypeman-go'\n/); +}); + +test('API source access is read-only and revoked before generation', () => { + const readToken = step('generate', 'Generate API read token'); + assert.match(readToken, /repositories: hypeman\n/); + assert.match(readToken, /permission-contents: read/); + assert.doesNotMatch(readToken, /permission-[\w-]+: write/); + const fetch = step('generate', 'Fetch API spec'); + assert.match(fetch, /GH_TOKEN: \$\{\{ steps\.api-read-token\.outputs\.token \}\}/); + assert.match(fetch, /gh api --method DELETE \/installation\/token\s*$/); + assert.match(fetch, /-f until="\$1"/); + assert.doesNotMatch(fetch, /gh repo clone/); + assert.ok(jobs.generate.indexOf(fetch) < jobs.generate.indexOf(step('generate', 'Update CLI coverage'))); + assert.doesNotMatch( + step('generate', 'Clone CLI repo'), + /GH_TOKEN:|GITHUB_TOKEN:|steps\.[\w-]+\.outputs\.token/, + ); +}); + +test('changes are restricted to the allowlist and validated against published modules', () => { + const agent = step('generate', 'Update CLI coverage'); + const snapshot = step('generate', 'Snapshot publishable changes'); + const verify = step('generate', 'Verify CLI changes'); + assert.ok(jobs.generate.indexOf(agent) < jobs.generate.indexOf(snapshot)); + assert.ok(jobs.generate.indexOf(snapshot) < jobs.generate.indexOf(verify)); + assert.doesNotMatch(agent, /GH_TOKEN:|GITHUB_TOKEN:|steps\.[\w-]+\.outputs\.token/); + assert.match(agent, /Do not commit, push, open, or merge PRs/); + assert.match(agent, /cli-scratch/); + assert.match(snapshot, /updater\/\.github\/scripts\/cli-update-disallowed-paths\.sh/); + assert.match(step('generate', 'Checkout updater scripts'), /sparse-checkout: \.github\/scripts/); + assert.match(snapshot, /git reset -q --hard\n\s+git clean -fdq/); + assert.match(snapshot, /AGENT_BASE: \$\{\{ steps\.cli-clone\.outputs\.agent_base \}\}/); + assert.match(snapshot, /git reset -q --soft "\$AGENT_BASE"\n\s+git add -A/); + const prepare = step('generate', 'Clone CLI repo'); + assert.match(prepare, /main_sha=\$\(git rev-parse origin\/main\)/); + assert.match(prepare, /git config user\.email[\s\S]*git rebase origin\/main; then[\s\S]*else\n\s+echo "rebased=true"/); + assert.match(prepare, /agent_base=\$\(git rev-parse HEAD\)[\s\S]*agent_base_tree=/); + // Only a branch without human commits may be regenerated from main. + assert.match(prepare, /git rebase --abort\n[\s\S]*grep -qvxF \\\n\s+-e '260533166\+kernel-internal\[bot\]@users\.noreply\.github\.com'[\s\S]*exit 1\n[\s\S]*git switch -C /); + const scriptCalls = workflow.match(/^.*disallowed-paths\.sh.*$/gm); + assert.equal(scriptCalls.length, 2); + for (const line of scriptCalls) assert.match(line, /\| bash /); + assert.match(verify, /\{\{with \.Replace\}\}/); + assert.match(verify, /select\(\.New\.Version == null\)/); + for (const command of ['go build ./...', 'go vet ./...', 'go test ./...']) { + assert.ok(verify.includes(command), `missing ${command}`); + } + assert.deepEqual( + disallowedPaths([ + 'go.mod', + 'go.sum', + 'cmd/hypeman/main.go', + 'pkg/cmd/volumecmd.go', + 'lib/compose/compose.go', + 'hypeman', + 'bin/check-release-environment', + 'response.json', + 'pkg/cmd/output.json', + 'lib/compose/testdata/compose.yaml', + '.github/workflows/release.yml', + 'scripts/test', + ]), + [ + 'hypeman', + 'bin/check-release-environment', + 'response.json', + 'pkg/cmd/output.json', + 'lib/compose/testdata/compose.yaml', + '.github/workflows/release.yml', + 'scripts/test', + ], + ); +}); + +test('publishing runs in a separate job that never executes repository code', () => { + assert.doesNotMatch(jobs.generate.join(''), /permission-[\w-]+: write/); + const mintStep = step('publish', 'Generate CLI publishing token'); + assert.match(mintStep, /repositories: hypeman-cli\n/); + assert.match(mintStep, /permission-contents: write/); + const publishRuns = jobs.publish.filter((entry) => entry.includes('run: |')).join(''); + assert.doesNotMatch(publishRuns, /^\s*(go|bun|npm|npx|node|pi|make)\b/m); + assert.doesNotMatch(jobs.publish.join(''), /secrets\.(ANTHROPIC|OPENAI|KERNEL)_API_KEY/); + const apply = step('publish', 'Apply validated changes'); + assert.match(apply, /git apply --index "\$RUNNER_TEMP\/cli-coverage-update\/changes\.patch"/); + assert.match(apply, /cli-update-disallowed-paths\.sh/); + assert.match(apply, /100644\|100755\|000000/); + // Publishing rebuilds the exact head the changes were validated on, so human + // commits on the update branch are never squashed or dropped. + assert.match(apply, /if \[\[ "\$REBASED" == true \]\]; then\n\s+git switch -c \S+ "\$UPDATE_SHA"\n\s+git rebase -q "\$MAIN_SHA"/); + assert.match(apply, /"\$\(git rev-parse 'HEAD\^\{tree\}'\)" != "\$AGENT_BASE_TREE"[\s\S]*exit 1/); + assert.ok(apply.indexOf('AGENT_BASE_TREE') < apply.indexOf('git apply --index')); + const mint = jobs.publish.indexOf(mintStep); + assert.ok(jobs.publish.indexOf(apply) < mint); + assert.doesNotMatch(jobs.publish.slice(0, mint).join(''), /GH_TOKEN:/); + const publish = step('publish', 'Open or update CLI coverage PR'); + assert.match(publish, /GH_TOKEN: \$\{\{ steps\.publish-token\.outputs\.token \}\}/); + assert.match(publish, /gh auth setup-git\n\s+git push --force-with-lease="cli-coverage-update:\$UPDATE_SHA"/); + for (const entry of jobs.publish) { + assert.doesNotMatch(entry, /continue-on-error: true|always\(\)/); + } +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8eb7af7..faf1456 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,3 +80,15 @@ jobs: - name: Run tests run: ./scripts/test + + updater-security: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '22' + - run: node --test .github/tests/update-coverage-security.test.mjs diff --git a/.github/workflows/update-cli-coverage.yml b/.github/workflows/update-cli-coverage.yml index f4fdb33..49f9b41 100644 --- a/.github/workflows/update-cli-coverage.yml +++ b/.github/workflows/update-cli-coverage.yml @@ -1,9 +1,9 @@ name: Update CLI Coverage on: - push: - branches: - - main + # A push to main can run before release-please tags the release. + release: + types: [published] workflow_dispatch: inputs: pr_number: @@ -11,31 +11,42 @@ on: required: false type: string -# Only run one instance at a time; cancel older runs when a new push arrives -concurrency: - group: update-cli-coverage - cancel-in-progress: true - permissions: contents: read + pull-requests: read + +concurrency: + group: update-cli-coverage + cancel-in-progress: false jobs: - update-cli-coverage: + generate: + if: github.repository == 'kernel/hypeman-go' runs-on: ubuntu-latest + timeout-minutes: 55 + outputs: + changed: ${{ steps.snapshot.outputs.changed }} + main_sha: ${{ steps.cli-clone.outputs.main_sha }} + update_sha: ${{ steps.cli-clone.outputs.update_sha }} + rebased: ${{ steps.cli-clone.outputs.rebased }} + agent_base_tree: ${{ steps.cli-clone.outputs.agent_base_tree }} + version: ${{ steps.sdk-version.outputs.version }} steps: - - name: Generate app token - id: app-token - uses: actions/create-github-app-token@v1 + - name: Generate API read token + id: api-read-token + uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 with: app-id: ${{ secrets.ADMIN_APP_ID }} private-key: ${{ secrets.ADMIN_APP_PRIVATE_KEY }} owner: kernel + repositories: hypeman + permission-contents: read - name: Get PR info for manual dispatch id: pr-info if: github.event_name == 'workflow_dispatch' env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} + GH_TOKEN: ${{ github.token }} run: | if [ -n "${{ inputs.pr_number }}" ]; then # Use provided PR number @@ -68,24 +79,32 @@ jobs: fi - name: Checkout SDK repo - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: - fetch-depth: 2 - fetch-tags: true + persist-credentials: false + # The SDK diff starts at CLI main's version, which can be several tags back. + fetch-depth: 0 # For manual dispatch with a specific PR, checkout the merge commit ref: ${{ steps.pr-info.outputs.merge_sha || github.sha }} - - name: Install Cursor CLI - run: | - curl https://cursor.com/install -fsS | bash - echo "$HOME/.cursor/bin" >> $GITHUB_PATH + - name: Checkout updater scripts + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + path: updater + sparse-checkout: .github/scripts + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '22' - - name: Enable Cursor Max Mode + - name: Install Pi + env: + PI_TELEMETRY: '0' run: | - CFG_DIR="$HOME/.cursor" - mkdir -p "$CFG_DIR" - echo '{"maxMode": true}' > "$CFG_DIR/cli-config.json" - echo "CURSOR_CONFIG_DIR=$CFG_DIR" >> "$GITHUB_ENV" + npm install -g --ignore-scripts @earendil-works/pi-coding-agent + pi --version - name: Configure git identity run: | @@ -93,39 +112,54 @@ jobs: git config --global user.email "260533166+kernel-internal[bot]@users.noreply.github.com" - name: Setup Go - uses: actions/setup-go@v6 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version: 'stable' - - name: Clone API repo - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} + - name: Clone CLI repo + id: cli-clone run: | - gh repo clone kernel/hypeman /tmp/hypeman -- --depth=1 - - - name: Clone CLI repo and checkout existing branch - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - run: | - gh repo clone kernel/hypeman-cli /tmp/hypeman-cli + git clone https://github.com/kernel/hypeman-cli.git /tmp/hypeman-cli cd /tmp/hypeman-cli - - # Try to fetch the cli-coverage-update branch from remote - if git fetch origin cli-coverage-update 2>/dev/null; then - echo "Branch cli-coverage-update exists, checking it out..." - git checkout cli-coverage-update - # Merge latest main to keep it up to date - git merge origin/main -m "Merge main into cli-coverage-update" --no-edit || true + git config user.name "kernel-internal[bot]" + git config user.email "260533166+kernel-internal[bot]@users.noreply.github.com" + echo "main_sha=$(git rev-parse origin/main)" >> "$GITHUB_OUTPUT" + if git show-ref --verify --quiet refs/remotes/origin/cli-coverage-update; then + echo "update_sha=$(git rev-parse origin/cli-coverage-update)" >> "$GITHUB_OUTPUT" + git switch --track origin/cli-coverage-update + if ! git rebase origin/main; then + git rebase --abort + # The agent covers everything since main's SDK version, so a bot-only + # branch can be regenerated from main. Human commits must be kept. + # The last two identities are from the previous agent CLI's runs. + if git log --no-merges --format=%ae origin/main..origin/cli-coverage-update \ + | grep -qvxF \ + -e '260533166+kernel-internal[bot]@users.noreply.github.com' \ + -e 'kernel-internal[bot]@users.noreply.github.com' \ + -e 'cursor-agent@cursor.com'; then + echo "::error::cli-coverage-update has human commits that conflict with main. Resolve the conflicts on that branch, then rerun this workflow." + exit 1 + fi + echo "::warning::cli-coverage-update conflicts with main; regenerating it from main." + git switch -C cli-coverage-update origin/main + else + echo "rebased=true" >> "$GITHUB_OUTPUT" + fi else - echo "Branch cli-coverage-update does not exist, creating from main..." - git checkout -b cli-coverage-update + git switch -c cli-coverage-update origin/main fi + # The agent's changes are published on top of this head, so human + # commits on the update branch keep their content and authorship. + echo "agent_base=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + echo "agent_base_tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT" - name: Get SDK version info id: sdk-version + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} run: | - # Get the latest tag if available, otherwise use commit SHA - LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + # Use the published release, else the latest tag, else the commit SHA + LATEST_TAG=${RELEASE_TAG:-$(git describe --tags --abbrev=0 2>/dev/null || echo "")} if [ -n "$LATEST_TAG" ]; then echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT echo "SDK version: $LATEST_TAG" @@ -147,15 +181,89 @@ jobs: echo "author=${{ github.event.head_commit.author.username || github.actor }}" >> $GITHUB_OUTPUT fi + - name: Compute SDK diff since CLI main's version + id: sdk-diff + run: | + # The report replaces the PR description, so audit every release since + # the SDK version on CLI main, not only the ones since the last run. + git -C /tmp/hypeman-cli show origin/main:go.mod > "$RUNNER_TEMP/cli-main-go.mod" + OLD_SDK_VERSION=$(go mod edit -json "$RUNNER_TEMP/cli-main-go.mod" \ + | jq -er '.Require[] | select(.Path == "github.com/kernel/hypeman-go") | .Version') + echo "CLI main uses SDK version: $OLD_SDK_VERSION" + echo "old_version=$OLD_SDK_VERSION" >> $GITHUB_OUTPUT + + NEW_SDK_VERSION="${{ steps.sdk-version.outputs.version }}" + echo "New SDK version: $NEW_SDK_VERSION" + + # Compute the diff between old and new SDK versions + # This gives the agent a focused view of what changed + if git diff "$OLD_SDK_VERSION".."$NEW_SDK_VERSION" -- api.md '*.go' ':!*_test.go' ':!internal/*' > /tmp/sdk-diff.patch 2>/dev/null; then + DIFF_LINES=$(wc -l < /tmp/sdk-diff.patch) + echo "SDK diff: $DIFF_LINES lines" + if [ "$DIFF_LINES" -eq 0 ]; then + echo "No SDK changes between versions (diff is empty)" + fi + else + echo "Could not compute SDK diff (old version tag may not exist), creating empty diff" + echo "" > /tmp/sdk-diff.patch + fi + + utc_date() { + TZ=UTC git log -1 --date=format-local:%Y-%m-%dT%H:%M:%SZ --format=%cd "$1" + } + echo "old_date=$(utc_date "$OLD_SDK_VERSION" 2>/dev/null || true)" >> $GITHUB_OUTPUT + echo "new_date=$(utc_date HEAD)" >> $GITHUB_OUTPUT + + - name: Fetch API spec + env: + GH_TOKEN: ${{ steps.api-read-token.outputs.token }} + OLD_DATE: ${{ steps.sdk-diff.outputs.old_date }} + NEW_DATE: ${{ steps.sdk-diff.outputs.new_date }} + run: | + main_at() { + gh api -X GET repos/kernel/hypeman/commits -f sha=main -f until="$1" -f per_page=1 --jq '.[0].sha // empty' + } + file_at() { + gh api -H 'Accept: application/vnd.github.raw' "repos/kernel/hypeman/contents/$1?ref=$2" + } + head=$(main_at "$NEW_DATE") + test -n "$head" + mkdir -p /tmp/hypeman + for file in openapi.yaml stainless.yaml; do + file_at "$file" "$head" > "/tmp/hypeman/$file" + done + base="" + if [[ -n "$OLD_DATE" ]]; then + base=$(main_at "$OLD_DATE") + fi + if [[ -n "$base" ]]; then + file_at openapi.yaml "$base" > "$RUNNER_TEMP/openapi-base.yaml" + diff -u --label "openapi.yaml@$base" --label "openapi.yaml@$head" \ + "$RUNNER_TEMP/openapi-base.yaml" /tmp/hypeman/openapi.yaml \ + > /tmp/openapi-diff.patch || [[ $? -eq 1 ]] + rm "$RUNNER_TEMP/openapi-base.yaml" + else + printf 'Spec baseline unavailable; compare all relevant descriptions in openapi.yaml.\n' > /tmp/openapi-diff.patch + fi + gh api --method DELETE /installation/token + - name: Update CLI coverage env: - CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} - GH_TOKEN: ${{ steps.app-token.outputs.token }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + PI_PREFERRED_MODEL: ${{ vars.PI_PREFERRED_MODEL }} + PI_SKIP_VERSION_CHECK: '1' + PI_TELEMETRY: '0' BRANCH_PREFIX: cli-coverage-update run: | - cursor-agent -p "You are a CLI updater that implements missing CLI commands based on SDK updates. + : "${PI_PREFERRED_MODEL:?Set the PI_PREFERRED_MODEL organization variable}" + mkdir -p "$RUNNER_TEMP/cli-scratch" + pi --print --no-session --approve \ + --model "$PI_PREFERRED_MODEL" --thinking max \ + "You are a CLI updater that implements missing CLI commands based on SDK updates. - The GitHub CLI is available as \`gh\` and authenticated via GH_TOKEN. Git is available. You have write access to the CLI repository (kernel/hypeman-cli). + Git is available for local diffs only. You have no GitHub publishing credentials. + Do not commit, push, open, or merge PRs. The workflow validates and publishes your changes. # Context - SDK Repo: ${{ github.repository }} (current directory) @@ -164,18 +272,22 @@ jobs: - Commit SHA: ${{ steps.pr-info.outputs.merge_sha || github.sha }} - Commit Author: ${{ steps.sdk-version.outputs.author }} - Trigger: ${{ github.event_name }} ${{ inputs.pr_number && format('(PR #{0})', inputs.pr_number) || '' }} - - API Repo Location: /tmp/hypeman + - API Spec Location: /tmp/hypeman (openapi.yaml and stainless.yaml as of this SDK release) - CLI Repo Location: /tmp/hypeman-cli - Update Branch Prefix: cli-coverage-update # Background The Go SDK (this repo) was just updated by Stainless, and may contain new API methods. The CLI (kernel/hypeman-cli) needs to be updated to expose these new methods as CLI commands. + The CLI main branch is on SDK version ${{ steps.sdk-diff.outputs.old_version }}, and we are updating it to ${{ steps.sdk-version.outputs.version }}. + The update branch may already hold coverage from earlier runs for releases in between; keep it and build on it. # Source Files - SDK api.md: Current directory - READ THIS FILE FIRST. This is the authoritative list of all SDK methods and their signatures. - SDK *.go files: Current directory - Contains param structs (e.g., InstanceNewParams, ImageNewParams) with all available options/fields. + - SDK diff: /tmp/sdk-diff.patch - IMPORTANT: This is the diff of api.md and Go source files between the SDK version on CLI main (${{ steps.sdk-diff.outputs.old_version }}) and the new version (${{ steps.sdk-version.outputs.version }}). Read this file to see exactly what methods, params, and fields were added or changed. Any additions here MUST be reflected in the CLI. - API Spec: /tmp/hypeman/stainless.yaml - SDK configuration with resources and methods - API Spec: /tmp/hypeman/openapi.yaml - Full OpenAPI specification. CHECK for x-cli-skip: true on endpoints - skip those from CLI coverage. + - API Spec diff: /tmp/openapi-diff.patch - Spec changes between the SDK version on CLI main and the new one, including endpoint, parameter, and field description changes. - CLI: /tmp/hypeman-cli - Existing CLI commands (in pkg/cmd/ directory) # CLI Architecture @@ -187,6 +299,7 @@ jobs: - Build commands: pkg/cmd/build.go - Utilities: pkg/cmd/cmdutil.go, pkg/cmd/format.go - Entry point: cmd/hypeman/main.go + - Compose support: lib/compose/ (also uses the SDK) # Task @@ -197,21 +310,27 @@ jobs: - Run: go mod tidy - This ensures the CLI always uses the latest SDK, even if no new commands are added - ## Step 2: Full SDK Method Enumeration (CRITICAL - DO NOT SKIP) - You MUST perform a complete enumeration of ALL SDK methods and their parameters. Do NOT rely only on recent commits. + ## Step 2: Review SDK Diff (CRITICAL - DO THIS BEFORE THE FULL ENUMERATION) + Read /tmp/sdk-diff.patch to see exactly what changed in the SDK between the version on CLI main and the new version. + This diff shows new methods, new param fields, and changed signatures — these are the changes most likely to need CLI coverage. + Treat every addition in this diff as a checklist item: each new method needs a CLI command, each new param field needs a CLI flag. + After verifying these are covered, proceed to the full enumeration below to catch anything else. + + ## Step 3: Full SDK Method Enumeration (CRITICAL - DO NOT SKIP) + You MUST perform a complete enumeration of ALL SDK methods and their parameters. Do NOT rely only on the diff from Step 2. - 2a. Read the api.md file in the SDK repo root. This file lists EVERY SDK method in the format: + 3a. Read the api.md file in the SDK repo root. This file lists EVERY SDK method in the format: - \`client.Resource.Method(ctx, params)\` with links to param types Extract a complete list of all methods. - 2b. For EACH SDK method, read the corresponding param type from the Go source files. + 3b. For EACH SDK method, read the corresponding param type from the Go source files. For example: - InstanceNewParams in instance.go -> lists all fields like \`Image\`, \`Region\`, \`VolumeMounts\`, etc. - ImageNewParams in image.go -> lists all fields like \`Name\`, \`Tag\`, etc. - VolumeNewParams in volume.go -> lists all fields like \`Name\`, \`Size\`, etc. Each field in a Params struct represents an option that could be a CLI flag. - 2c. Build a complete SDK coverage matrix: + 3c. Build a complete SDK coverage matrix: | SDK Method | SDK Param Type | SDK Param Fields | |------------|----------------|------------------| | client.Instances.New | InstanceNewParams | Image, Region, VolumeMounts, ... | @@ -220,14 +339,14 @@ jobs: | client.Volumes.New | VolumeNewParams | Name, Size, ... | | ... | ... | ... | - ## Step 3: Full CLI Command Enumeration (CRITICAL - DO NOT SKIP) + ## Step 4: Full CLI Command Enumeration (CRITICAL - DO NOT SKIP) Enumerate ALL existing CLI commands and their flags. - 3a. Look at pkg/cmd/ directory for existing command files - 3b. For each command file, extract: + 4a. Look at pkg/cmd/ directory for existing command files + 4b. For each command file, extract: - The command name/path (e.g., \`hypeman run\`, \`hypeman image list\`) - All flags defined for that command - 3c. Build a CLI coverage matrix: + 4c. Build a CLI coverage matrix: | CLI Command | CLI Flags | |-------------|-----------| | hypeman run | --name, --image, --quiet, ... | @@ -236,12 +355,13 @@ jobs: | hypeman volume create | --name, ... | | ... | ... | - ## Step 4: Gap Analysis (CRITICAL - DO NOT SKIP) - Compare the SDK matrix (Step 2) with the CLI matrix (Step 3) to identify: + ## Step 5: Gap Analysis (CRITICAL - DO NOT SKIP) + Compare the SDK matrix (Step 3) with the CLI matrix (Step 4) to identify: - 4a. Missing commands: SDK methods with NO corresponding CLI command - 4b. Missing flags: SDK param fields with NO corresponding CLI flag - 4c. Create a gap report: + 5a. Missing commands: SDK methods with NO corresponding CLI command + 5b. Missing flags: SDK param fields with NO corresponding CLI flag + 5c. Cross-check against the SDK diff from Step 2: every addition in /tmp/sdk-diff.patch MUST appear as a gap to address (new method = missing command, new param field = missing flag) unless it already exists in the CLI. + 5d. Create a gap report: ## Missing Commands - client.SomeResource.SomeMethod -> needs new CLI command @@ -249,20 +369,29 @@ jobs: - InstanceNewParams.SomeNewField -> \`hypeman run\` needs --some-new-field - VolumeNewParams.Region -> \`hypeman volume create\` needs --region - ## Step 5: Implement Missing Coverage - For each gap identified in Step 4: + ## Step 6: Implement Missing Coverage + For each gap identified in Step 5: - Implement missing commands following existing patterns in pkg/cmd/ - Add missing flags to existing commands - Use urfave/cli/v3 for command and flag definitions (NOT cobra) - Run \`go build ./...\` to verify the code compiles - ## Step 6: Commit and Push - - You should already be on the cli-coverage-update branch (it was checked out during setup if it existed) - - If you're on main, create/switch to the cli-coverage-update branch - - Commit with message describing SDK version bump and any new commands/flags - - IMPORTANT: Do NOT force push! Use regular \`git push origin cli-coverage-update\` to preserve existing work on the branch - - If push fails due to divergence, pull and rebase first: \`git pull --rebase origin cli-coverage-update\` - - Create or update the PR in kernel/hypeman-cli + ## Step 6b: Sync Help Text With the API Spec + The SDK is generated from openapi.yaml, so its endpoint, parameter, and schema descriptions are the source of truth for what the API does. + - For every changed summary or description in /tmp/openapi-diff.patch that belongs to an endpoint, parameter, or field the CLI exposes, update the matching command Usage/Description text and flag Usage string. Use the spec text for anything you add in this run too. + - Adapt the text; do not copy it verbatim. API descriptions are written for HTTP callers and often reference other endpoints, HTTP methods, status codes, headers, or JSON body paths that mean nothing to a CLI user. Rewrite those references in CLI terms: name the hypeman command and flags that do the equivalent. For example, \"List volumes with \`GET /volumes\`\" becomes \"List volumes with \`hypeman volume list\`\", and a body field like \`size_gb\` becomes its flag, \`--size\`. Drop API details that do not apply to the command. + - Follow existing help text style: keep Usage to one concise line and put caveats in Description. + - Preserve meaning exactly: limits, defaults, units, accepted values, deprecations, and caveats must match the spec. Do not invent behavior the spec does not state. + - Change only text whose meaning the spec change affects, or existing text the spec now shows to be wrong. Do not restyle unrelated help text. + + ## Step 7: Review and Report + - Keep every scratch artifact outside the checkout: write binaries, command output, and logs under $RUNNER_TEMP/cli-scratch, never under /tmp/hypeman-cli. + - Read the full diff against origin/main and remove unrelated changes or unnecessary wrappers/casts. + - Write $RUNNER_TEMP/cli-coverage-report.md with the new commands/flags, help text updated to match spec changes (with the spec operation or field each follows), and intentional exclusions. + - The report replaces the PR description, so it must describe every change in the diff against origin/main, including changes earlier runs left on this branch, not only this run's. + - The report becomes part of a public PR. Include no credentials, customer information, private discussions, or internal URLs. + - Do not commit, push, change git authentication, or open PRs. Leave the changes for the workflow to validate and publish. + - Only go.mod, go.sum, and .go files under cmd/, pkg/, and lib/ are published. The workflow discards every other change before validation. # SDK Method -> CLI Command Mapping Guide Instance operations use Docker-style top-level commands: @@ -322,54 +451,164 @@ jobs: - Handle errors appropriately - Match the style of existing commands - # Output Format - After pushing changes, create or update an evergreen PR using gh: - - 1. Check if a PR already exists for the cli-coverage-update branch: - gh pr list --repo kernel/hypeman-cli --head cli-coverage-update --json number - - 2. If PR exists, update it. If not, create a new one. - - If new commands or flags were added: - Title: 'CLI: Update hypeman SDK to and add new commands/flags' - Body: - 'This PR updates the Hypeman Go SDK to ${{ steps.sdk-version.outputs.version }} and adds CLI commands/flags for new SDK methods. - - ## SDK Update - - Updated hypeman-go to ${{ steps.sdk-version.outputs.version }} - - ## Coverage Analysis - This PR was generated by performing a full enumeration of SDK methods and CLI commands. - - ## New Commands - - \`hypeman \` for \`client.Resource.Action()\` + # Constraints + - ALWAYS update the SDK version in go.mod - this is the primary purpose + - ALWAYS perform the full enumeration (Steps 2-5) - this is critical for finding gaps + - ALL SDK methods in api.md MUST have corresponding CLI commands, EXCEPT those marked with x-cli-skip in openapi.yaml or noted as internal-only in the mapping guide above + - SKIP endpoints marked with x-cli-skip: true in openapi.yaml - these are internal endpoints not suitable for CLI + - The API spec is taken from the API's main branch at release time and can be ahead of the released SDK. The SDK defines what exists: never add commands or flags for operations or fields this SDK version does not have. Use the spec only for the meaning of things the SDK has. + - Streaming methods may have different CLI implementations (e.g., build events are streamed internally) + - Even if no coverage gaps are found, still leave the SDK version bump and write the coverage report + - Ensure code compiles before finishing + " - ## New Flags - - \`--flag-name\` on \`hypeman \` for \`ResourceParams.FieldName\` + - name: Snapshot publishable changes + id: snapshot + working-directory: /tmp/hypeman-cli + env: + AGENT_BASE: ${{ steps.cli-clone.outputs.agent_base }} + run: | + git reset -q --soft "$AGENT_BASE" + git add -A + git diff --cached --name-only --no-renames -z \ + | bash "$GITHUB_WORKSPACE/updater/.github/scripts/cli-update-disallowed-paths.sh" > "$RUNNER_TEMP/excluded-paths" + if [[ -s "$RUNNER_TEMP/excluded-paths" ]]; then + echo "::warning::Discarding changes outside the CLI updater allowlist:" + tr '\0' '\n' < "$RUNNER_TEMP/excluded-paths" + GIT_LITERAL_PATHSPECS=1 git reset -q --pathspec-from-file="$RUNNER_TEMP/excluded-paths" --pathspec-file-nul + fi + git diff --cached --binary --no-renames --no-ext-diff --no-textconv > "$RUNNER_TEMP/changes.patch" + git reset -q --hard + git clean -fdq + if [[ -s "$RUNNER_TEMP/changes.patch" ]]; then + git apply --index "$RUNNER_TEMP/changes.patch" + echo "changed=true" >> "$GITHUB_OUTPUT" + fi - Triggered by: kernel/hypeman-go@${{ steps.pr-info.outputs.merge_sha || github.sha }} - Reviewer: @' + - name: Verify CLI changes + working-directory: /tmp/hypeman-cli + env: + SDK_MODULE: ${{ steps.sdk-version.outputs.module }} + SDK_VERSION: ${{ steps.sdk-version.outputs.version }} + run: | + test -s "$RUNNER_TEMP/cli-coverage-report.md" + expected=$(go list -m -f '{{.Version}}' "$SDK_MODULE@$SDK_VERSION") + test "$(go list -m -f '{{.Version}}' "$SDK_MODULE")" = "$expected" + # Validation must build against published modules, not local replacements. + test -z "$(go list -m -f '{{with .Replace}}{{.Path}}{{end}}' "$SDK_MODULE")" + go mod edit -json | jq -e '[.Replace[]? | select(.New.Version == null)] | length == 0' + go build ./... + go vet ./... + go test ./... + git diff --cached --check + + - name: Upload validated changes + if: steps.snapshot.outputs.changed == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: cli-coverage-update + path: | + ${{ runner.temp }}/changes.patch + ${{ runner.temp }}/cli-coverage-report.md + if-no-files-found: error + retention-days: 7 + + publish: + needs: generate + if: needs.generate.outputs.changed == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + SDK_VERSION: ${{ needs.generate.outputs.version }} + MAIN_SHA: ${{ needs.generate.outputs.main_sha }} + UPDATE_SHA: ${{ needs.generate.outputs.update_sha }} + REBASED: ${{ needs.generate.outputs.rebased }} + AGENT_BASE_TREE: ${{ needs.generate.outputs.agent_base_tree }} + steps: + - name: Checkout updater scripts + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false - If only SDK version update (no coverage gaps found): - Title: 'CLI: Update Hypeman Go SDK to ${{ steps.sdk-version.outputs.version }}' - Body: - 'This PR updates the Hypeman Go SDK dependency to the latest version. + - name: Download validated changes + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: cli-coverage-update + path: ${{ runner.temp }}/cli-coverage-update - ## SDK Update - - Updated hypeman-go to ${{ steps.sdk-version.outputs.version }} + - name: Checkout CLI + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + repository: kernel/hypeman-cli + path: cli + fetch-depth: 0 + persist-credentials: false - ## Coverage Analysis - A full enumeration of SDK methods and CLI commands was performed. No coverage gaps were found. + - name: Apply validated changes + working-directory: cli + run: | + git config user.name "kernel-internal[bot]" + git config user.email "260533166+kernel-internal[bot]@users.noreply.github.com" + if [[ "$REBASED" == true ]]; then + git switch -c cli-coverage-update "$UPDATE_SHA" + git rebase -q "$MAIN_SHA" + else + git switch -c cli-coverage-update "$MAIN_SHA" + fi + if [[ "$(git rev-parse 'HEAD^{tree}')" != "$AGENT_BASE_TREE" ]]; then + echo "::error::The rebuilt update branch differs from the one the changes were validated on." + exit 1 + fi + git apply --index "$RUNNER_TEMP/cli-coverage-update/changes.patch" + git diff --cached --name-only --no-renames -z \ + | bash ../.github/scripts/cli-update-disallowed-paths.sh > "$RUNNER_TEMP/disallowed-paths" + if [[ -s "$RUNNER_TEMP/disallowed-paths" ]]; then + echo "::error::Validated changes include paths outside the CLI updater allowlist:" + tr '\0' '\n' < "$RUNNER_TEMP/disallowed-paths" + exit 1 + fi + if git diff --cached --raw --no-renames | awk '$2 !~ /^(100644|100755|000000)$/ { found = 1 } END { exit !found }'; then + echo "::error::Validated changes may only contain regular files." + exit 1 + fi + git commit -q -m "Update Hypeman Go SDK to $SDK_VERSION and CLI coverage" - Triggered by: kernel/hypeman-go@${{ steps.pr-info.outputs.merge_sha || github.sha }} - Reviewer: @' + - name: Generate CLI publishing token + id: publish-token + uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + with: + app-id: ${{ secrets.ADMIN_APP_ID }} + private-key: ${{ secrets.ADMIN_APP_PRIVATE_KEY }} + owner: kernel + repositories: hypeman-cli + permission-contents: write + permission-pull-requests: write - # Constraints - - ALWAYS update the SDK version in go.mod - this is the primary purpose - - ALWAYS perform the full enumeration (Steps 2-4) - this is critical for finding gaps - - ALL SDK methods in api.md MUST have corresponding CLI commands, EXCEPT those marked with x-cli-skip in openapi.yaml or noted as internal-only in the mapping guide above - - SKIP endpoints marked with x-cli-skip: true in openapi.yaml - these are internal endpoints not suitable for CLI - - Streaming methods may have different CLI implementations (e.g., build events are streamed internally) - - Even if no coverage gaps are found, still create a PR for the SDK version bump - - Ensure code compiles before pushing - " --model ${{ vars.CURSOR_PREFERRED_MODEL }} --force --output-format=text + - name: Open or update CLI coverage PR + working-directory: cli + env: + GH_TOKEN: ${{ steps.publish-token.outputs.token }} + run: | + if git diff --quiet origin/main HEAD; then + echo "CLI coverage is already up to date." + exit 0 + fi + gh auth setup-git + git push --force-with-lease="cli-coverage-update:$UPDATE_SHA" origin cli-coverage-update + + body="$RUNNER_TEMP/cli-coverage-pr.md" + { + printf '## Summary\n\n- Update the Hypeman Go SDK to `%s`.\n' "$SDK_VERSION" + printf '\n## Coverage\n\n' + head -c 60000 "$RUNNER_TEMP/cli-coverage-update/cli-coverage-report.md" + printf '\n## Validation\n\n- Go build\n- Go vet\n- Go tests\n' + } > "$body" + pr=$(gh pr list --repo kernel/hypeman-cli --head cli-coverage-update --base main \ + --state open --json number --jq '.[0].number // empty') + title="CLI: Update Hypeman Go SDK to $SDK_VERSION" + if [[ -n "$pr" ]]; then + gh pr edit "$pr" --repo kernel/hypeman-cli --title "$title" --body-file "$body" + else + gh pr create --repo kernel/hypeman-cli --head cli-coverage-update --base main \ + --title "$title" --body-file "$body" + fi From 904a1a740553b774d2f21f6bc075fe4f70404988 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:50:48 +0000 Subject: [PATCH 2/2] Keep app credentials out of the job that runs Pi --- .../tests/update-coverage-security.test.mjs | 215 +++++++++++++++- .github/workflows/update-cli-coverage.yml | 239 +++++++++++------- 2 files changed, 346 insertions(+), 108 deletions(-) diff --git a/.github/tests/update-coverage-security.test.mjs b/.github/tests/update-coverage-security.test.mjs index f22cbd8..97e1a59 100644 --- a/.github/tests/update-coverage-security.test.mjs +++ b/.github/tests/update-coverage-security.test.mjs @@ -1,15 +1,20 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; +import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import test from 'node:test'; const workflow = readFileSync(new URL('../workflows/update-cli-coverage.yml', import.meta.url), 'utf8'); -const jobs = Object.fromEntries( +const jobTexts = Object.fromEntries( workflow .slice(workflow.indexOf('\njobs:\n')) .split(/^ (?=[\w-]+:\n)/m) .slice(1) - .map((job) => [job.slice(0, job.indexOf(':')), job.split(/(?=^ - )/m).slice(1)]), + .map((job) => [job.slice(0, job.indexOf(':')), job]), +); +const jobs = Object.fromEntries( + Object.entries(jobTexts).map(([name, job]) => [name, job.split(/(?=^ - )/m).slice(1)]), ); const steps = Object.values(jobs).flat(); const step = (job, name) => { @@ -17,14 +22,93 @@ const step = (job, name) => { assert.ok(value, `missing ${job} step: ${name}`); return value; }; +const scriptPath = new URL('../scripts/cli-update-disallowed-paths.sh', import.meta.url).pathname; const disallowedPaths = (paths) => - spawnSync('bash', [new URL('../scripts/cli-update-disallowed-paths.sh', import.meta.url).pathname], { + spawnSync('bash', [scriptPath], { input: paths.map((path) => `${path}\0`).join(''), encoding: 'utf8', }) .stdout.split('\0') .filter(Boolean); +// The run script of a step, as the runner executes it. +const runScript = (entry) => { + const lines = entry.slice(entry.indexOf(' run: |\n') + ' run: |\n'.length).split('\n'); + return lines.map((line) => line.replace(/^ {10}/, '')).join('\n'); +}; +const gitEnv = { + ...process.env, + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_CONFIG_NOSYSTEM: '1', + GIT_AUTHOR_NAME: 'test', + GIT_AUTHOR_EMAIL: 'test@example.com', + GIT_COMMITTER_NAME: 'test', + GIT_COMMITTER_EMAIL: 'test@example.com', +}; +const git = (cwd, ...args) => { + const result = spawnSync('git', args, { cwd, env: gitEnv, encoding: 'utf8' }); + assert.equal(result.status, 0, `git ${args.join(' ')}: ${result.stderr}`); + return result.stdout.trim(); +}; +const write = (root, path, content, mode) => { + mkdirSync(join(root, path, '..'), { recursive: true }); + writeFileSync(join(root, path), content); + if (mode) chmodSync(join(root, path), mode); +}; +const runStep = (entry, cwd, env) => + spawnSync('bash', ['-e', '-c', runScript(entry)], { + cwd, + env: { ...gitEnv, ...env }, + encoding: 'utf8', + }); + +// A temporary workspace with a hypeman-cli-like repo on main. +const fixture = (t) => { + const root = mkdtempSync(join(tmpdir(), 'cli-coverage-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + mkdirSync(join(root, '.github/scripts'), { recursive: true }); + copyFileSync(scriptPath, join(root, '.github/scripts/cli-update-disallowed-paths.sh')); + mkdirSync(join(root, 'updater')); + symlinkSync(join(root, '.github'), join(root, 'updater/.github')); + const cli = join(root, 'cli'); + mkdirSync(cli); + git(cli, 'init', '-q', '-b', 'main'); + write(cli, 'go.mod', 'module example.com/cli\n'); + write(cli, 'pkg/cmd/volume.go', 'package cmd\n'); + write(cli, 'scripts/test', '#!/bin/sh\n', 0o755); + git(cli, 'add', '-A'); + git(cli, 'commit', '-q', '-m', 'main'); + const runnerTemp = join(root, 'runner-temp'); + mkdirSync(join(runnerTemp, 'cli-coverage-update'), { recursive: true }); + return { root, cli, runnerTemp, mainSha: git(cli, 'rev-parse', 'HEAD'), mainTree: git(cli, 'rev-parse', 'HEAD^{tree}') }; +}; + +// Stages edits on a scratch branch and writes them as the validated patch. +const writePatch = ({ cli, runnerTemp, mainSha }, edit) => { + git(cli, 'switch', '-q', '-c', 'scratch', mainSha); + edit(cli); + git(cli, 'add', '-A'); + writeFileSync( + join(runnerTemp, 'cli-coverage-update/changes.patch'), + `${git(cli, 'diff', '--cached', '--binary', '--no-renames')}\n`, + ); + git(cli, 'reset', '-q', '--hard'); + git(cli, 'clean', '-ffdq'); + git(cli, 'switch', '-q', '--detach', mainSha); + git(cli, 'branch', '-q', '-D', 'scratch'); +}; + +const applyChanges = (fx, env = {}) => + runStep(step('publish', 'Apply validated changes'), fx.cli, { + RUNNER_TEMP: fx.runnerTemp, + SDK_VERSION: 'v1.2.3', + MAIN_SHA: fx.mainSha, + UPDATE_SHA: '', + REBASED: '', + AGENT_BASE_TREE: fx.mainTree, + ...env, + }); + test('actions are pinned and checkouts do not persist credentials', () => { for (const entry of steps.filter((entry) => entry.includes('uses: '))) { assert.match(entry, /uses: [\w/-]+@[a-f0-9]{40}(?:\s|$)/); @@ -35,26 +119,51 @@ test('actions are pinned and checkouts do not persist credentials', () => { } }); -test('generation only runs in the production SDK repository', () => { +test('credentialed steps only run in the production SDK repository', () => { // The staging mirror shares this file and must not publish unreleased SDK versions. - assert.match(workflow, /\n generate:\n if: github\.repository == 'kernel\/hypeman-go'\n/); + assert.match(workflow, /\n prefetch:\n if: github\.repository == 'kernel\/hypeman-go'\n/); + assert.match(workflow, /\n generate:\n needs: prefetch\n if: github\.repository == 'kernel\/hypeman-go'\n/); }); -test('API source access is read-only and revoked before generation', () => { - const readToken = step('generate', 'Generate API read token'); +test('the Pi job references no secrets other than the model API keys', () => { + // Every secret a job references is loaded into the runner at job start, and + // Pi could read it from runner memory with passwordless sudo. + const piJobs = Object.keys(jobTexts).filter((name) => /^\s*pi --print\b/m.test(jobTexts[name])); + assert.deepEqual(piJobs, ['generate']); + const referenced = (text) => [...text.matchAll(/\bsecrets\b(?:\.([\w-]+))?/g)].map((match) => match[1] ?? ''); + assert.deepEqual(referenced(workflow.slice(0, workflow.indexOf('\njobs:\n'))), []); + for (const name of piJobs) { + assert.deepEqual( + [...new Set(referenced(jobTexts[name]))].sort(), + ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY'], + `${name} may only reference the model API keys`, + ); + assert.doesNotMatch(jobTexts[name], /github\.token|create-github-app-token|^ permissions:/m); + } + assert.match(workflow, /^permissions:\n contents: read\n\n/m); +}); + +test('API source access is read-only, revoked, and kept out of the Pi job', () => { + const readToken = step('prefetch', 'Generate API read token'); assert.match(readToken, /repositories: hypeman\n/); assert.match(readToken, /permission-contents: read/); assert.doesNotMatch(readToken, /permission-[\w-]+: write/); - const fetch = step('generate', 'Fetch API spec'); + const fetch = step('prefetch', 'Fetch API spec'); assert.match(fetch, /GH_TOKEN: \$\{\{ steps\.api-read-token\.outputs\.token \}\}/); assert.match(fetch, /gh api --method DELETE \/installation\/token\s*$/); assert.match(fetch, /-f until="\$1"/); assert.doesNotMatch(fetch, /gh repo clone/); - assert.ok(jobs.generate.indexOf(fetch) < jobs.generate.indexOf(step('generate', 'Update CLI coverage'))); + const tokenUses = steps.filter((entry) => entry.includes('steps.api-read-token.outputs.token')); + assert.deepEqual(tokenUses, [fetch]); + // Pi only receives the fetched files. + assert.match(step('prefetch', 'Upload CLI coverage context'), /name: cli-coverage-context\n/); + assert.match(step('generate', 'Download CLI coverage context'), /name: cli-coverage-context\n/); assert.doesNotMatch( step('generate', 'Clone CLI repo'), /GH_TOKEN:|GITHUB_TOKEN:|steps\.[\w-]+\.outputs\.token/, ); + // Pi can write to the Go caches, so they must never be saved for other runs. + assert.match(step('generate', 'Setup Go'), /cache: false/); }); test('changes are restricted to the allowlist and validated against published modules', () => { @@ -139,3 +248,89 @@ test('publishing runs in a separate job that never executes repository code', () assert.doesNotMatch(entry, /continue-on-error: true|always\(\)/); } }); + +test('the publish gate commits allowlisted regular files', (t) => { + const fx = fixture(t); + writePatch(fx, (cli) => { + write(cli, 'go.mod', 'module example.com/cli\n\nrequire example.com/sdk v1.2.3\n'); + write(cli, 'pkg/cmd/volume.go', 'package cmd\n\nvar size int\n'); + write(cli, 'lib/compose/spec.go', 'package compose\n'); + write(cli, 'cmd/hypeman/main.go', 'package main\n', 0o755); + }); + const result = applyChanges(fx); + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.equal(git(fx.cli, 'rev-parse', 'HEAD~1'), fx.mainSha); + assert.deepEqual(git(fx.cli, 'diff', '--name-only', 'HEAD~1', 'HEAD').split('\n'), [ + 'cmd/hypeman/main.go', + 'go.mod', + 'lib/compose/spec.go', + 'pkg/cmd/volume.go', + ]); + assert.equal(git(fx.cli, 'log', '-1', '--format=%s'), 'Update Hypeman Go SDK to v1.2.3 and CLI coverage'); +}); + +test('the publish gate rejects paths outside the allowlist', (t) => { + const fx = fixture(t); + writePatch(fx, (cli) => { + write(cli, 'pkg/cmd/volume.go', 'package cmd\n\nvar size int\n'); + write(cli, 'scripts/test', '#!/bin/sh\ncurl example.com\n', 0o755); + }); + const result = applyChanges(fx); + assert.equal(result.status, 1); + assert.match(result.stdout, /outside the CLI updater allowlist:\nscripts\/test/); + assert.equal(git(fx.cli, 'rev-parse', 'HEAD'), fx.mainSha); +}); + +test('the publish gate rejects symlinks and submodules on allowlisted paths', (t) => { + for (const edit of [ + (cli) => symlinkSync('/etc/passwd', join(cli, 'pkg/cmd/passwd.go')), + (cli) => { + const nested = join(cli, 'pkg/cmd/module.go'); + mkdirSync(nested); + git(nested, 'init', '-q'); + git(nested, 'commit', '-q', '--allow-empty', '-m', 'nested'); + }, + ]) { + const fx = fixture(t); + writePatch(fx, edit); + const result = applyChanges(fx); + assert.equal(result.status, 1, result.stdout + result.stderr); + assert.match(result.stdout, /may only contain regular files/); + assert.equal(git(fx.cli, 'rev-parse', 'HEAD'), fx.mainSha); + } +}); + +test('the publish gate rejects a base that differs from the validated one', (t) => { + const fx = fixture(t); + writePatch(fx, (cli) => write(cli, 'pkg/cmd/volume.go', 'package cmd\n\nvar size int\n')); + const result = applyChanges(fx, { AGENT_BASE_TREE: 'b'.repeat(40) }); + assert.equal(result.status, 1); + assert.match(result.stdout, /differs from the one the changes were validated on/); +}); + +test('the snapshot keeps agent commits and drops everything outside the allowlist', (t) => { + const fx = fixture(t); + git(fx.cli, 'switch', '-q', '-c', 'cli-coverage-update'); + write(fx.cli, 'pkg/cmd/volume.go', 'package cmd\n\nvar size int\n'); + git(fx.cli, 'commit', '-q', '-am', 'agent commit'); + write(fx.cli, 'lib/compose/spec.go', 'package compose\n'); + write(fx.cli, 'notes.md', 'scratch\n'); + write(fx.cli, 'scripts/test', '#!/bin/sh\ncurl example.com\n', 0o755); + const output = join(fx.runnerTemp, 'output'); + const result = runStep(step('generate', 'Snapshot publishable changes'), fx.cli, { + AGENT_BASE: fx.mainSha, + GITHUB_WORKSPACE: fx.root, + GITHUB_OUTPUT: output, + RUNNER_TEMP: fx.runnerTemp, + }); + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.equal(readFileSync(output, 'utf8'), 'changed=true\n'); + assert.equal(git(fx.cli, 'rev-parse', 'HEAD'), fx.mainSha); + // The validated checkout holds only the allowlisted changes, all staged. + assert.deepEqual(git(fx.cli, 'status', '--porcelain', '--untracked-files=all').split('\n'), [ + 'A lib/compose/spec.go', + 'M pkg/cmd/volume.go', + ]); + const patch = readFileSync(join(fx.runnerTemp, 'changes.patch'), 'utf8'); + assert.doesNotMatch(patch, /notes\.md|scripts\/test/); +}); diff --git a/.github/workflows/update-cli-coverage.yml b/.github/workflows/update-cli-coverage.yml index 49f9b41..a1b2c7f 100644 --- a/.github/workflows/update-cli-coverage.yml +++ b/.github/workflows/update-cli-coverage.yml @@ -13,24 +13,25 @@ on: permissions: contents: read - pull-requests: read concurrency: group: update-cli-coverage cancel-in-progress: false jobs: - generate: + prefetch: if: github.repository == 'kernel/hypeman-go' runs-on: ubuntu-latest - timeout-minutes: 55 + timeout-minutes: 10 + permissions: + contents: read + pull-requests: read outputs: - changed: ${{ steps.snapshot.outputs.changed }} - main_sha: ${{ steps.cli-clone.outputs.main_sha }} - update_sha: ${{ steps.cli-clone.outputs.update_sha }} - rebased: ${{ steps.cli-clone.outputs.rebased }} - agent_base_tree: ${{ steps.cli-clone.outputs.agent_base_tree }} + sha: ${{ steps.pr-info.outputs.merge_sha || github.sha }} version: ${{ steps.sdk-version.outputs.version }} + module: ${{ steps.sdk-version.outputs.module }} + author: ${{ steps.sdk-version.outputs.author }} + old_version: ${{ steps.sdk-diff.outputs.old_version }} steps: - name: Generate API read token id: api-read-token @@ -87,71 +88,11 @@ jobs: # For manual dispatch with a specific PR, checkout the merge commit ref: ${{ steps.pr-info.outputs.merge_sha || github.sha }} - - name: Checkout updater scripts - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - path: updater - sparse-checkout: .github/scripts - persist-credentials: false - - - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: '22' - - - name: Install Pi - env: - PI_TELEMETRY: '0' - run: | - npm install -g --ignore-scripts @earendil-works/pi-coding-agent - pi --version - - - name: Configure git identity - run: | - git config --global user.name "kernel-internal[bot]" - git config --global user.email "260533166+kernel-internal[bot]@users.noreply.github.com" - - name: Setup Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version: 'stable' - - - name: Clone CLI repo - id: cli-clone - run: | - git clone https://github.com/kernel/hypeman-cli.git /tmp/hypeman-cli - cd /tmp/hypeman-cli - git config user.name "kernel-internal[bot]" - git config user.email "260533166+kernel-internal[bot]@users.noreply.github.com" - echo "main_sha=$(git rev-parse origin/main)" >> "$GITHUB_OUTPUT" - if git show-ref --verify --quiet refs/remotes/origin/cli-coverage-update; then - echo "update_sha=$(git rev-parse origin/cli-coverage-update)" >> "$GITHUB_OUTPUT" - git switch --track origin/cli-coverage-update - if ! git rebase origin/main; then - git rebase --abort - # The agent covers everything since main's SDK version, so a bot-only - # branch can be regenerated from main. Human commits must be kept. - # The last two identities are from the previous agent CLI's runs. - if git log --no-merges --format=%ae origin/main..origin/cli-coverage-update \ - | grep -qvxF \ - -e '260533166+kernel-internal[bot]@users.noreply.github.com' \ - -e 'kernel-internal[bot]@users.noreply.github.com' \ - -e 'cursor-agent@cursor.com'; then - echo "::error::cli-coverage-update has human commits that conflict with main. Resolve the conflicts on that branch, then rerun this workflow." - exit 1 - fi - echo "::warning::cli-coverage-update conflicts with main; regenerating it from main." - git switch -C cli-coverage-update origin/main - else - echo "rebased=true" >> "$GITHUB_OUTPUT" - fi - else - git switch -c cli-coverage-update origin/main - fi - # The agent's changes are published on top of this head, so human - # commits on the update branch keep their content and authorship. - echo "agent_base=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - echo "agent_base_tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT" + cache: false - name: Get SDK version info id: sdk-version @@ -184,10 +125,12 @@ jobs: - name: Compute SDK diff since CLI main's version id: sdk-diff run: | + context="$RUNNER_TEMP/cli-coverage-context" + mkdir -p "$context/api" # The report replaces the PR description, so audit every release since # the SDK version on CLI main, not only the ones since the last run. - git -C /tmp/hypeman-cli show origin/main:go.mod > "$RUNNER_TEMP/cli-main-go.mod" - OLD_SDK_VERSION=$(go mod edit -json "$RUNNER_TEMP/cli-main-go.mod" \ + git clone -q --depth=1 https://github.com/kernel/hypeman-cli.git "$RUNNER_TEMP/hypeman-cli-main" + OLD_SDK_VERSION=$(go mod edit -json "$RUNNER_TEMP/hypeman-cli-main/go.mod" \ | jq -er '.Require[] | select(.Path == "github.com/kernel/hypeman-go") | .Version') echo "CLI main uses SDK version: $OLD_SDK_VERSION" echo "old_version=$OLD_SDK_VERSION" >> $GITHUB_OUTPUT @@ -197,15 +140,15 @@ jobs: # Compute the diff between old and new SDK versions # This gives the agent a focused view of what changed - if git diff "$OLD_SDK_VERSION".."$NEW_SDK_VERSION" -- api.md '*.go' ':!*_test.go' ':!internal/*' > /tmp/sdk-diff.patch 2>/dev/null; then - DIFF_LINES=$(wc -l < /tmp/sdk-diff.patch) + if git diff "$OLD_SDK_VERSION".."$NEW_SDK_VERSION" -- api.md '*.go' ':!*_test.go' ':!internal/*' > "$context/sdk-diff.patch" 2>/dev/null; then + DIFF_LINES=$(wc -l < "$context/sdk-diff.patch") echo "SDK diff: $DIFF_LINES lines" if [ "$DIFF_LINES" -eq 0 ]; then echo "No SDK changes between versions (diff is empty)" fi else echo "Could not compute SDK diff (old version tag may not exist), creating empty diff" - echo "" > /tmp/sdk-diff.patch + echo "" > "$context/sdk-diff.patch" fi utc_date() { @@ -220,6 +163,7 @@ jobs: OLD_DATE: ${{ steps.sdk-diff.outputs.old_date }} NEW_DATE: ${{ steps.sdk-diff.outputs.new_date }} run: | + context="$RUNNER_TEMP/cli-coverage-context" main_at() { gh api -X GET repos/kernel/hypeman/commits -f sha=main -f until="$1" -f per_page=1 --jq '.[0].sha // empty' } @@ -228,9 +172,8 @@ jobs: } head=$(main_at "$NEW_DATE") test -n "$head" - mkdir -p /tmp/hypeman for file in openapi.yaml stainless.yaml; do - file_at "$file" "$head" > "/tmp/hypeman/$file" + file_at "$file" "$head" > "$context/api/$file" done base="" if [[ -n "$OLD_DATE" ]]; then @@ -239,14 +182,114 @@ jobs: if [[ -n "$base" ]]; then file_at openapi.yaml "$base" > "$RUNNER_TEMP/openapi-base.yaml" diff -u --label "openapi.yaml@$base" --label "openapi.yaml@$head" \ - "$RUNNER_TEMP/openapi-base.yaml" /tmp/hypeman/openapi.yaml \ - > /tmp/openapi-diff.patch || [[ $? -eq 1 ]] + "$RUNNER_TEMP/openapi-base.yaml" "$context/api/openapi.yaml" \ + > "$context/openapi-diff.patch" || [[ $? -eq 1 ]] rm "$RUNNER_TEMP/openapi-base.yaml" else - printf 'Spec baseline unavailable; compare all relevant descriptions in openapi.yaml.\n' > /tmp/openapi-diff.patch + printf 'Spec baseline unavailable; compare all relevant descriptions in openapi.yaml.\n' > "$context/openapi-diff.patch" fi gh api --method DELETE /installation/token + - name: Upload CLI coverage context + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: cli-coverage-context + path: ${{ runner.temp }}/cli-coverage-context + if-no-files-found: error + retention-days: 7 + + generate: + needs: prefetch + if: github.repository == 'kernel/hypeman-go' + runs-on: ubuntu-latest + timeout-minutes: 55 + outputs: + changed: ${{ steps.snapshot.outputs.changed }} + main_sha: ${{ steps.cli-clone.outputs.main_sha }} + update_sha: ${{ steps.cli-clone.outputs.update_sha }} + rebased: ${{ steps.cli-clone.outputs.rebased }} + agent_base_tree: ${{ steps.cli-clone.outputs.agent_base_tree }} + steps: + - name: Checkout SDK repo + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + ref: ${{ needs.prefetch.outputs.sha }} + + - name: Checkout updater scripts + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + path: updater + sparse-checkout: .github/scripts + persist-credentials: false + + - name: Download CLI coverage context + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: cli-coverage-context + path: /tmp/cli-coverage-context + + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '22' + + - name: Install Pi + env: + PI_TELEMETRY: '0' + run: | + npm install -g --ignore-scripts @earendil-works/pi-coding-agent + pi --version + + - name: Configure git identity + run: | + git config --global user.name "kernel-internal[bot]" + git config --global user.email "260533166+kernel-internal[bot]@users.noreply.github.com" + + - name: Setup Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: 'stable' + # Pi can write to the Go caches, so they must never be saved for other runs. + cache: false + + - name: Clone CLI repo + id: cli-clone + run: | + git clone https://github.com/kernel/hypeman-cli.git /tmp/hypeman-cli + cd /tmp/hypeman-cli + git config user.name "kernel-internal[bot]" + git config user.email "260533166+kernel-internal[bot]@users.noreply.github.com" + echo "main_sha=$(git rev-parse origin/main)" >> "$GITHUB_OUTPUT" + if git show-ref --verify --quiet refs/remotes/origin/cli-coverage-update; then + echo "update_sha=$(git rev-parse origin/cli-coverage-update)" >> "$GITHUB_OUTPUT" + git switch --track origin/cli-coverage-update + if ! git rebase origin/main; then + git rebase --abort + # The agent covers everything since main's SDK version, so a bot-only + # branch can be regenerated from main. Human commits must be kept. + # The last two identities are from the previous agent CLI's runs. + if git log --no-merges --format=%ae origin/main..origin/cli-coverage-update \ + | grep -qvxF \ + -e '260533166+kernel-internal[bot]@users.noreply.github.com' \ + -e 'kernel-internal[bot]@users.noreply.github.com' \ + -e 'cursor-agent@cursor.com'; then + echo "::error::cli-coverage-update has human commits that conflict with main. Resolve the conflicts on that branch, then rerun this workflow." + exit 1 + fi + echo "::warning::cli-coverage-update conflicts with main; regenerating it from main." + git switch -C cli-coverage-update origin/main + else + echo "rebased=true" >> "$GITHUB_OUTPUT" + fi + else + git switch -c cli-coverage-update origin/main + fi + # The agent's changes are published on top of this head, so human + # commits on the update branch keep their content and authorship. + echo "agent_base=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + echo "agent_base_tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT" + - name: Update CLI coverage env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} @@ -267,27 +310,27 @@ jobs: # Context - SDK Repo: ${{ github.repository }} (current directory) - - SDK Module: ${{ steps.sdk-version.outputs.module }} - - SDK Version: ${{ steps.sdk-version.outputs.version }} - - Commit SHA: ${{ steps.pr-info.outputs.merge_sha || github.sha }} - - Commit Author: ${{ steps.sdk-version.outputs.author }} + - SDK Module: ${{ needs.prefetch.outputs.module }} + - SDK Version: ${{ needs.prefetch.outputs.version }} + - Commit SHA: ${{ needs.prefetch.outputs.sha }} + - Commit Author: ${{ needs.prefetch.outputs.author }} - Trigger: ${{ github.event_name }} ${{ inputs.pr_number && format('(PR #{0})', inputs.pr_number) || '' }} - - API Spec Location: /tmp/hypeman (openapi.yaml and stainless.yaml as of this SDK release) + - API Spec Location: /tmp/cli-coverage-context/api (openapi.yaml and stainless.yaml as of this SDK release) - CLI Repo Location: /tmp/hypeman-cli - Update Branch Prefix: cli-coverage-update # Background The Go SDK (this repo) was just updated by Stainless, and may contain new API methods. The CLI (kernel/hypeman-cli) needs to be updated to expose these new methods as CLI commands. - The CLI main branch is on SDK version ${{ steps.sdk-diff.outputs.old_version }}, and we are updating it to ${{ steps.sdk-version.outputs.version }}. + The CLI main branch is on SDK version ${{ needs.prefetch.outputs.old_version }}, and we are updating it to ${{ needs.prefetch.outputs.version }}. The update branch may already hold coverage from earlier runs for releases in between; keep it and build on it. # Source Files - SDK api.md: Current directory - READ THIS FILE FIRST. This is the authoritative list of all SDK methods and their signatures. - SDK *.go files: Current directory - Contains param structs (e.g., InstanceNewParams, ImageNewParams) with all available options/fields. - - SDK diff: /tmp/sdk-diff.patch - IMPORTANT: This is the diff of api.md and Go source files between the SDK version on CLI main (${{ steps.sdk-diff.outputs.old_version }}) and the new version (${{ steps.sdk-version.outputs.version }}). Read this file to see exactly what methods, params, and fields were added or changed. Any additions here MUST be reflected in the CLI. - - API Spec: /tmp/hypeman/stainless.yaml - SDK configuration with resources and methods - - API Spec: /tmp/hypeman/openapi.yaml - Full OpenAPI specification. CHECK for x-cli-skip: true on endpoints - skip those from CLI coverage. - - API Spec diff: /tmp/openapi-diff.patch - Spec changes between the SDK version on CLI main and the new one, including endpoint, parameter, and field description changes. + - SDK diff: /tmp/cli-coverage-context/sdk-diff.patch - IMPORTANT: This is the diff of api.md and Go source files between the SDK version on CLI main (${{ needs.prefetch.outputs.old_version }}) and the new version (${{ needs.prefetch.outputs.version }}). Read this file to see exactly what methods, params, and fields were added or changed. Any additions here MUST be reflected in the CLI. + - API Spec: /tmp/cli-coverage-context/api/stainless.yaml - SDK configuration with resources and methods + - API Spec: /tmp/cli-coverage-context/api/openapi.yaml - Full OpenAPI specification. CHECK for x-cli-skip: true on endpoints - skip those from CLI coverage. + - API Spec diff: /tmp/cli-coverage-context/openapi-diff.patch - Spec changes between the SDK version on CLI main and the new one, including endpoint, parameter, and field description changes. - CLI: /tmp/hypeman-cli - Existing CLI commands (in pkg/cmd/ directory) # CLI Architecture @@ -305,13 +348,13 @@ jobs: ## Step 1: Update SDK Version (ALWAYS DO THIS FIRST) - Go to /tmp/hypeman-cli - - Update go.mod to require the latest SDK: ${{ steps.sdk-version.outputs.module }}@${{ steps.sdk-version.outputs.version }} - - Run: go get ${{ steps.sdk-version.outputs.module }}@${{ steps.sdk-version.outputs.version }} + - Update go.mod to require the latest SDK: ${{ needs.prefetch.outputs.module }}@${{ needs.prefetch.outputs.version }} + - Run: go get ${{ needs.prefetch.outputs.module }}@${{ needs.prefetch.outputs.version }} - Run: go mod tidy - This ensures the CLI always uses the latest SDK, even if no new commands are added ## Step 2: Review SDK Diff (CRITICAL - DO THIS BEFORE THE FULL ENUMERATION) - Read /tmp/sdk-diff.patch to see exactly what changed in the SDK between the version on CLI main and the new version. + Read /tmp/cli-coverage-context/sdk-diff.patch to see exactly what changed in the SDK between the version on CLI main and the new version. This diff shows new methods, new param fields, and changed signatures — these are the changes most likely to need CLI coverage. Treat every addition in this diff as a checklist item: each new method needs a CLI command, each new param field needs a CLI flag. After verifying these are covered, proceed to the full enumeration below to catch anything else. @@ -360,7 +403,7 @@ jobs: 5a. Missing commands: SDK methods with NO corresponding CLI command 5b. Missing flags: SDK param fields with NO corresponding CLI flag - 5c. Cross-check against the SDK diff from Step 2: every addition in /tmp/sdk-diff.patch MUST appear as a gap to address (new method = missing command, new param field = missing flag) unless it already exists in the CLI. + 5c. Cross-check against the SDK diff from Step 2: every addition in /tmp/cli-coverage-context/sdk-diff.patch MUST appear as a gap to address (new method = missing command, new param field = missing flag) unless it already exists in the CLI. 5d. Create a gap report: ## Missing Commands - client.SomeResource.SomeMethod -> needs new CLI command @@ -378,7 +421,7 @@ jobs: ## Step 6b: Sync Help Text With the API Spec The SDK is generated from openapi.yaml, so its endpoint, parameter, and schema descriptions are the source of truth for what the API does. - - For every changed summary or description in /tmp/openapi-diff.patch that belongs to an endpoint, parameter, or field the CLI exposes, update the matching command Usage/Description text and flag Usage string. Use the spec text for anything you add in this run too. + - For every changed summary or description in /tmp/cli-coverage-context/openapi-diff.patch that belongs to an endpoint, parameter, or field the CLI exposes, update the matching command Usage/Description text and flag Usage string. Use the spec text for anything you add in this run too. - Adapt the text; do not copy it verbatim. API descriptions are written for HTTP callers and often reference other endpoints, HTTP methods, status codes, headers, or JSON body paths that mean nothing to a CLI user. Rewrite those references in CLI terms: name the hypeman command and flags that do the equivalent. For example, \"List volumes with \`GET /volumes\`\" becomes \"List volumes with \`hypeman volume list\`\", and a body field like \`size_gb\` becomes its flag, \`--size\`. Drop API details that do not apply to the command. - Follow existing help text style: keep Usage to one concise line and put caveats in Description. - Preserve meaning exactly: limits, defaults, units, accepted values, deprecations, and caveats must match the spec. Do not invent behavior the spec does not state. @@ -488,8 +531,8 @@ jobs: - name: Verify CLI changes working-directory: /tmp/hypeman-cli env: - SDK_MODULE: ${{ steps.sdk-version.outputs.module }} - SDK_VERSION: ${{ steps.sdk-version.outputs.version }} + SDK_MODULE: ${{ needs.prefetch.outputs.module }} + SDK_VERSION: ${{ needs.prefetch.outputs.version }} run: | test -s "$RUNNER_TEMP/cli-coverage-report.md" expected=$(go list -m -f '{{.Version}}' "$SDK_MODULE@$SDK_VERSION") @@ -514,12 +557,12 @@ jobs: retention-days: 7 publish: - needs: generate + needs: [prefetch, generate] if: needs.generate.outputs.changed == 'true' runs-on: ubuntu-latest timeout-minutes: 10 env: - SDK_VERSION: ${{ needs.generate.outputs.version }} + SDK_VERSION: ${{ needs.prefetch.outputs.version }} MAIN_SHA: ${{ needs.generate.outputs.main_sha }} UPDATE_SHA: ${{ needs.generate.outputs.update_sha }} REBASED: ${{ needs.generate.outputs.rebased }}