diff --git a/.agents/skills/release-yield/SKILL.md b/.agents/skills/release-yield/SKILL.md index f8dd746..3ee76c1 100644 --- a/.agents/skills/release-yield/SKILL.md +++ b/.agents/skills/release-yield/SKILL.md @@ -3,7 +3,7 @@ name: release-yield description: "Release Yield through its protected GitHub workflows and verify every public registry." --- - + This adapter exposes the canonical Yield workflow at `skills/release-yield`. Read its SKILL.md, then run from the repository root: diff --git a/.changeset/self-serve-private-mirror.md b/.changeset/self-serve-private-mirror.md new file mode 100644 index 0000000..c28a6c0 --- /dev/null +++ b/.changeset/self-serve-private-mirror.md @@ -0,0 +1,7 @@ +--- +"@operatorstack/yield": patch +--- + +Make non-Git onboarding commands explicit, generate commit-ready Rust lockfiles, +add exact local-runtime recovery to Go and Rust adapters, and require verified +Artifact Registry parity for stable releases. diff --git a/.claude/skills/release-yield/SKILL.md b/.claude/skills/release-yield/SKILL.md index f8dd746..3ee76c1 100644 --- a/.claude/skills/release-yield/SKILL.md +++ b/.claude/skills/release-yield/SKILL.md @@ -3,7 +3,7 @@ name: release-yield description: "Release Yield through its protected GitHub workflows and verify every public registry." --- - + This adapter exposes the canonical Yield workflow at `skills/release-yield`. Read its SKILL.md, then run from the repository root: diff --git a/.cursor/skills/release-yield/SKILL.md b/.cursor/skills/release-yield/SKILL.md index f8dd746..3ee76c1 100644 --- a/.cursor/skills/release-yield/SKILL.md +++ b/.cursor/skills/release-yield/SKILL.md @@ -3,7 +3,7 @@ name: release-yield description: "Release Yield through its protected GitHub workflows and verify every public registry." --- - + This adapter exposes the canonical Yield workflow at `skills/release-yield`. Read its SKILL.md, then run from the repository root: diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 86e52a8..1eb7a4c 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -381,6 +381,144 @@ jobs: path: dist/crates-receipt/ if-no-files-found: error + private-mirror: + name: Mirror stable release to Artifact Registry + needs: [resolve, build, npm, pypi, crates] + if: needs.resolve.outputs.channel == 'stable' + runs-on: ubuntu-latest + environment: private-production + permissions: + contents: read + id-token: write + env: + VERSION: ${{ needs.resolve.outputs.version }} + SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }} + AR_NPM_URL: https://${{ vars.AR_LOCATION }}-npm.pkg.dev/${{ vars.AR_PROJECT }}/${{ vars.AR_NPM_REPO }}/ + AR_PYTHON_URL: https://${{ vars.AR_LOCATION }}-python.pkg.dev/${{ vars.AR_PROJECT }}/${{ vars.AR_PYTHON_REPO }}/ + steps: + - name: Check out mirror control code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + ref: ${{ github.sha }} + path: control + - name: Check out exact release source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ needs.resolve.outputs.source_sha }} + path: source + - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + name: packages-${{ needs.resolve.outputs.version }}-${{ needs.resolve.outputs.source_sha }} + path: dist/release-unit + - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + name: crates-${{ needs.resolve.outputs.version }}-${{ needs.resolve.outputs.source_sha }} + path: dist/crates + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: "24" + package-manager-cache: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.12" + - id: auth + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3 + with: + workload_identity_provider: ${{ vars.WIF_PROVIDER }} + service_account: ${{ vars.DEPLOYER_SA_EMAIL }} + - uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3 + with: + install_components: package-go-module + - name: Inspect immutable release unit + run: >- + node control/packaging/private-mirror.mjs inspect + --version "$VERSION" + --source-sha "$SOURCE_SHA" + --release-unit dist/release-unit + --crates dist/crates + --manifest "$RUNNER_TEMP/private-mirror-manifest.json" + - id: remote + name: Refuse drift and select missing private artifacts + run: >- + node control/packaging/private-mirror.mjs status + --manifest "$RUNNER_TEMP/private-mirror-manifest.json" + --status "$RUNNER_TEMP/private-mirror-status.json" + --output "$GITHUB_OUTPUT" + - name: Configure Artifact Registry npm authentication + if: steps.remote.outputs.npm_state != 'matched' + run: | + gcloud artifacts print-settings npm \ + --project="${{ vars.AR_PROJECT }}" --location="${{ vars.AR_LOCATION }}" \ + --repository="${{ vars.AR_NPM_REPO }}" --scope=@operatorstack > "$RUNNER_TEMP/yield.npmrc" + npx -y google-artifactregistry-auth "$RUNNER_TEMP/yield.npmrc" + - name: Mirror exact npm archives + if: steps.remote.outputs.npm_state != 'matched' + env: + NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/yield.npmrc + shell: bash + run: | + set -euo pipefail + jq -r '.missing.npm[].file' "$RUNNER_TEMP/private-mirror-status.json" | while read -r file; do + npm publish "dist/release-unit/npm/${file}" --registry="$AR_NPM_URL" + done + - name: Mirror exact Python wheels + if: steps.remote.outputs.python_state != 'matched' + run: | + python -m pip install --disable-pip-version-check twine==6.2.0 keyrings.google-artifactregistry-auth==1.1.2 + jq -r '.missing.python[].file' "$RUNNER_TEMP/private-mirror-status.json" | while read -r file; do + python -m twine upload --repository-url "$AR_PYTHON_URL" "dist/release-unit/pypi/${file}" + done + - name: Mirror exact tagged Go source + if: steps.remote.outputs.go_state == 'missing' + shell: bash + run: | + set -euo pipefail + test "$(git -C source rev-parse HEAD)" = "$SOURCE_SHA" + source_dir="$(mktemp -d)" + git -C source archive "$SOURCE_SHA" | tar -x -C "$source_dir" + gcloud artifacts go upload \ + --project="${{ vars.AR_PROJECT }}" --location="${{ vars.AR_LOCATION }}" \ + --repository="${{ vars.AR_GO_REPO }}" \ + --module-path=github.com/operatorstack/yield \ + --version="v${VERSION}" --source="$source_dir" + - name: Prepare exact crates.io mirror records + if: steps.remote.outputs.rust_state != 'matched' + run: >- + node control/packaging/private-mirror.mjs prepare-cargo + --manifest "$RUNNER_TEMP/private-mirror-manifest.json" + --status "$RUNNER_TEMP/private-mirror-status.json" + --crates dist/crates + --output "$RUNNER_TEMP/private-cargo" + - name: Mirror exact Rust crate archives and index records + if: steps.remote.outputs.rust_state != 'matched' + run: >- + gcloud artifacts generic upload + --project="${{ vars.AR_PROJECT }}" --location="${{ vars.AR_LOCATION }}" + --repository="${{ vars.AR_GENERIC_REPO }}" + --package=yield-rust --version="$VERSION" + --source-directory="$RUNNER_TEMP/private-cargo" + - name: Verify complete private mirror + shell: bash + run: | + set -euo pipefail + for attempt in {1..12}; do + if node control/packaging/private-mirror.mjs verify \ + --manifest "$RUNNER_TEMP/private-mirror-manifest.json" \ + --receipt "$RUNNER_TEMP/private-mirror.json"; then + exit 0 + fi + test "$attempt" -lt 12 + sleep 10 + done + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: private-mirror-${{ needs.resolve.outputs.version }}-${{ needs.resolve.outputs.source_sha }} + path: ${{ runner.temp }}/private-mirror.json + if-no-files-found: error + selfhost-canary: name: Release skill against exact canary needs: [resolve, npm] diff --git a/.github/workflows/release-finalize.yml b/.github/workflows/release-finalize.yml index 539293f..96dc7b7 100644 --- a/.github/workflows/release-finalize.yml +++ b/.github/workflows/release-finalize.yml @@ -93,11 +93,14 @@ jobs: version="${TAG#v}" package_receipt="packages-${version}-${SOURCE_SHA}" crates_receipt="crates-${version}-${SOURCE_SHA}" + private_receipt="private-mirror-${version}-${SOURCE_SHA}" run_id="" while read -r candidate; do test -n "$candidate" || continue artifact_names="$(gh api "/repos/${GITHUB_REPOSITORY}/actions/runs/${candidate}/artifacts?per_page=100" --jq '.artifacts[].name')" - if grep -Fqx "$package_receipt" <<< "$artifact_names" && grep -Fqx "$crates_receipt" <<< "$artifact_names"; then + if grep -Fqx "$package_receipt" <<< "$artifact_names" && \ + grep -Fqx "$crates_receipt" <<< "$artifact_names" && \ + grep -Fqx "$private_receipt" <<< "$artifact_names"; then run_id="$candidate" break fi @@ -108,6 +111,18 @@ jobs: --name "$package_receipt" --dir "$RUNNER_TEMP/release-unit" gh run download "$run_id" --repo "$GITHUB_REPOSITORY" \ --name "$crates_receipt" --dir "$RUNNER_TEMP/crates-receipt" + gh run download "$run_id" --repo "$GITHUB_REPOSITORY" \ + --name "$private_receipt" --dir "$RUNNER_TEMP/private-mirror" + jq -e \ + --arg version "$version" --arg source_sha "$SOURCE_SHA" \ + '.schema_version == 1 and .version == $version and .source_sha == $source_sha and + (.targets | length == 6) and + (.packages.npm | length == 8) and (.packages.python | length == 6) and + (.packages.go | length == 1) and (.packages.rust | length == 7) and + ([.states[]] | all(. == "matched")) and + ([.endpoints.npm, .endpoints.python, .endpoints.go, + .endpoints.rust_index, .endpoints.rust_download] | all(type == "string"))' \ + "$RUNNER_TEMP/private-mirror/private-mirror.json" for package in \ @operatorstack/yield \ @operatorstack/create-yield \ @@ -131,7 +146,6 @@ jobs: --source-sha "$SOURCE_SHA" \ --attempts 3 \ --delay-ms 10000 - node evals/scripts/run.mjs --check test "$(git rev-list -n 1 "$TAG")" = "$SOURCE_SHA" assets="$RUNNER_TEMP/release-assets" mkdir -p "$assets" diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index cea9d54..75644f7 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -143,7 +143,7 @@ jobs: - run: go test ./... examples: - name: Example workflows and evaluations + name: Example workflows runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -166,29 +166,11 @@ jobs: examples/data-migration examples/library/rust - run: npm ci --ignore-scripts - - name: Rerun first-party evaluations - working-directory: evals - run: | - npm ci - npm test - - name: Validate semantic conversion receipt when required - working-directory: evals - env: - EVAL_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} - EVAL_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - run: npm run test:conversion - name: Run every example fixture run: | base_tag="$(git tag --merged HEAD --list 'v[0-9]*' --sort=-v:refname | head -n1)" test -n "$base_tag" - pending_changesets="$(git diff --name-only --diff-filter=A "$base_tag"..HEAD -- '.changeset/*.md')" - if [[ -n "$pending_changesets" ]]; then - plan="$RUNNER_TEMP/example-release-plan.env" - node scripts/release-plan.mjs --bump auto --output "$plan" --notes "$RUNNER_TEMP/example-release-notes.md" - . "$plan" - else - version="${base_tag#v}" - fi + version="${base_tag#v}" go build -ldflags "-X main.version=$version" -o "$RUNNER_TEMP/yskill" ./cmd/yskill "$RUNNER_TEMP/yskill" test examples/investigate "$RUNNER_TEMP/yskill" test examples/release-checklist diff --git a/README.md b/README.md index 38202b4..c60b33c 100644 --- a/README.md +++ b/README.md @@ -192,13 +192,13 @@ Registration is the discovery step. This command detects installed verified agents and writes a small adapter for each one: ```bash -npm exec -- yskill register skills/release +npm exec -- yskill register skills/release --root . ``` Select verified agents explicitly when you do not want automatic detection: ```bash -npm exec -- yskill register skills/release \ +npm exec -- yskill register skills/release --root . \ --agent cursor,codex,claude-code ``` @@ -239,8 +239,8 @@ helper: | Language | Command | | ---------- | ------------------------------------------------------------------------------------------------------------------- | -| TypeScript | `npm exec -- yskill helper install --language typescript` | -| Python | `python -m yieldskill helper install --language python` | +| TypeScript | `npm exec -- yskill helper install --root . --language typescript` | +| Python | `python -m yieldskill helper install --root . --language python` | | Rust | `cargo install yieldskill --root .yield --locked`, then `.yield/bin/yskill helper install --root . --language rust` | | Go | `go run github.com/operatorstack/yield/cmd/yskill@latest helper install --root . --language go` | diff --git a/cmd/yskill/agents.go b/cmd/yskill/agents.go index 4e7576c..3cc8dba 100644 --- a/cmd/yskill/agents.go +++ b/cmd/yskill/agents.go @@ -205,7 +205,7 @@ func cmdRegisterAll(args []string) error { if launcherErr != nil { return launcherErr } - content := renderAdapter(metadata, sourceRel, digest, launcher) + content := renderAdapter(metadata, sourceRel, digest, launcher, manifest.Language) for _, agent := range selectedAgents { path := filepath.Join(repoRoot, filepath.FromSlash(agent.ProjectDir), metadata.Name, "SKILL.md") plan := plansByPath[path] @@ -345,7 +345,7 @@ func registerSkill(skillArg, rootArg string, requested []string) ([]registration return nil, err } } - content := renderAdapter(metadata, sourceRel, digest, launcher) + content := renderAdapter(metadata, sourceRel, digest, launcher, manifest.Language) byDestination := map[string][]string{} for _, agent := range selected { destination := filepath.Join(repoRoot, filepath.FromSlash(agent.ProjectDir), metadata.Name, "SKILL.md") @@ -687,12 +687,16 @@ func verifyLocalRuntime(path, expected, language string) error { } func localRuntimeInstallCommand(language, expected string) string { + return localRuntimeInstallCommandFor(language, expected, runtime.GOOS) +} + +func localRuntimeInstallCommandFor(language, expected, goos string) string { switch language { case "go": - if runtime.GOOS == "windows" { - return fmt.Sprintf(`New-Item -ItemType Directory -Force .yield\bin | Out-Null; $env:GOBIN="$PWD\.yield\bin"; $env:GOPROXY="https://get.operatorstack.systems/go,direct"; go install github.com/operatorstack/yield/cmd/yskill@v%s`, expected) + if goos == "windows" { + return fmt.Sprintf(`New-Item -ItemType Directory -Force .yield\bin | Out-Null; $env:GOBIN="$PWD\.yield\bin"; $env:GOPROXY="https://proxy.golang.org,direct"; go install github.com/operatorstack/yield/cmd/yskill@v%s`, expected) } - return fmt.Sprintf(`mkdir -p .yield/bin && GOBIN="$PWD/.yield/bin" GOPROXY=https://get.operatorstack.systems/go,direct go install github.com/operatorstack/yield/cmd/yskill@v%s`, expected) + return fmt.Sprintf(`mkdir -p .yield/bin && GOBIN="$PWD/.yield/bin" GOPROXY=https://proxy.golang.org,direct go install github.com/operatorstack/yield/cmd/yskill@v%s`, expected) case "rust": return fmt.Sprintf(`cargo install yieldskill@%s --root .yield --locked`, expected) default: @@ -799,8 +803,52 @@ func shellQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" } -func renderAdapter(metadata skillMetadata, sourceRel, digest, launcher string) string { +func renderAdapter(metadata skillMetadata, sourceRel, digest, launcher, language string) string { path := shellQuote(sourceRel) + if language == "go" || language == "rust" { + posixInstall := localRuntimeInstallCommandFor(language, runtimeVersion(), "linux") + windowsInstall := localRuntimeInstallCommandFor(language, runtimeVersion(), "windows") + return fmt.Sprintf(`--- +name: %s +description: %s +--- + +%s%s; digest: %s; version: %s --> + +This adapter exposes the canonical Yield workflow at %s. +Read its SKILL.md, then run from the repository root. + +On macOS or Linux, if .yield/bin/yskill is missing, run: + + %s + +Then start or resume the workflow with .yield/bin/yskill. + +On Windows PowerShell, if .yield\bin\yskill.exe is missing, run: + + %s + +Then start or resume the workflow with .\.yield\bin\yskill.exe. + +Start the workflow: + + .yield/bin/yskill run %s + +On Windows PowerShell use: + + .\.yield\bin\yskill.exe run %s + +If installation was required, retry the matching run command above. +Follow each returned operation exactly. Answer each operation directly with +the same launcher: + + respond --value --skill %s + +For structured agent results, use --result-json instead of --value. + +Do not skip an operation or invent its response. +`, metadata.Name, yamlString(metadata.Description), generatedAdapterPrefix, sourceRel, digest, runtimeVersion(), "`"+sourceRel+"`", posixInstall, windowsInstall, path, path, path) + } return fmt.Sprintf(`--- name: %s description: %s diff --git a/cmd/yskill/agents_test.go b/cmd/yskill/agents_test.go index 5ffa3b0..3a494dd 100644 --- a/cmd/yskill/agents_test.go +++ b/cmd/yskill/agents_test.go @@ -414,6 +414,45 @@ func TestRepositoryRuntimeRejectsMissingAndWrongVersions(t *testing.T) { } } +func TestLocalRuntimeRecoveryUsesPublicRegistries(t *testing.T) { + goUnix := localRuntimeInstallCommandFor("go", "0.5.1", "linux") + goWindows := localRuntimeInstallCommandFor("go", "0.5.1", "windows") + for _, command := range []string{goUnix, goWindows} { + if !strings.Contains(command, "https://proxy.golang.org,direct") || !strings.Contains(command, "@v0.5.1") { + t.Fatalf("Go recovery is not public and exact: %s", command) + } + if strings.Contains(command, "get.operatorstack.systems") { + t.Fatalf("Go recovery still uses the private mirror: %s", command) + } + } + if got := localRuntimeInstallCommandFor("rust", "0.5.1", "windows"); got != "cargo install yieldskill@0.5.1 --root .yield --locked" { + t.Fatalf("Rust recovery = %q", got) + } +} + +func TestGeneratedLocalRuntimeAdapterIsPortableAndActionable(t *testing.T) { + previousVersion := version + version = "0.5.1" + t.Cleanup(func() { version = previousVersion }) + adapter := renderAdapter( + skillMetadata{Name: "safe-change", Description: "Check a safe change."}, + "skills/safe-change", + "sha256:test", + ".yield/bin/yskill", + "rust", + ) + for _, want := range []string{ + "cargo install yieldskill@0.5.1 --root .yield --locked", + ".yield/bin/yskill run 'skills/safe-change'", + `.\.yield\bin\yskill.exe run 'skills/safe-change'`, + "If installation was required, retry", + } { + if !strings.Contains(adapter, want) { + t.Fatalf("adapter missing %q:\n%s", want, adapter) + } + } +} + func TestLocalStateIgnoreFileCoversRuntimeAndRuns(t *testing.T) { repo := t.TempDir() if err := ensureLocalStateIgnored(repo); err != nil { diff --git a/cmd/yskill/main_test.go b/cmd/yskill/main_test.go index 5c9750b..d026910 100644 --- a/cmd/yskill/main_test.go +++ b/cmd/yskill/main_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "errors" "flag" "io" "os" @@ -18,6 +19,16 @@ import ( "github.com/operatorstack/yield/internal/runlog" ) +func stubRustLockfile(t *testing.T) { + t.Helper() + previous := generateRustLockfile + generateRustLockfile = func(dir string) error { + writeTestFile(t, filepath.Join(dir, "Cargo.lock"), "version = 4\n") + return nil + } + t.Cleanup(func() { generateRustLockfile = previous }) +} + func TestPrintProgressKeepsCompleteStructuredResult(t *testing.T) { result := `{"summary":"` + strings.Repeat("x", 300) + `","count":42}` read, write, err := os.Pipe() @@ -168,6 +179,7 @@ func TestParseOnePositionalKeepsFlagFirstOrder(t *testing.T) { } func TestScaffoldSkillWritesLanguageSpecificEntrypoints(t *testing.T) { + stubRustLockfile(t) previousVersion := version previousTidyGoModule := tidyGoModule version = "0.1.9" @@ -191,7 +203,7 @@ func TestScaffoldSkillWritesLanguageSpecificEntrypoints(t *testing.T) { {"typescript", []string{"main.ts", "package.json", "skill.json"}, "npm exec -- yskill run .", `"@operatorstack/yield": "0.1.9"`, ""}, {"python", []string{"main.py", "requirements.txt", "skill.json"}, "python -m yieldskill run .", "yieldskill==0.1.9", ""}, {"go", []string{"main.go", "go.mod", "skill.json"}, "yskill run .", "github.com/operatorstack/yield v0.1.9", ""}, - {"rust", []string{"src/main.rs", "Cargo.toml", "skill.json", ".gitignore"}, "yskill run .", `version = "=0.1.9"`, rustSkillGitignore}, + {"rust", []string{"src/main.rs", "Cargo.toml", "Cargo.lock", "skill.json", ".gitignore"}, "yskill run .", `version = "=0.1.9"`, rustSkillGitignore}, } for _, tt := range tests { t.Run(tt.language, func(t *testing.T) { @@ -263,6 +275,7 @@ func TestScaffoldSkillWritesLanguageSpecificEntrypoints(t *testing.T) { } func TestRustScaffoldPreservesExistingGitignore(t *testing.T) { + stubRustLockfile(t) dir := filepath.Join(t.TempDir(), "safe-change") if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) @@ -341,6 +354,7 @@ func TestShellQuoteForPlatform(t *testing.T) { } func TestRustScaffoldNamesPrimaryBinary(t *testing.T) { + stubRustLockfile(t) previousVersion := version version = "0.1.28" t.Cleanup(func() { version = previousVersion }) @@ -358,6 +372,7 @@ func TestRustScaffoldRunsPrimaryBinaryWhenFixtureAddsAnotherBinary(t *testing.T) if _, err := exec.LookPath("cargo"); err != nil { t.Skip("cargo is not installed") } + stubRustLockfile(t) dir := filepath.Join(t.TempDir(), "safe-change") if err := scaffoldSkill(dir, "rust", "", "Check a safe change before applying it."); err != nil { t.Fatal(err) @@ -400,6 +415,7 @@ func TestGoScaffoldCanResolveItsPinnedModuleOnFirstRun(t *testing.T) { } func TestLocalGoAndRustScaffoldsKeepTheInvokedRuntime(t *testing.T) { + stubRustLockfile(t) previousVersion := version previousTidyGoModule := tidyGoModule previousExecutable := currentExecutable @@ -435,6 +451,7 @@ func TestLocalGoAndRustScaffoldsKeepTheInvokedRuntime(t *testing.T) { } func TestRustScaffoldPinsTheInvokedRuntimeWithoutPrivateRegistryConfig(t *testing.T) { + stubRustLockfile(t) previousVersion := version previousExecutable := currentExecutable previousInspect := inspectRuntimeVersion @@ -515,11 +532,32 @@ func TestCmdInitRustScaffoldIsDoctorValid(t *testing.T) { if err := cmdInit([]string{"--language", "rust", "--description", "Check a safe change before applying it.", dir}); err != nil { t.Fatal(err) } + if _, err := os.Stat(filepath.Join(dir, "Cargo.lock")); err != nil { + t.Fatalf("Rust scaffold lockfile: %v", err) + } if err := cmdDoctor([]string{dir, "--root", root}); err != nil { t.Fatalf("doctor rejected Rust scaffold: %v", err) } } +func TestRustScaffoldPreservesExistingCargoLock(t *testing.T) { + previous := generateRustLockfile + generateRustLockfile = func(string) error { + return errors.New("lockfile generation must not run") + } + t.Cleanup(func() { generateRustLockfile = previous }) + + dir := filepath.Join(t.TempDir(), "safe-change") + const existing = "user-owned-lockfile\n" + writeTestFile(t, filepath.Join(dir, "Cargo.lock"), existing) + if err := scaffoldSkill(dir, "rust", "", "Check a safe change before applying it."); err != nil { + t.Fatal(err) + } + if got := readTestFile(t, filepath.Join(dir, "Cargo.lock")); got != existing { + t.Fatalf("existing Cargo.lock changed: %q", got) + } +} + func TestPythonScaffoldUsesRelocatableInterpreter(t *testing.T) { previousVersion := version version = "0.1.9" diff --git a/cmd/yskill/scaffold.go b/cmd/yskill/scaffold.go index 3977323..b38c3b8 100644 --- a/cmd/yskill/scaffold.go +++ b/cmd/yskill/scaffold.go @@ -26,6 +26,16 @@ var tidyGoModule = func(dir string) error { return nil } +var generateRustLockfile = func(dir string) error { + cmd := exec.Command("cargo", "generate-lockfile", "--manifest-path", filepath.Join(dir, "Cargo.toml")) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("prepare Rust scaffold dependencies: %w", err) + } + return nil +} + func defaultLanguage() string { if language := strings.TrimSpace(os.Getenv("YIELD_LANGUAGE")); language != "" { return language @@ -146,6 +156,16 @@ func scaffoldSkill(dir, language, sdkPath, description string) error { return err } } + if language == "rust" { + lockfile := filepath.Join(dir, "Cargo.lock") + if _, err := os.Stat(lockfile); os.IsNotExist(err) { + if err := generateRustLockfile(dir); err != nil { + return err + } + } else if err != nil { + return err + } + } if _, err := readSkillMetadata(dir); err != nil { return fmt.Errorf("validate SKILL.md: %w", err) } diff --git a/docs/agent-setup.md b/docs/agent-setup.md index 010852d..b399ebe 100644 --- a/docs/agent-setup.md +++ b/docs/agent-setup.md @@ -76,10 +76,10 @@ manual workflow, install the guided helper explicitly from the repository root: ```bash # TypeScript -npm exec -- yskill helper install --language typescript +npm exec -- yskill helper install --root . --language typescript # Python -python -m yieldskill helper install --language python +python -m yieldskill helper install --root . --language python # Rust cargo install yieldskill --root .yield --locked diff --git a/docs/convert-existing-skill.md b/docs/convert-existing-skill.md index 5409c69..17d6af4 100644 --- a/docs/convert-existing-skill.md +++ b/docs/convert-existing-skill.md @@ -9,7 +9,7 @@ Package installation does not add the helper. Install it explicitly for the project language. For example: ```bash -npm exec -- yskill helper install --language typescript +npm exec -- yskill helper install --root . --language typescript ``` See the [quickstart](quickstart.md) for Python, Rust, and Go commands. diff --git a/docs/formatting.md b/docs/formatting.md index 06fe456..684b772 100644 --- a/docs/formatting.md +++ b/docs/formatting.md @@ -27,5 +27,5 @@ The command pins third-party formatter versions. Go and Rust use the repository toolchain versions. GitHub Actions runs the check and does not rewrite files. The formatter skips generated files. It also skips evaluation sources whose -exact bytes belong to a committed receipt. Run the relevant generator or -evaluation when you change those sources. +exact bytes belong to a committed receipt. Evaluation receipts remain frozen +until an evaluation is intentionally run and reviewed. diff --git a/docs/quickstart.md b/docs/quickstart.md index fa125e0..d706f4b 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -49,8 +49,8 @@ This runs the fixture to a terminal outcome without leaving a run journal. ## 4. Register it ```bash -npm exec -- yskill register skills/release -npm exec -- yskill doctor skills/release --agent codex,cursor,claude-code --test +npm exec -- yskill register skills/release --root . +npm exec -- yskill doctor skills/release --root . --agent codex,cursor,claude-code --test ``` Registration creates only small discovery adapters. The canonical workflow, @@ -71,10 +71,10 @@ After learning the manual flow, install guided assistance explicitly: ```bash # TypeScript -npm exec -- yskill helper install --language typescript +npm exec -- yskill helper install --root . --language typescript # Python -python -m yieldskill helper install --language python +python -m yieldskill helper install --root . --language python # Rust .yield/bin/yskill helper install --root . --language rust diff --git a/evals/README.md b/evals/README.md index 76ea90b..a3b542c 100644 --- a/evals/README.md +++ b/evals/README.md @@ -25,7 +25,7 @@ cd evals npm run eval ``` -Check that the published result still matches the current source: +Check a deliberately refreshed result against the current source: ```bash npm test @@ -49,9 +49,10 @@ judgment is correct, or that illustrative commands are production-safe. The fixed test data supplies agent and human responses so the suite can test only the code-controlled workflow layer. -`results/latest.json` is a compact, website-safe result. Its source hash is -computed from the CLI, engine, protocol, SDKs, example workflows, fixtures, and -evaluation harness. CI reruns the suite instead of trusting that file alone. +`results/latest.json` is a compact, website-safe result pinned to the Yield +version that was explicitly evaluated. CI does not rerun evaluations or require +their source hashes to follow ordinary product changes. Run the evaluation +manually when new evidence is needed, then review and commit its receipt. ## Coding-agent workflow check diff --git a/packaging/private-mirror.mjs b/packaging/private-mirror.mjs new file mode 100644 index 0000000..aa84bc8 --- /dev/null +++ b/packaging/private-mirror.mjs @@ -0,0 +1,326 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto" +import { copyFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises" +import { basename, join, resolve } from "node:path" +import process from "node:process" +import { pathToFileURL } from "node:url" +import { crateNames, indexPath, registryRecord } from "./crates-release.mjs" +import { inspectLocalRelease } from "./pypi-release.mjs" +import { targets } from "./targets.mjs" + +const stableVersion = /^\d+\.\d+\.\d+$/ +const fullSHA = /^[0-9a-f]{40}$/ +const defaultBase = "https://get.operatorstack.systems" + +function expect(condition, message) { + if (!condition) throw new Error(message) +} + +async function sha256(path) { + return createHash("sha256") + .update(await readFile(path)) + .digest("hex") +} + +async function responseSHA256(response) { + expect(response.ok, `download returned HTTP ${response.status}`) + return createHash("sha256") + .update(Buffer.from(await response.arrayBuffer())) + .digest("hex") +} + +export function classifyCollection(expected, remote, label) { + const expectedByName = new Map(expected.map((item) => [item.name, item.sha256])) + expect(expectedByName.size === expected.length, `${label}: expected identities are not unique`) + const remoteByName = new Map() + for (const item of remote) { + expect(!remoteByName.has(item.name), `${label}: duplicate remote artifact ${item.name}`) + expect(expectedByName.has(item.name), `${label}: unexpected remote artifact ${item.name}`) + expect( + expectedByName.get(item.name) === item.sha256, + `${label}: checksum drift for ${item.name}`, + ) + remoteByName.set(item.name, item.sha256) + } + if (remoteByName.size === 0) return "missing" + return remoteByName.size === expectedByName.size ? "matched" : "partial" +} + +function missingArtifacts(expected, remote) { + const present = new Set(remote.map((item) => item.name)) + return expected.filter((item) => !present.has(item.name)) +} + +export function validateMirrorIdentity({ version, sourceSHA, npmRelease }) { + expect(stableVersion.test(version), "version must be stable semver") + expect(fullSHA.test(sourceSHA), "source SHA must be a full commit SHA") + expect(npmRelease?.schema_version === 1, "npm release schema must be 1") + expect(npmRelease.version === version, "npm release version does not match") + expect(npmRelease.source_sha === sourceSHA, "npm release source SHA does not match") +} + +async function localCrates(directory, version) { + const names = new Set(await readdir(directory)) + const crates = [] + for (const name of crateNames) { + const file = `${name}-${version}.crate` + expect(names.has(file), `missing crate archive ${file}`) + crates.push({ name, file, sha256: await sha256(join(directory, file)) }) + } + expect( + [...names].filter((name) => name.endsWith(".crate")).length === crateNames.length, + "crate archive directory contains unexpected files", + ) + return crates +} + +export async function inspectMirrorUnit({ version, sourceSHA, releaseUnit, crates }) { + const npmRoot = join(releaseUnit, "npm") + const npmRelease = JSON.parse(await readFile(join(npmRoot, "npm-release.json"), "utf8")) + validateMirrorIdentity({ version, sourceSHA, npmRelease }) + expect(npmRelease.archives.length === 8, "private mirror requires eight npm archives") + const npm = [] + for (const archive of npmRelease.archives) { + expect(archive.version === version, `${archive.name}: npm version does not match`) + const digest = await sha256(join(npmRoot, archive.file)) + expect(digest === archive.sha256, `${archive.name}: npm archive checksum drift`) + npm.push({ name: archive.name, file: archive.file, sha256: digest }) + } + const python = (await inspectLocalRelease(join(releaseUnit, "pypi"), version)).map((file) => ({ + name: file.filename, + file: file.filename, + sha256: file.sha256, + })) + const rust = await localCrates(crates, version) + return { + schema_version: 1, + version, + source_sha: sourceSHA, + targets: targets.map((target) => target.id), + npm, + python, + rust, + go: { module: "github.com/operatorstack/yield", version: `v${version}` }, + } +} + +async function remoteNPM(manifest, base, fetchImpl) { + const remote = [] + for (const expected of manifest.npm) { + const path = expected.name.replace("/", "%2F") + const response = await fetchImpl(`${base}/npm/${path}`, { cache: "no-store" }) + if (response.status === 404) continue + expect(response.ok, `${expected.name}: private npm returned HTTP ${response.status}`) + const packument = await response.json() + const record = packument.versions?.[manifest.version] + if (!record) continue + expect(record.dist?.tarball, `${expected.name}: private npm tarball URL is missing`) + remote.push({ + name: expected.name, + sha256: await responseSHA256(await fetchImpl(record.dist.tarball)), + }) + } + return remote +} + +async function remotePython(manifest, base, fetchImpl) { + const response = await fetchImpl(`${base}/pip/simple/yieldskill/`, { cache: "no-store" }) + if (response.status === 404) return [] + expect(response.ok, `private Python index returned HTTP ${response.status}`) + const expected = new Set(manifest.python.map((file) => file.name)) + const remote = [] + for (const match of (await response.text()).matchAll(/href=["']([^"']+)["']/gi)) { + const url = new URL(match[1], `${base}/pip/simple/yieldskill/`) + const name = decodeURIComponent(basename(url.pathname)) + if (!expected.has(name)) continue + const digest = url.hash.match(/^#sha256=([0-9a-f]{64})$/)?.[1] + remote.push({ name, sha256: digest ?? (await responseSHA256(await fetchImpl(url))) }) + } + return remote +} + +async function remoteRust(manifest, base, fetchImpl) { + const remote = [] + for (const expected of manifest.rust) { + const response = await fetchImpl(`${base}/cargo/index/${indexPath(expected.name)}`, { + headers: { "User-Agent": "operatorstack-yield-private-mirror/1" }, + cache: "no-store", + }) + if (response.status === 404) continue + expect(response.ok, `${expected.name}: private Cargo index returned HTTP ${response.status}`) + const record = (await response.text()) + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)) + .find((item) => item.vers === manifest.version) + if (!record) continue + expect(record.cksum === expected.sha256, `${expected.name}: private Cargo index checksum drift`) + const download = `${base}/cargo/crates/${expected.name}/${manifest.version}/download` + const archive = await fetchImpl(download) + if (archive.status === 404) continue + expect(archive.ok, `${expected.name}: private Cargo download returned HTTP ${archive.status}`) + remote.push({ name: expected.name, sha256: await responseSHA256(archive) }) + } + return remote +} + +async function remoteGo(manifest, base, fetchImpl) { + const privateZip = `${base}/go/github.com/operatorstack/yield/@v/v${manifest.version}.zip` + const response = await fetchImpl(privateZip, { cache: "no-store" }) + if (response.status === 404) return [] + expect(response.ok, `private Go proxy returned HTTP ${response.status}`) + const privateDigest = await responseSHA256(response) + const publicResponse = await fetchImpl( + `https://proxy.golang.org/github.com/operatorstack/yield/@v/v${manifest.version}.zip`, + { cache: "no-store" }, + ) + const publicDigest = await responseSHA256(publicResponse) + expect(privateDigest === publicDigest, "private Go module zip differs from the public module") + return [{ name: manifest.go.module, sha256: privateDigest }] +} + +export async function inspectRemote(manifest, { base = defaultBase, fetchImpl = fetch } = {}) { + const [npm, python, rust, go] = await Promise.all([ + remoteNPM(manifest, base, fetchImpl), + remotePython(manifest, base, fetchImpl), + remoteRust(manifest, base, fetchImpl), + remoteGo(manifest, base, fetchImpl), + ]) + const goExpected = go.length ? go : [] + return { + states: { + npm: classifyCollection(manifest.npm, npm, "npm"), + python: classifyCollection(manifest.python, python, "Python"), + rust: classifyCollection(manifest.rust, rust, "Cargo"), + go: goExpected.length === 0 ? "missing" : "matched", + }, + remote: { npm, python, rust, go }, + missing: { + npm: missingArtifacts(manifest.npm, npm), + python: missingArtifacts(manifest.python, python), + rust: missingArtifacts(manifest.rust, rust), + go: go.length ? [] : [manifest.go], + }, + } +} + +export async function prepareCargoMirror( + manifest, + { crates, output, names = crateNames, fetchImpl = fetch }, +) { + await mkdir(output, { recursive: true }) + const selected = new Set(names) + for (const item of manifest.rust.filter((candidate) => selected.has(candidate.name))) { + const record = await registryRecord(item.name, manifest.version, fetchImpl) + expect(record, `${item.name}@${manifest.version}: crates.io index record is missing`) + expect( + record.cksum === item.sha256, + `${item.name}: crates.io checksum differs from release unit`, + ) + await copyFile(join(crates, item.file), join(output, item.file)) + await writeFile(join(output, `${item.name}-index.json`), `${JSON.stringify(record)}\n`) + } +} + +function receipt(manifest, remote, base) { + return { + schema_version: 1, + version: manifest.version, + source_sha: manifest.source_sha, + registry: base, + targets: manifest.targets, + endpoints: { + npm: `${base}/npm/`, + python: `${base}/pip/simple/yieldskill/`, + go: `${base}/go/${manifest.go.module}/@v/v${manifest.version}.zip`, + rust_index: `${base}/cargo/index/`, + rust_download: `${base}/cargo/crates/{crate}/{version}/download`, + }, + packages: { + npm: remote.remote.npm, + python: remote.remote.python, + go: remote.remote.go, + rust: remote.remote.rust, + }, + states: remote.states, + } +} + +function parseArgs(argv) { + const [command, ...rest] = argv + const values = {} + for (let index = 0; index < rest.length; index += 2) { + const key = rest[index] + expect(key?.startsWith("--") && rest[index + 1] !== undefined, `invalid argument ${key ?? ""}`) + values[key.slice(2)] = rest[index + 1] + } + expect(["inspect", "status", "prepare-cargo", "verify"].includes(command), "unknown command") + return { command, ...values } +} + +async function appendOutput(path, states) { + if (!path) return + await writeFile( + path, + Object.entries(states) + .map(([key, value]) => `${key}_state=${value}\n`) + .join(""), + { + flag: "a", + }, + ) +} + +async function main() { + const options = parseArgs(process.argv.slice(2)) + if (options.command === "inspect") { + expect( + options.version && options["source-sha"] && options["release-unit"] && options.crates, + "inspect inputs are required", + ) + const manifest = await inspectMirrorUnit({ + version: options.version, + sourceSHA: options["source-sha"], + releaseUnit: resolve(options["release-unit"]), + crates: resolve(options.crates), + }) + expect(options.manifest, "--manifest is required") + await writeFile(options.manifest, `${JSON.stringify(manifest, null, 2)}\n`) + return + } + expect(options.manifest, "--manifest is required") + const manifest = JSON.parse(await readFile(options.manifest, "utf8")) + if (options.command === "prepare-cargo") { + expect(options.crates && options.output, "prepare-cargo requires --crates and --output") + const status = options.status ? JSON.parse(await readFile(options.status, "utf8")) : null + await prepareCargoMirror(manifest, { + crates: resolve(options.crates), + output: resolve(options.output), + names: status?.missing?.rust?.map((item) => item.name) ?? crateNames, + }) + return + } + const remote = await inspectRemote(manifest, { base: options.base ?? defaultBase }) + if (options.command === "status") { + await appendOutput(options.output, remote.states) + if (options.status) await writeFile(options.status, `${JSON.stringify(remote, null, 2)}\n`) + process.stdout.write(`${JSON.stringify(remote.states, null, 2)}\n`) + return + } + expect( + Object.values(remote.states).every((state) => state === "matched"), + "private mirror is incomplete", + ) + expect(options.receipt, "verify requires --receipt") + await writeFile( + options.receipt, + `${JSON.stringify(receipt(manifest, remote, options.base ?? defaultBase), null, 2)}\n`, + ) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + main().catch((error) => { + console.error(`private-mirror: ${error.message}`) + process.exit(1) + }) +} diff --git a/packaging/private-mirror.test.mjs b/packaging/private-mirror.test.mjs new file mode 100644 index 0000000..aa3ed00 --- /dev/null +++ b/packaging/private-mirror.test.mjs @@ -0,0 +1,61 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { classifyCollection, inspectRemote, validateMirrorIdentity } from "./private-mirror.mjs" + +const expected = [ + { name: "one", sha256: "a".repeat(64) }, + { name: "two", sha256: "b".repeat(64) }, +] + +test("classifies a wholly missing or byte-identical private release", () => { + assert.equal(classifyCollection(expected, [], "fixture"), "missing") + assert.equal(classifyCollection(expected, expected, "fixture"), "matched") +}) + +test("reports partial releases and refuses checksum drift", () => { + assert.equal(classifyCollection(expected, expected.slice(0, 1), "fixture"), "partial") + assert.throws( + () => classifyCollection(expected, [{ name: "one", sha256: "c".repeat(64) }], "fixture"), + /checksum drift/, + ) +}) + +test("binds the mirror to one stable version and source revision", () => { + const input = { + version: "0.5.1", + sourceSHA: "1".repeat(40), + npmRelease: { schema_version: 1, version: "0.5.1", source_sha: "1".repeat(40) }, + } + assert.doesNotThrow(() => validateMirrorIdentity(input)) + assert.throws( + () => validateMirrorIdentity({ ...input, version: "0.5.0" }), + /version does not match/, + ) + assert.throws( + () => validateMirrorIdentity({ ...input, sourceSHA: "2".repeat(40) }), + /source SHA does not match/, + ) +}) + +test("treats a missing private crate payload as recoverable", async () => { + const checksum = "a".repeat(64) + const manifest = { + version: "0.5.1", + npm: [], + python: [], + go: { module: "github.com/operatorstack/yield", version: "v0.5.1" }, + rust: [{ name: "yieldskill", sha256: checksum }], + } + const fetchImpl = async (url) => { + if (String(url).includes("/cargo/index/")) { + return new Response( + `${JSON.stringify({ name: "yieldskill", vers: "0.5.1", cksum: checksum })}\n`, + ) + } + return new Response("missing", { status: 404 }) + } + + const result = await inspectRemote(manifest, { base: "https://mirror.test", fetchImpl }) + assert.equal(result.states.rust, "missing") + assert.deepEqual(result.missing.rust, manifest.rust) +}) diff --git a/scripts/check-release-control.mjs b/scripts/check-release-control.mjs index 9aa0979..503bb05 100644 --- a/scripts/check-release-control.mjs +++ b/scripts/check-release-control.mjs @@ -84,6 +84,17 @@ export async function checkReleaseControl(root = resolve(import.meta.dirname, ". verify.jobs?.validate?.name === "Release authority and full validation", "the protected validation context must remain stable", ) + expect( + verify.jobs?.examples?.name === "Example workflows" && + raw["verify.yml"].includes('version="${base_tag#v}"'), + "example verification must stay pinned to the latest released Yield version", + ) + expect( + !raw["verify.yml"].includes("evals/scripts/run.mjs") && + !raw["verify.yml"].includes("test:conversion") && + !raw["verify.yml"].includes("working-directory: evals"), + "automatic verification must not run manual evaluations", + ) const release = workflows["release.yml"] expect(release, "release.yml is required") @@ -152,6 +163,28 @@ export async function checkReleaseControl(root = resolve(import.meta.dirname, ". publisher.jobs?.crates?.environment === "crates-production", "stable crates.io publishing must use the protected crates-production environment", ) + const privateMirror = publisher.jobs?.["private-mirror"] + expect(privateMirror?.environment === "private-production", "private mirroring must be protected") + expect( + privateMirror?.permissions?.contents === "read" && + privateMirror?.permissions?.["id-token"] === "write", + "private mirroring must use read-only source plus WIF", + ) + expect( + ["build", "npm", "pypi", "crates"].every((job) => privateMirror?.needs?.includes(job)), + "private mirroring must follow every immutable public release unit", + ) + expect( + privateMirror?.if === "needs.resolve.outputs.channel == 'stable'", + "private mirroring must exclude canary releases", + ) + expect( + raw["npm-publish.yml"].includes("packaging/private-mirror.mjs verify") && + raw["npm-publish.yml"].includes( + "name: private-mirror-${{ needs.resolve.outputs.version }}-${{ needs.resolve.outputs.source_sha }}", + ), + "private mirroring must emit an exact verified receipt", + ) expect( publisher.jobs?.["selfhost-canary"]?.needs?.includes("npm"), "canary self-hosting must follow successful npm publication", @@ -261,7 +294,8 @@ export async function checkReleaseControl(root = resolve(import.meta.dirname, ". expect( raw["release-finalize.yml"].includes("artifacts?per_page=100") && raw["release-finalize.yml"].includes('grep -Fqx "$package_receipt"') && - raw["release-finalize.yml"].includes('grep -Fqx "$crates_receipt"'), + raw["release-finalize.yml"].includes('grep -Fqx "$crates_receipt"') && + raw["release-finalize.yml"].includes('grep -Fqx "$private_receipt"'), "finalization must select the publisher receipt by its exact source-bound artifact names", ) expect( @@ -288,6 +322,10 @@ export async function checkReleaseControl(root = resolve(import.meta.dirname, ". raw["release-finalize.yml"].includes("package-contract.mjs"), "finalization must create the website package contract before release publication", ) + expect( + !raw["release-finalize.yml"].includes("evals/scripts/run.mjs"), + "release finalization must consume frozen evidence without rerunning evaluations", + ) expect( raw["release-finalize.yml"].indexOf("gh release upload") < raw["release-finalize.yml"].indexOf("--draft=false"), @@ -302,6 +340,12 @@ export async function checkReleaseControl(root = resolve(import.meta.dirname, ". raw["release-finalize.yml"].includes('--name "$crates_receipt"'), "finalization must consume the publisher-produced crates receipt", ) + expect( + raw["release-finalize.yml"].includes( + 'private_receipt="private-mirror-${version}-${SOURCE_SHA}"', + ) && raw["release-finalize.yml"].includes('--name "$private_receipt"'), + "finalization must consume the verified private mirror receipt", + ) expect( raw["release.yml"].includes("gh workflow run npm-publish.yml"), "the release controller must dispatch the trusted-publishing event after tagging", diff --git a/scripts/readme.test.mjs b/scripts/readme.test.mjs index e963e9b..0e3def4 100644 --- a/scripts/readme.test.mjs +++ b/scripts/readme.test.mjs @@ -251,7 +251,7 @@ test("Python README presents a public five-step workflow", async () => { assert.match(readme, /python -m pip install yieldskill/) assert.match(readme, /python -m yieldskill init skills\/env-doctor/) assert.match(readme, /python -m yieldskill doctor skills\/env-doctor --test/) - assert.match(readme, /python -m yieldskill register skills\/env-doctor/) + assert.match(readme, /python -m yieldskill register skills\/env-doctor --root \./) assert.match(readme, /^\/env-doctor$/m) assert.match( readme, @@ -371,8 +371,8 @@ test("README and quickstart use the public documentation and package registries" /\[public documentation\]\(https:\/\/yield\.operatorstack\.systems\/docs\/\)/, ) const helperCommands = [ - "npm exec -- yskill helper install --language typescript", - "python -m yieldskill helper install --language python", + "npm exec -- yskill helper install --root . --language typescript", + "python -m yieldskill helper install --root . --language python", ".yield/bin/yskill helper install --root . --language rust", "go run github.com/operatorstack/yield/cmd/yskill@latest helper install --root . --language go", ] diff --git a/sdk/python/README.md b/sdk/python/README.md index ebd22ea..bdd73bd 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -143,14 +143,14 @@ fixture. A successful test reaches `completed` without leaving a run journal. Registration lets installed coding agents discover the workflow: ```bash -python -m yieldskill register skills/env-doctor +python -m yieldskill register skills/env-doctor --root . ``` Select the verified agents explicitly when you do not want automatic detection: ```bash -python -m yieldskill register skills/env-doctor \ +python -m yieldskill register skills/env-doctor --root . \ --agent cursor,codex,claude-code ``` @@ -229,7 +229,7 @@ Installing `yieldskill` does not create skills or coding-agent adapters. After learning the manual workflow above, install guided assistance explicitly: ```bash -python -m yieldskill helper install --language python +python -m yieldskill helper install --root . --language python ``` Review the plan and restart the coding agent after installation. The helper diff --git a/skills/release-yield/src/release-controller.mjs b/skills/release-yield/src/release-controller.mjs index 12e1bd8..c8cea49 100644 --- a/skills/release-yield/src/release-controller.mjs +++ b/skills/release-yield/src/release-controller.mjs @@ -300,7 +300,7 @@ async function monitorController(values) { async function monitorPublisher(values) { const info = await waitRun( values["run-id"], - new Set(["npm-production", "pypi-production", "crates-production"]), + new Set(["npm-production", "pypi-production", "crates-production", "private-production"]), ) return { run_id: String(info.databaseId), run_url: info.url, environments: info.environments } }