mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-23 00:41:36 +00:00
* feat(eval): run the skill-evolution loop online Add a scheduled + dispatch-gated workflow that runs the offline propose -> benchmark -> gate loop (workflow_bench.evolve) in CI with the pinned Claude canary runtime and bubblewrap containment, uploads the benchmark evidence as an artifact, and on a gate-passed promotion opens a human-reviewed PR via the release App token. The applied overlay is bounded to the canonical skill tree and its shipped mirrors; any escape fails the run instead of reaching a PR. The scheduled lane ships disabled behind GITNEXUS_EVOLUTION_ENABLED and requires the new GITNEXUS_BENCH_AUTH_TOKEN secret (benchmark sessions bill real API usage), mirroring the review agent's staged rollout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ * fix(ci): restructure promotion-PR script so no lint suppression is needed Replace the inline single-quoted credential helper with a GIT_ASKPASS file written via a quoted heredoc (the App token still reaches git only through step env at push time), and assemble the PR body from quoted heredocs plus double-quoted printf instead of a backtick-laden single-quoted template. Every run script in the workflow now passes shellcheck with zero findings and zero disables; the body and askpass rendering are smoke-tested. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ * fix(ci): apply gate-passing overlays in the evolution loop The loop invoked workflow_bench.evolve without --apply, so apply_promoted_overlay (its only working-tree writer, gated by `if args.apply:`) never ran. git status stayed clean, promoted=false was emitted every run, and the App-token/PR-open steps were unreachable dead code — a gate-passing run went green as "No promotion this run". validate_promotion_for_apply already runs before the apply gate, so adding --apply lets a passing candidate reach the tree without weakening the deterministic gate; the boundary check then confirms it stayed in the skill trees. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ * fix(ci): provision ~/GitNexus so the benchmark repo resolves on CI Every scenario in tasks.scenarios.yaml addresses the target repo as ~/GitNexus; runner_tasks.py resolves it with expanduser().resolve() then `git -C <repo> rev-parse`, which raises when the path is missing. On a hosted runner the checkout lands in $GITHUB_WORKSPACE and nothing created ~/GitNexus, so the first real run failed at task-binding. Symlink ~/GitNexus -> $GITHUB_WORKSPACE before the loop. The checkout uses fetch-depth: 0 (full history for the parentless clone), and the benchmark only clones the repo copy-on-write and mounts deps read-only, so the checkout is never mutated. GITNEXUS_BENCH_ORACLE_ROOT stays unset — it defaults to the in-repo oracles dir and is staged by the harness. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ * fix(ci): harden promotion summary output and PR branch recovery Three fixes to the promotion-detection and PR-open steps: - GITHUB_OUTPUT summary used a fixed `PROMOTION_EOF` heredoc delimiter; a value containing that marker on its own line could close the block early and inject output keys. Use a per-run random delimiter, matching the pattern already in tree-sitter-upgrade-readiness.yml. - The summary concatenated every generation's promotion.json (including rejected ones), so the PR body could show a losing generation's decisions. The loop returns on the first promotion, so emit only the highest-numbered gen-N/bench/promotion.json — the decision that fired. - The promotion branch name omitted the run attempt. GITHUB_RUN_ID is stable across re-runs, so a re-run after push-succeeds/PR-create-fails could never push. Include ${GITHUB_RUN_ATTEMPT} (the artifact name already does). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ * fix(ci): least-privilege the promotion App token and gate on an Environment The Mint-App-Token step passed only app-id + private-key, so the minted token inherited every permission the Release App installation holds (including Workflows: write) — far more than "push a branch, open a PR". Switch to `client-id` (as publish.yml does) and request only permission-contents: write + permission-pull-requests: write. Bind the job to a protected Environment (gitnexus-evolution) so promotion runs can be gated server-side. workflow_dispatch runs the workflow and in-tree evolve.py from the *dispatched ref*, so a code-side ref guard is removable by the dispatched branch itself; an Environment deployment-branch rule (main only) is the boundary that holds. The admin steps to create it and scope the secrets are documented in the activation checklist. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ * fix(ci): correct upload-artifact pin comment and add shell strict-mode - The upload-artifact SHA 043fb46d… is v7.0.1 (labeled so in the sibling workflows that pin it); the comment mislabeled it # v6.0.0. Correct the comment; the pin is unchanged. - Add `set -euo pipefail` to the two build steps that lacked it, matching every other run block in the file (GitHub's default shell already sets -eo pipefail; this adds -u and consistency). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ * docs(ci): complete the skill-evolution activation checklist - Add RELEASE_APP_ID / RELEASE_APP_PRIVATE_KEY to the required-secrets checklist (the Mint step hard-fails without them on a promotion) and the App-install-scope verification. - Document the protected Environment admin step and why it is the real boundary for the workflow_dispatch ref-secret exposure. - Note that workflow_dispatch runs the billing loop regardless of GITNEXUS_EVOLUTION_ENABLED. - Justify the weekly cron against the README's ~90-day guidance and note the 355-minute timeout ceiling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ * fix(eval): redact API tokens from diagnostic fields before artifact upload results.jsonl (runner.py) and proposer-session.json (evolve.py) serialize session records whose error_detail can carry a stderr_tail that echoed the API key. Transcripts are redacted before persistence, but these two sinks were not, and both land in the 14-day evolution artifact. Run each record's serialized JSON through the existing redact_text with the run's auth token before writing. Scoped to these diagnostic sinks only: the promoted overlay and proposal.md are left untouched (the overlay is the applied artifact and must stay byte-identical for apply and the shipped-skills-sync guard). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ * test(ci): add a contract test for the skill-evolution workflow No test exercised this workflow's path, which is why both P1 blockers (missing --apply, unresolvable ~/GitNexus task repo) reached production. Parse the workflow YAML and assert the structural contract: --apply is passed, the task repo is provisioned, the promotion branch carries the run attempt, the App token is permission-scoped and the job is Environment- gated, the output summary uses a random delimiter and a single generation, the artifact pin is labelled correctly, and every multi-line shell step sets strict mode. Follows the review-agent-workflow.test.ts precedent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ * feat(ci): run the proposer on its own (stronger) model One `model` input drove both the benchmark arms and the proposer/diagnosis session. Split them: `model` stays the benchmark arms (match the model your skill users run, so a promotion is valid for them and the tasks aren't ceiling-saturated), and a new `proposer_model` input runs the proposer — the harder meta-reasoning task that writes the candidate skill, and only one session per generation, so a stronger model is cheap here. evolve.py already supports --proposer-model; the workflow just didn't expose it. Defaults: arms = claude-sonnet-5, proposer = claude-opus-4-8 (both overridable via workflow_dispatch). The weekly cadence bounds the added spend. Contract test asserts the split stays wired. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
113 lines
4.6 KiB
TypeScript
113 lines
4.6 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { load } from 'js-yaml';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
// Contract guard for the online skill-evolution workflow. Both P1 blockers
|
|
// fixed here (a gate-passing run never applied its overlay; the benchmark
|
|
// could not resolve its task repo on a hosted runner) reached production
|
|
// because nothing exercised this workflow's path. Assert the structural
|
|
// contract so a regression fails loudly in CI instead of on the first real run.
|
|
const WORKFLOW_PATH = path.resolve(
|
|
__dirname,
|
|
'../../../.github/workflows/gitnexus-skill-evolution.yml',
|
|
);
|
|
const workflow = readFileSync(WORKFLOW_PATH, 'utf8');
|
|
const workflowDocument = load(workflow) as {
|
|
jobs?: Record<
|
|
string,
|
|
{
|
|
environment?: unknown;
|
|
steps?: Array<{
|
|
name?: string;
|
|
run?: unknown;
|
|
uses?: string;
|
|
with?: Record<string, unknown>;
|
|
}>;
|
|
}
|
|
>;
|
|
};
|
|
|
|
const evolveJob = workflowDocument.jobs?.evolve;
|
|
|
|
function stepRun(stepName: string): string {
|
|
const step = evolveJob?.steps?.find(({ name }) => name === stepName);
|
|
return typeof step?.run === 'string' ? step.run : '';
|
|
}
|
|
|
|
describe('gitnexus skill-evolution workflow contract', () => {
|
|
it('applies gate-passing overlays so the promotion-PR path is reachable', () => {
|
|
const loop = stepRun('Run the propose → benchmark → gate loop');
|
|
expect(loop).toContain('python -m workflow_bench.evolve');
|
|
// Without --apply the overlay is never written, git status stays clean,
|
|
// promoted=false is emitted, and the App-token/PR steps are dead code.
|
|
expect(loop).toContain('--apply');
|
|
});
|
|
|
|
it('runs the proposer on its own model, separate from the benchmark arms', () => {
|
|
const loop = stepRun('Run the propose → benchmark → gate loop');
|
|
// The benchmark arms match the production model; the proposer/diagnosis
|
|
// session gets its own (stronger) model — one session per generation.
|
|
expect(loop).toContain('--model "${MODEL}"');
|
|
expect(loop).toContain('--proposer-model "${PROPOSER_MODEL}"');
|
|
});
|
|
|
|
it('provisions the benchmark task repo at ~/GitNexus before the loop', () => {
|
|
const provision = stepRun('Point the benchmark task repo at the checkout');
|
|
expect(provision).toContain('ln -sfn');
|
|
expect(provision).toContain('${GITHUB_WORKSPACE}');
|
|
expect(provision).toContain('${HOME}/GitNexus');
|
|
});
|
|
|
|
it('names the promotion branch with the run attempt for re-run recovery', () => {
|
|
const openPr = stepRun('Open the promotion PR');
|
|
expect(openPr).toContain('${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}');
|
|
});
|
|
|
|
it('emits only the promoted generation with a per-run random output delimiter', () => {
|
|
const detect = stepRun('Detect and bound the applied promotion');
|
|
// Random per-run delimiter, not a fixed heredoc marker that a summary
|
|
// value could close early.
|
|
expect(detect).toContain('openssl rand -hex');
|
|
expect(detect).not.toContain("echo 'summary<<PROMOTION_EOF'");
|
|
// Single promoted generation (highest-numbered gen-N), not a blind
|
|
// concatenation of every generation's promotion.json.
|
|
expect(detect).toContain('sort -V');
|
|
expect(detect).not.toContain('xargs -0 -r cat');
|
|
});
|
|
|
|
it('least-privileges the App token and gates the job on a protected Environment', () => {
|
|
expect(evolveJob?.environment).toBe('gitnexus-evolution');
|
|
const mint = evolveJob?.steps?.find(({ name }) => name === 'Mint GitHub App token');
|
|
expect(mint?.with).toMatchObject({
|
|
'client-id': expect.any(String),
|
|
'permission-contents': 'write',
|
|
'permission-pull-requests': 'write',
|
|
});
|
|
expect(mint?.with).not.toHaveProperty('app-id');
|
|
});
|
|
|
|
it('labels the upload-artifact pin with its real version', () => {
|
|
expect(workflow).toContain(
|
|
'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1',
|
|
);
|
|
expect(workflow).not.toContain('# v6.0.0');
|
|
});
|
|
|
|
it('runs every multi-line shell step under strict mode', () => {
|
|
const runSteps = (evolveJob?.steps ?? []).filter(
|
|
(step): step is { name?: string; run: string } =>
|
|
typeof step.run === 'string' && step.run.includes('\n'),
|
|
);
|
|
expect(runSteps.length).toBeGreaterThan(0);
|
|
for (const step of runSteps) {
|
|
expect(step.run, `${step.name} must set -euo pipefail`).toContain('set -euo pipefail');
|
|
}
|
|
});
|
|
|
|
it('documents the App secrets and protected Environment on the activation checklist', () => {
|
|
expect(workflow).toContain('RELEASE_APP_ID');
|
|
expect(workflow).toContain('RELEASE_APP_PRIVATE_KEY');
|
|
expect(workflow).toContain('gitnexus-evolution');
|
|
});
|
|
});
|