Merge origin/master into feat/quality-loop-cli-20260914

Resolve package.json version in favor of master (1.22.2). Keep the
offline quality-gate scripts. Release/publish workflow stays master's.
This commit is contained in:
Claude
2026-09-17 10:52:49 +02:00
16 changed files with 430 additions and 65 deletions
+11 -19
View File
@@ -67,37 +67,29 @@ jobs:
echo "OUT=$OUT" >> "$GITHUB_ENV"
env:
OUT: ${{ runner.temp }}/release-package
- name: Refuse a version that already exists on the registry
- name: Decide whether to publish or resume a matching registry copy
id: decide
run: node scripts/publish-npm-verify.mjs decide
env:
OUT: ${{ runner.temp }}/release-package
CLI_SHA: ${{ inputs.cli_sha }}
- name: Publish the exact directory once
if: steps.decide.outputs.action == 'publish'
run: |
set -eu
VERSION=$(node -p 'JSON.parse(require("fs").readFileSync(process.env.OUT + "/candidate.json","utf8")).version')
if npm view "@przeprogramowani/10x-cli@$VERSION" version >/dev/null 2>&1; then
echo "::error::@przeprogramowani/10x-cli@$VERSION already published; never republish"; exit 1
fi
- name: Publish the exact directory once
run: |
set -eu
node -e 'require("fs").writeFileSync(process.env.NPM_CONFIG_USERCONFIG, "registry=https://registry.npmjs.org/\n//registry.npmjs.org/:_authToken=" + process.env.NPM_TOKEN + "\n", { mode: 0o600 })'
npm publish . --ignore-scripts --access public
env:
NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/release-npmrc
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Verify actual registry bytes against the pack
run: |
set -eu
node -e '
const fs = require("fs"), crypto = require("crypto");
const c = JSON.parse(fs.readFileSync(process.env.OUT + "/candidate.json", "utf8"));
(async () => {
const meta = await (await fetch(`https://registry.npmjs.org/@przeprogramowani%2f10x-cli/${c.version}`)).json();
const bytes = Buffer.from(await (await fetch(meta.dist.tarball)).arrayBuffer());
const integrity = "sha512-" + crypto.createHash("sha512").update(bytes).digest("base64");
const result = { version: meta.version, gitHead: meta.gitHead, expectedIntegrity: c.integrity, registryIntegrity: meta.dist.integrity, actualIntegrity: integrity, sourceSha: process.env.CLI_SHA };
console.log(JSON.stringify(result));
fs.writeFileSync(process.env.OUT + "/published-result.json", JSON.stringify(result));
if (meta.gitHead !== process.env.CLI_SHA || meta.dist.integrity !== c.integrity || integrity !== c.integrity) throw new Error("Published package differs from the pack; release incomplete, never republish");
})().catch((e) => { console.error(e.message); process.exit(1); });
'
run: node scripts/publish-npm-verify.mjs verify
env:
OUT: ${{ runner.temp }}/release-package
CLI_SHA: ${{ inputs.cli_sha }}
- name: Tag the published source
# The tag may already exist (pushed by hand, or a previous attempt).
+5 -1
View File
@@ -128,6 +128,8 @@ artifacts/rules before applying; repeat a skill filter for a narrow update:
10x_cli auth --status
# If login is needed: 10x_cli auth (email or Circle); see auth commands below.
10x_cli list --course 10xdevs4
10x_cli get m1l1 --type skills --name 10x-idea-check --course 10xdevs4 --tool claude-code --lang pl --dry-run
10x_cli get m1l1 --type skills --name 10x-idea-check --course 10xdevs4 --tool claude-code --lang pl
10x_cli get m1l1 --type skills --name 10x-init --course 10xdevs4 --tool claude-code --lang pl --dry-run
10x_cli get m1l1 --type skills --name 10x-init --course 10xdevs4 --tool claude-code --lang pl
10x_cli get m1l1 --type skills --name 10x-shape --course 10xdevs4 --tool claude-code --lang pl --dry-run
@@ -143,7 +145,9 @@ artifacts/rules before applying; repeat a skill filter for a narrow update:
The guide uses lesson 1's existing 10xCards example and produces
`context/foundation/shape-notes.md`, then `context/foundation/prd.md`.
Read all three installed skill trees; PRD requires the sibling
Lesson setup installs all four skill trees. Use `10x-idea-check` and its references
first when you want to assess an idea; it is optional before init → shape → PRD.
Read the installed entrypoints and their references; PRD requires the sibling
`.claude/skills/10x-shape/references/prd-schema.md`. Preserve existing outputs and
follow the skills' collision choices. `CLAUDE-m1l1` is a separate lesson rule;
see the guide for prerequisite checks without a full-get fallback. `10x-plan`
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@przeprogramowani/10x-cli",
"version": "1.22.1",
"version": "1.22.2",
"description": "Open-source CLI for 10xDevs course content",
"repository": {
"type": "git",
+1
View File
@@ -1,3 +1,4 @@
export class BranchUpdateRequiredError extends Error {}
export function stableVersion(value: unknown): boolean;
export function calculateVersion(input: any): Promise<any>;
export function packageWithVersion(text: string, version: string): string;
+12 -1
View File
@@ -7,6 +7,12 @@ import { Bumper } from "conventional-recommended-bump";
const sha = (s) => typeof s === "string" && /^[a-f0-9]{40}$/.test(s);
export const stableVersion = (s) => typeof s === "string" && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(s);
const git = (cwd, ...args) => execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 8 * 1024 * 1024 }).trim();
export class BranchUpdateRequiredError extends Error {
constructor() {
super("Branch update required: integrate current master before version preparation; no record was prepared.");
this.name = "BranchUpdateRequiredError";
}
}
export function validateBaseline(baseline, { cwd = process.cwd(), master }) {
if (!baseline || !stableVersion(baseline.version) || baseline.tag !== `v${baseline.version}` || !sha(baseline.sha) || baseline.gitHead !== baseline.sha || !sha(master)) throw new Error("Verified published baseline required");
if (git(cwd, "rev-parse", `${baseline.tag}^{commit}`) !== baseline.sha) throw new Error("Published tag changed");
@@ -23,8 +29,13 @@ export function packageFilesChanged(cwd, from, to) {
export async function calculateVersion({ cwd = process.cwd(), head, master, baseline }) {
if (!sha(head)) throw new Error("Exact candidate head required");
validateBaseline(baseline, { cwd, master });
try {
git(cwd, "merge-base", "--is-ancestor", master, head);
} catch (error) {
if (error.status === 1) throw new BranchUpdateRequiredError();
throw error;
}
git(cwd, "merge-base", "--is-ancestor", baseline.sha, head);
git(cwd, "merge-base", "--is-ancestor", master, head);
if (!packageFilesChanged(cwd, baseline.sha, head)) return null;
const reader = new Bumper(cwd).loadPreset("angular").tag(baseline.tag).commits({ from: baseline.sha, to: head }, {});
const initial = await reader.bump();
+14 -4
View File
@@ -3,13 +3,14 @@ import { realpathSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { calculateVersion, packageWithVersion, stableVersion } from "./auto-version.mjs";
import { BranchUpdateRequiredError, calculateVersion, packageWithVersion, stableVersion } from "./auto-version.mjs";
import { CLI_REPOSITORY, fullSha, github, canonicalRun, successfulJobs } from "./release-github.mjs";
import { readReceiptArchive } from "./verify-coordinated-receipt.mjs";
const git = (...args) => execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 8 * 1024 * 1024 }).trim();
export function validatePullRequest(pr, expectedHead, expectedBase) {
if (!pr || pr.state !== "open" || pr.head?.repo?.full_name !== CLI_REPOSITORY || pr.base?.repo?.full_name !== CLI_REPOSITORY || pr.base?.ref !== "master" || pr.head.sha !== expectedHead || pr.base.sha !== expectedBase || !fullSha(expectedHead) || !fullSha(expectedBase) || !Number.isSafeInteger(pr.number) || !/^[a-zA-Z0-9_./-]+$/.test(pr.head.ref) || pr.head.ref === "master") throw new Error("Live exact same-repository PR and base required");
if (!pr || pr.state !== "open" || pr.head?.repo?.full_name !== CLI_REPOSITORY || pr.base?.repo?.full_name !== CLI_REPOSITORY || pr.base?.ref !== "master" || pr.head.sha !== expectedHead || !fullSha(pr.base.sha) || !fullSha(expectedHead) || !fullSha(expectedBase) || !Number.isSafeInteger(pr.number) || !/^[a-zA-Z0-9_./-]+$/.test(pr.head.ref) || pr.head.ref === "master") throw new Error("Live exact same-repository PR and base required");
if (pr.base.sha !== expectedBase) throw new BranchUpdateRequiredError();
return pr;
}
export async function publishedBaseline(get, registry = async () => {
@@ -168,7 +169,16 @@ export async function reconcileVersionEvent({ env, event, bootstrap = false }, {
for (const hint of prs) {
if (hint.head?.repo?.full_name !== CLI_REPOSITORY) continue;
if (!fullSha(hint.head.sha)) throw new Error("Exact head required");
records.push(await prepare(hint));
try {
records.push(await prepare(hint));
} catch (error) {
if (error instanceof BranchUpdateRequiredError) {
console.error(error.message);
records.push(null);
continue;
}
throw error;
}
}
return records;
}
@@ -209,4 +219,4 @@ function isEntrypoint() {
return false;
}
}
if (isEntrypoint()) main().catch(() => { console.error("Trusted version preparation rejected; inspect exact head/base/baseline metadata."); process.exitCode = 1; });
if (isEntrypoint()) main().catch((error) => { console.error(error instanceof BranchUpdateRequiredError ? error.message : (error instanceof Error && error.message) || "Trusted version preparation rejected; inspect exact head/base/baseline metadata."); process.exitCode = 1; });
+7
View File
@@ -0,0 +1,7 @@
export const DEFAULT_WAIT: { timeoutMs: number; initialDelayMs: number; maxDelayMs: number };
export function metadataIsComplete(meta: any): boolean;
export function fetchVersionMetadata(version: string, fetchFn?: typeof fetch): Promise<{ status: number; body: any }>;
export function waitForPublishedMetadata(version: string, options?: any): Promise<any>;
export function assertPackMatchesRegistry(input: any): any;
export function downloadTarball(metadata: any, fetchFn?: typeof fetch): Promise<Buffer>;
export function classifyPublishDecision(input: any): Promise<any>;
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { readFileSync, writeFileSync, appendFileSync } from "node:fs";
import { realpathSync } from "node:fs";
import { fileURLToPath } from "node:url";
const REGISTRY_VERSION = (version) => `https://registry.npmjs.org/@przeprogramowani%2f10x-cli/${version}`;
export const DEFAULT_WAIT = { timeoutMs: 180_000, initialDelayMs: 1_000, maxDelayMs: 8_000 };
const sha = (s) => typeof s === "string" && /^[a-f0-9]{40}$/.test(s);
const integrityOf = (bytes) => "sha512-" + createHash("sha512").update(bytes).digest("base64");
export function metadataIsComplete(meta) {
return Boolean(meta && meta.version && meta.dist && typeof meta.dist.tarball === "string" && meta.dist.tarball.startsWith("https://") && typeof meta.dist.integrity === "string" && meta.dist.integrity.startsWith("sha512-"));
}
export async function fetchVersionMetadata(version, fetchFn = fetch) {
const response = await fetchFn(REGISTRY_VERSION(version), { signal: AbortSignal.timeout(30000) });
const text = await response.text();
let body = null;
try { body = JSON.parse(text); } catch { body = null; }
return { status: response.status, body };
}
export async function waitForPublishedMetadata(version, { fetchFn = fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = () => Date.now(), log = console.error, timeoutMs = DEFAULT_WAIT.timeoutMs, initialDelayMs = DEFAULT_WAIT.initialDelayMs, maxDelayMs = DEFAULT_WAIT.maxDelayMs } = {}) {
const deadline = now() + timeoutMs;
let delay = initialDelayMs, attempt = 0;
while (true) {
attempt += 1;
const { status, body } = await fetchVersionMetadata(version, fetchFn);
if (status === 200 && metadataIsComplete(body)) {
log(`registry metadata ready for ${version} after ${attempt} attempt(s)`);
return body;
}
const remaining = deadline - now();
if (remaining <= 0) throw new Error(`Registry metadata for ${version} did not become complete within ${timeoutMs}ms after ${attempt} attempt(s); never republish`);
log(`registry wait attempt ${attempt}: status=${status} complete=${metadataIsComplete(body)} next=${Math.min(delay, remaining)}ms`);
await sleep(Math.min(delay, remaining));
delay = Math.min(delay * 2, maxDelayMs);
}
}
export function assertPackMatchesRegistry({ metadata, tarballBytes, expectedIntegrity, sourceSha }) {
if (!sha(sourceSha)) throw new Error("Exact candidate SHA required");
if (!expectedIntegrity || !expectedIntegrity.startsWith("sha512-")) throw new Error("Pack integrity required");
if (!metadataIsComplete(metadata)) throw new Error("Complete registry metadata required");
const actualIntegrity = integrityOf(tarballBytes);
if (metadata.gitHead !== sourceSha || metadata.dist.integrity !== expectedIntegrity || actualIntegrity !== expectedIntegrity || metadata.dist.integrity !== actualIntegrity) {
throw new Error("Published package differs from the pack; release incomplete, never republish");
}
return { version: metadata.version, gitHead: metadata.gitHead, expectedIntegrity, registryIntegrity: metadata.dist.integrity, actualIntegrity, sourceSha };
}
export async function downloadTarball(metadata, fetchFn = fetch) {
const response = await fetchFn(metadata.dist.tarball, { signal: AbortSignal.timeout(60000) });
if (!response.ok) throw new Error("Registry tarball unavailable");
return Buffer.from(await response.arrayBuffer());
}
export async function classifyPublishDecision({ version, expectedIntegrity, sourceSha, fetchFn = fetch, wait }) {
const { status, body } = await fetchVersionMetadata(version, fetchFn);
if (status === 404 || (body && body.error === "Not found")) return { action: "publish" };
const metadata = metadataIsComplete(body) ? body : await waitForPublishedMetadata(version, { fetchFn, ...wait });
const bytes = await downloadTarball(metadata, fetchFn);
const result = assertPackMatchesRegistry({ metadata, tarballBytes: bytes, expectedIntegrity, sourceSha });
return { action: "resume", result };
}
function candidate(out) {
return JSON.parse(readFileSync(`${out}/candidate.json`, "utf8"));
}
function writeGithubOutput(name, value) {
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
}
async function decide() {
const out = process.env.OUT, sourceSha = process.env.CLI_SHA, c = candidate(out);
const decision = await classifyPublishDecision({ version: c.version, expectedIntegrity: c.integrity, sourceSha });
writeGithubOutput("action", decision.action);
if (decision.result) writeFileSync(`${out}/published-result.json`, JSON.stringify(decision.result));
console.log(JSON.stringify({ action: decision.action, version: c.version }));
}
async function verify() {
const out = process.env.OUT, sourceSha = process.env.CLI_SHA, c = candidate(out);
const metadata = await waitForPublishedMetadata(c.version);
const bytes = await downloadTarball(metadata);
const result = assertPackMatchesRegistry({ metadata, tarballBytes: bytes, expectedIntegrity: c.integrity, sourceSha });
writeFileSync(`${out}/published-result.json`, JSON.stringify(result));
console.log(JSON.stringify(result));
}
function isEntrypoint() {
try { return Boolean(process.argv[1]) && realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]); }
catch { return false; }
}
if (isEntrypoint()) {
const command = process.argv[2];
const run = command === "decide" ? decide : command === "verify" ? verify : null;
if (!run) { console.error("publish-npm-verify requires decide or verify"); process.exitCode = 2; }
else run().catch((error) => { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; });
}
+22 -7
View File
@@ -79,10 +79,15 @@ refresh transparently; re-login is needed only when refresh cannot recover them.
The launch exercise follows lesson 1, “Od pomysłu do PRD”, using its existing
10xCards example. `10x-plan` is not part of this launch demonstration.
After checking each name's capability and availability, download three separate
complete selected skill trees. Inspect each dry-run before its corresponding write:
Prepare all four lesson skills: `10x-idea-check`, `10x-init`, `10x-shape` and
`10x-prd`. Idea assessment is optional to run; its files should still be available
when preparing lesson 1. If the user requested only a specific skill, retain that
narrower scope. After checking each name's capability and availability, download
four separate complete selected skill trees. Inspect each dry-run before its corresponding write:
```bash
10x_cli get m1l1 --type skills --name 10x-idea-check --course 10xdevs4 --tool claude-code --lang pl --dry-run
10x_cli get m1l1 --type skills --name 10x-idea-check --course 10xdevs4 --tool claude-code --lang pl
10x_cli get m1l1 --type skills --name 10x-init --course 10xdevs4 --tool claude-code --lang pl --dry-run
10x_cli get m1l1 --type skills --name 10x-init --course 10xdevs4 --tool claude-code --lang pl
10x_cli get m1l1 --type skills --name 10x-shape --course 10xdevs4 --tool claude-code --lang pl --dry-run
@@ -96,6 +101,11 @@ chosen macOS/zsh exercise directory, each of these checks must succeed before us
(stop on any failure; do not infer success from the last check alone):
```bash
test -s .claude/skills/10x-idea-check/SKILL.md
test -s .claude/skills/10x-idea-check/references/examples.md
test -s .claude/skills/10x-idea-check/references/assessment-guide.md
test -s .claude/skills/10x-idea-check/references/10xdevs-4-dates.md
test -s .claude/skills/10x-idea-check/references/10xdevs-4-certification.md
test -s .claude/skills/10x-init/SKILL.md
test -s .claude/skills/10x-shape/SKILL.md
test -s .claude/skills/10x-shape/references/prd-schema.md
@@ -108,14 +118,14 @@ own directory. A standalone PRD tree is insufficient. Read all installed
entrypoints and every reference they require; the paths above are the known
source minimum, not permission to discard extra files from a published bundle.
Also inspect `.claude/.10x-cli-manifest.json`: `lessons.m1l1.skills` must include
all three names, with file hashes in `files.skills`. These are lesson-owned
all four names, with file hashes in `files.skills`. These are lesson-owned
partial downloads, not independent owners. Inspect `.10x-cli.json` for the course
binding; partial downloads do not establish a complete lesson release identity.
CLI 1.21.0 is published with v4 and filtered skill downloads; production m1l1 EN/PL
contains init/shape/prd and their references. These revised helpers are a separate
contains idea-check/init/shape/prd and their references. These revised helpers are a separate
source change, not proof that their course copies have been published.
Verify all three names against the actual selected release. If any name, schema,
Verify all four names against the actual selected release. If any name, schema,
owner or release is missing/mismatched, preserve the precise error and stop the
exercise; never silently substitute a whole lesson, another course or filtered get.
@@ -140,7 +150,11 @@ the learner's actual answers from lesson 1. If those inputs are absent, ask for
them; do not invent product requirements, a replacement task.md or a ready-made
plan. Keep private lesson text out of public fixtures and transcripts.
Do not assume native slash/$ discovery or automatic activation from npm install.
Give the agent explicit local paths and work through these steps separately:
Give the agent explicit local paths. If the learner wants to assess whether an
idea fits their experience, time and course goals, first read
`.claude/skills/10x-idea-check/SKILL.md` and its references and follow that skill.
Do not make assessment a prerequisite when the learner is ready to shape.
Then work through these steps separately:
1. Read `.claude/skills/10x-init/SKILL.md` and follow it in the chosen project.
Inspect the create-if-absent context/changes, context/archive and
@@ -179,7 +193,7 @@ workaround:
```
Normal sync refreshes the full lessons recorded in the manifest, not just the
three selected skills. `--all` broadens scope to unlocked lessons and is not needed
four selected skills. `--all` broadens scope to unlocked lessons and is not needed
for this exercise. Missing managed files should be repaired; local edits should
remain visible as conflicts or preserved files. Read all report outcomes and
resource counts even if exit is 0: skipped conflicts alone are not process errors.
@@ -252,6 +266,7 @@ visible. Doctor exit 78 can coexist with outer JSON `status: "ok"`.
| Locked or unpublished v4 | Inspect module availability/release evidence; do not bypass the gate or fall back to v3. |
| Unsupported name/missing index | Verify exact CLI package and content release; preserve the error for the release owner. |
| Network/API failure | Keep diagnostics, retry the same context when service returns; no config reset. |
| Missing `10x-idea-check` after setup | Older helper journeys selected only init/shape/prd. Check the actual commands, selected profile path and manifest; use the idea-check preview/get above to add its complete tree. If the files already exist, read that exact SKILL.md and check the agent's discovery/reload behavior before reinstalling. An absent slash command alone does not prove missing files. |
| Wrong directory/profile | Recheck cwd and explicit flags; a fresh project may legitimately have no tool directory. |
| Signature/release mismatch | Preserve failure and source identity; do not disable verification or reuse unrelated bytes. |
| Edition/manifest conflict | Preserve binding and manifests for repair; use a separate v4 project rather than deleting them. |
@@ -162,7 +162,7 @@ already managed by CLI should be used/updated through CLI, not overwritten here.
### CLI channel
Use this channel after setup/auth and only with verified skill-filter support and
available v4 content. These helpers and the launch chain `10x-init`, `10x-shape`, `10x-prd` belong to m1l1
available v4 content. These helpers and the lesson skills `10x-idea-check`, `10x-init`, `10x-shape`, `10x-prd` belong to m1l1
and inherit course membership and module availability. Their source membership
does not prove that the content has been published or unlocked.
@@ -201,11 +201,17 @@ place two updaters over the same files. Normal use does not require any takeover
## Download, use, update
The launch example is lesson 1's existing 10xCards: init → shape → PRD.
Prepare lesson 1's four skills, including `10x-idea-check` with its references.
The launch example remains the existing 10xCards: init → shape → PRD.
Idea assessment is optional to run before that chain; install its tree during
lesson setup so the learner can use it. Retain a narrower scope when the user
explicitly requested only a specific skill.
`10x-plan` is not available for the launch demonstration. After capability and
content checks for each name, inspect each preview before its corresponding write:
```bash
10x_cli get m1l1 --type skills --name 10x-idea-check --course 10xdevs4 --tool claude-code --lang pl --dry-run
10x_cli get m1l1 --type skills --name 10x-idea-check --course 10xdevs4 --tool claude-code --lang pl
10x_cli get m1l1 --type skills --name 10x-init --course 10xdevs4 --tool claude-code --lang pl --dry-run
10x_cli get m1l1 --type skills --name 10x-init --course 10xdevs4 --tool claude-code --lang pl
10x_cli get m1l1 --type skills --name 10x-shape --course 10xdevs4 --tool claude-code --lang pl --dry-run
@@ -214,10 +220,15 @@ content checks for each name, inspect each preview before its corresponding writ
10x_cli get m1l1 --type skills --name 10x-prd --course 10xdevs4 --tool claude-code --lang pl
```
Require the complete three trees and inspect each installed entrypoint/reference.
Require the complete four trees and inspect each installed entrypoint/reference.
All checks below must succeed before use; stop on any failure:
```bash
test -s .claude/skills/10x-idea-check/SKILL.md
test -s .claude/skills/10x-idea-check/references/examples.md
test -s .claude/skills/10x-idea-check/references/assessment-guide.md
test -s .claude/skills/10x-idea-check/references/10xdevs-4-dates.md
test -s .claude/skills/10x-idea-check/references/10xdevs-4-certification.md
test -s .claude/skills/10x-init/SKILL.md
test -s .claude/skills/10x-shape/SKILL.md
test -s .claude/skills/10x-shape/references/prd-schema.md
@@ -227,11 +238,11 @@ test -s .claude/skills/10x-prd/../10x-shape/references/prd-schema.md
PRD reads `../10x-shape/references/prd-schema.md` relative to its SKILL.md;
isolated PRD download is insufficient. These are the source minimum: preserve
additional supporting files in the selected release. Inspect all three names in
additional supporting files in the selected release. Inspect all four names in
`lessons.m1l1.skills`, their hashes in `files.skills`, and the project edition
binding. Partial downloads do not establish complete lesson freshness/release identity.
Membership in source is candidate evidence; actual filtered availability, full PL
references and release identity still need verification for all three names.
references and release identity still need verification for all four names.
`CLAUDE-m1l1` is a separate lesson rule and is not included in these filtered gets.
The inspected three skill sources do not require it for the chain. This is not
@@ -240,8 +251,10 @@ require it, inspect an existing rule's provenance, or report the missing
prerequisite and ask the lesson/release owner for a supported route before that
step. Never invent a command, overwrite a rule or fall back to full lesson get.
Have the agent explicitly read each installed SKILL.md and its references in
order: init preserves/scaffolds context directories; shape conducts the actual
If the learner wants to assess their idea before shaping, have the agent read
`.claude/skills/10x-idea-check/SKILL.md` and its references and follow that skill.
A learner ready to shape can skip assessment. Then read the chain's entrypoints
and references in order: init preserves/scaffolds context directories; shape conducts the actual
10xCards discovery with the learner and writes
`context/foundation/shape-notes.md`; after the learner approves those notes, PRD
uses them and the sibling schema to produce `context/foundation/prd.md`.
@@ -252,7 +265,7 @@ work. Stop at PRD, without stack selection or implementation. Download alone is
not use; native slash/$ discovery needs separate agent evidence. Keep private
lesson text out of public fixtures. The guide supplies the detailed agent steps.
Sync below refreshes entire recorded lessons, not only the three skill filters.
Sync below refreshes entire recorded lessons, not only the four skill filters.
Preview may include other skills, prompts, configs and course rules; apply only
when the user accepts that scope. For a narrow update, repeat the selected skill
filter instead. Never use sync to silently bypass a missing lesson-rule prerequisite.
+7 -1
View File
@@ -125,9 +125,15 @@ Auth: valid / login required / unknown; live course access and module state
Setup helper: actual path, public or CLI owner, observed source ref if known
Guide helper: actual path, owner and source ref if known; full reference present
Readiness: verified checks; remaining release/network/access issues, if any
Next task: guide's download → use → sync journey, retaining this context
Next task: guide's lesson setup (idea-check/init/shape/prd) → use → sync journey, or the user's narrower request
```
For lesson 1 setup, carry forward all four skills in the handoff:
`10x-idea-check`, `10x-init`, `10x-shape` and `10x-prd`, with their references.
Idea assessment is optional to run, not a reason to omit its files during lesson
setup. A request for one specific skill remains a narrower download. Verify
materialized files separately from native slash-command discovery.
Once ready, continue in guide without rerunning installation or asking the user
to repeat choices already supplied. Report remaining blockers precisely if the
download cannot yet run; do not present preparation as a completed real journey.
@@ -162,7 +162,7 @@ already managed by CLI should be used/updated through CLI, not overwritten here.
### CLI channel
Use this channel after setup/auth and only with verified skill-filter support and
available v4 content. These helpers and the launch chain `10x-init`, `10x-shape`, `10x-prd` belong to m1l1
available v4 content. These helpers and the lesson skills `10x-idea-check`, `10x-init`, `10x-shape`, `10x-prd` belong to m1l1
and inherit course membership and module availability. Their source membership
does not prove that the content has been published or unlocked.
@@ -201,11 +201,17 @@ place two updaters over the same files. Normal use does not require any takeover
## Download, use, update
The launch example is lesson 1's existing 10xCards: init → shape → PRD.
Prepare lesson 1's four skills, including `10x-idea-check` with its references.
The launch example remains the existing 10xCards: init → shape → PRD.
Idea assessment is optional to run before that chain; install its tree during
lesson setup so the learner can use it. Retain a narrower scope when the user
explicitly requested only a specific skill.
`10x-plan` is not available for the launch demonstration. After capability and
content checks for each name, inspect each preview before its corresponding write:
```bash
10x_cli get m1l1 --type skills --name 10x-idea-check --course 10xdevs4 --tool claude-code --lang pl --dry-run
10x_cli get m1l1 --type skills --name 10x-idea-check --course 10xdevs4 --tool claude-code --lang pl
10x_cli get m1l1 --type skills --name 10x-init --course 10xdevs4 --tool claude-code --lang pl --dry-run
10x_cli get m1l1 --type skills --name 10x-init --course 10xdevs4 --tool claude-code --lang pl
10x_cli get m1l1 --type skills --name 10x-shape --course 10xdevs4 --tool claude-code --lang pl --dry-run
@@ -214,10 +220,15 @@ content checks for each name, inspect each preview before its corresponding writ
10x_cli get m1l1 --type skills --name 10x-prd --course 10xdevs4 --tool claude-code --lang pl
```
Require the complete three trees and inspect each installed entrypoint/reference.
Require the complete four trees and inspect each installed entrypoint/reference.
All checks below must succeed before use; stop on any failure:
```bash
test -s .claude/skills/10x-idea-check/SKILL.md
test -s .claude/skills/10x-idea-check/references/examples.md
test -s .claude/skills/10x-idea-check/references/assessment-guide.md
test -s .claude/skills/10x-idea-check/references/10xdevs-4-dates.md
test -s .claude/skills/10x-idea-check/references/10xdevs-4-certification.md
test -s .claude/skills/10x-init/SKILL.md
test -s .claude/skills/10x-shape/SKILL.md
test -s .claude/skills/10x-shape/references/prd-schema.md
@@ -227,11 +238,11 @@ test -s .claude/skills/10x-prd/../10x-shape/references/prd-schema.md
PRD reads `../10x-shape/references/prd-schema.md` relative to its SKILL.md;
isolated PRD download is insufficient. These are the source minimum: preserve
additional supporting files in the selected release. Inspect all three names in
additional supporting files in the selected release. Inspect all four names in
`lessons.m1l1.skills`, their hashes in `files.skills`, and the project edition
binding. Partial downloads do not establish complete lesson freshness/release identity.
Membership in source is candidate evidence; actual filtered availability, full PL
references and release identity still need verification for all three names.
references and release identity still need verification for all four names.
`CLAUDE-m1l1` is a separate lesson rule and is not included in these filtered gets.
The inspected three skill sources do not require it for the chain. This is not
@@ -240,8 +251,10 @@ require it, inspect an existing rule's provenance, or report the missing
prerequisite and ask the lesson/release owner for a supported route before that
step. Never invent a command, overwrite a rule or fall back to full lesson get.
Have the agent explicitly read each installed SKILL.md and its references in
order: init preserves/scaffolds context directories; shape conducts the actual
If the learner wants to assess their idea before shaping, have the agent read
`.claude/skills/10x-idea-check/SKILL.md` and its references and follow that skill.
A learner ready to shape can skip assessment. Then read the chain's entrypoints
and references in order: init preserves/scaffolds context directories; shape conducts the actual
10xCards discovery with the learner and writes
`context/foundation/shape-notes.md`; after the learner approves those notes, PRD
uses them and the sibling schema to produce `context/foundation/prd.md`.
@@ -252,7 +265,7 @@ work. Stop at PRD, without stack selection or implementation. Download alone is
not use; native slash/$ discovery needs separate agent evidence. Keep private
lesson text out of public fixtures. The guide supplies the detailed agent steps.
Sync below refreshes entire recorded lessons, not only the three skill filters.
Sync below refreshes entire recorded lessons, not only the four skill filters.
Preview may include other skills, prompts, configs and course rules; apply only
when the user accepts that scope. For a narrow update, repeat the selected skill
filter instead. Never use sync to silently bypass a missing lesson-rule prerequisite.
+62 -1
View File
@@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { calculateVersion, packageWithVersion } from "../scripts/auto-version.mjs";
import { BranchUpdateRequiredError, calculateVersion, packageWithVersion } from "../scripts/auto-version.mjs";
import { preparePullRequest, validatePullRequest, verifyMergedPreparation, reconcileVersionEvent } from "../scripts/prepare-version.mjs";
const sha = (c: string) => c.repeat(40);
function fixture() {
@@ -131,3 +131,64 @@ describe("generated version filtering uses content, never commit scope", () => {
} finally { f.cleanup(); }
}, 30000);
});
describe("stale PR version preparation", () => {
it("classifies unrebased open PRs, writes nothing, and lets reconcile skip them while preparing current PRs", async () => {
const f = fixture();
try {
f.git("checkout", "-qb", "candidate");
mkdirSync(join(f.cwd, "skills")); writeFileSync(join(f.cwd, "skills/SKILL.md"), "helper\n");
f.git("add", "."); f.git("commit", "-qm", "feat: helper candidate");
let staleHead = f.git("rev-parse", "HEAD");
f.git("checkout", "-qb", "current-master", f.base);
writeFileSync(join(f.cwd, "src/index.ts"), "export const value = 2;\n");
f.git("add", "."); f.git("commit", "-qm", "feat: merged sibling"); f.git("tag", "v1.1.0");
const master = f.git("rev-parse", "HEAD"), repo = { full_name: "przeprogramowani/10x-cli" };
f.git("checkout", "-qb", "current-candidate", master);
writeFileSync(join(f.cwd, "src/index.ts"), "export const value = 3;\n");
f.git("add", "."); f.git("commit", "-qm", "fix: current open candidate");
const currentHead = f.git("rev-parse", "HEAD");
let baseline = f.baseline;
const stale = { number: 47, state: "open", head: { sha: staleHead, ref: "candidate", repo }, base: { sha: f.base, ref: "master", repo } };
const current = { number: 49, state: "open", head: { sha: currentHead, ref: "current-candidate", repo }, base: { sha: master, ref: "master", repo } };
const writes: Array<{ path: string; method: string }> = [];
const get = async (path: string, method = "GET"): Promise<any> => {
if (method !== "GET") { writes.push({ path, method }); return { sha: sha("d") }; }
if (path === "pulls/47") return stale;
if (path === "pulls/49") return current;
if (path === "pulls?state=open&base=master&per_page=100") return [stale, current];
if (path === "git/ref/heads/master") return { object: { sha: master } };
return { tree: { sha: sha("c") } };
};
const prepareHint = (hint: { number: number }) => preparePullRequest({ number: hint.number, runId: "201", runAttempt: 1, workflowSha: master }, {
get, calculate: (input: any) => calculateVersion({ cwd: f.cwd, ...input }),
readPackage: async (value: string) => f.git("show", `${value}:package.json`), baseline: async () => baseline,
});
await expect(prepareHint(stale)).rejects.toMatchObject({ name: "BranchUpdateRequiredError" });
expect(writes).toEqual([]);
stale.base.sha = master;
await expect(prepareHint(stale)).rejects.toMatchObject({ name: "BranchUpdateRequiredError" });
expect(writes).toEqual([]);
for (const input of [{ head: staleHead, baseline: { ...baseline, gitHead: sha("f") } }, { head: sha("f"), baseline }]) {
let error: any;
try { await calculateVersion({ cwd: f.cwd, master, ...input }); } catch (caught) { error = caught; }
expect(error).toBeDefined(); expect(error).not.toBeInstanceOf(BranchUpdateRequiredError);
}
stale.base.sha = f.base;
const env = { GITHUB_REPOSITORY: repo.full_name, GITHUB_REF: "refs/heads/master", GITHUB_EVENT_NAME: "push" };
const records = await reconcileVersionEvent({ env, event: {} }, { get, prepare: prepareHint, baseline: async () => baseline });
expect(records[0]).toBeNull();
expect(records[1]?.prNumber).toBe(49);
expect(records[1]?.version).toBe("1.1.0");
expect(records[1]?.inputHead).toBe(currentHead);
expect(writes.some((write) => write.path === "git/refs/heads/current-candidate" && write.method === "PATCH")).toBe(true);
expect(writes.some((write) => write.path.startsWith("git/refs/heads/candidate"))).toBe(false);
f.git("checkout", "-q", "candidate");
f.git("merge", "--no-edit", "current-master");
staleHead = f.git("rev-parse", "HEAD"); stale.head.sha = staleHead; stale.base.sha = master;
writes.length = 0;
const updated = await prepareHint(stale);
expect(updated?.baseSha).toBe(master); expect(updated?.inputHead).toBe(staleHead); expect(updated?.version).toBe("1.1.0");
} finally { f.cleanup(); }
}, 30000);
});
+43 -8
View File
@@ -7,11 +7,23 @@ import type { LessonBundle } from "../src/lib/api-content";
import { applyBundle } from "../src/lib/writer";
const repo = resolve(import.meta.dir, "..");
const names = ["10x-init", "10x-shape", "10x-prd"];
const names = ["10x-idea-check", "10x-init", "10x-shape", "10x-prd"];
const ideaCheckReferences = [
"examples.md",
"assessment-guide.md",
"10xdevs-4-dates.md",
"10xdevs-4-certification.md",
];
const schema = "../10x-shape/references/prd-schema.md";
const required = names.map((name) => `.claude/skills/${name}/SKILL.md`);
required.splice(2, 0, ".claude/skills/10x-shape/references/prd-schema.md");
required.push(`.claude/skills/10x-prd/${schema}`);
const required = [
".claude/skills/10x-idea-check/SKILL.md",
...ideaCheckReferences.map((name) => `.claude/skills/10x-idea-check/references/${name}`),
".claude/skills/10x-init/SKILL.md",
".claude/skills/10x-shape/SKILL.md",
".claude/skills/10x-shape/references/prd-schema.md",
".claude/skills/10x-prd/SKILL.md",
`.claude/skills/10x-prd/${schema}`,
];
const docs = [
"skills/10x-cli-guide/SKILL.md",
"skills/10x-cli-guide/references/compatibility.md",
@@ -22,7 +34,8 @@ const expectedGets = names.flatMap((name) => [
`10x_cli get m1l1 --type skills --name ${name} ${flags} --dry-run`, `10x_cli get m1l1 --type skills --name ${name} ${flags}`,
]);
function demoGets(text: string) {
return text.split(/\r?\n/).filter((line) => /^10x_cli get m1l1 --type skills --name 10x-(?:init|shape|prd) /.test(line));
return text.split(/\r?\n/).filter((line) =>
/^10x_cli get m1l1 --type skills --name (?!10x-cli-(?:setup|guide)\b)\S+ /.test(line));
}
function preflightPaths(text: string) {
return text.split(/\r?\n/).filter((line) => line.startsWith("test -s ")).map((line) => line.slice(8));
@@ -37,6 +50,11 @@ function fixture(name: string): LessonBundle {
? `# Synthetic PRD fixture\nRead \`${schema}\` before using context/foundation/shape-notes.md to write context/foundation/prd.md.\n`
: `# Synthetic ${name} fixture\n` }];
if (name === "10x-shape") files.push({ path: "references/prd-schema.md", content: "# Synthetic schema fixture\n" });
if (name === "10x-idea-check") {
for (const reference of ideaCheckReferences) {
files.push({ path: `references/${reference}`, content: `# Synthetic ${reference} fixture\n` });
}
}
return { lessonId: "m1l1", module: 1, lesson: 1, title: "Offline fixture", summary: "Not lesson content",
skills: [{ name, files }], prompts: [], rules: [], configs: [] };
}
@@ -52,7 +70,7 @@ afterEach(() => rmSync(root, { recursive: true, force: true }));
describe.each(["\n", "\r\n"])("launch journey and complete supporting trees with newline %j", (newline) => {
// Exercise both checkout line endings on every platform, including negative cases.
const read = (path: string) => readFileSync(join(repo, path), "utf8").replace(/\r?\n/g, newline);
it("documents separate preview/write pairs in init → shape → PRD order with the same context", () => {
it("documents separate preview/write pairs in idea-check → init → shape → PRD order with the same context", () => {
for (const path of [...docs, "README.md"]) {
const text = read(path);
expect(demoGets(text)).toEqual(expectedGets);
@@ -62,7 +80,7 @@ describe.each(["\n", "\r\n"])("launch journey and complete supporting trees with
}
for (const path of docs) expect(preflightPaths(read(path))).toEqual(required);
});
it("materializes the three documented trees with the real writer and resolves the PRD sibling schema", async () => {
it("materializes all four documented trees and their supporting files with the real writer", async () => {
// Actual sequential partial writer, without an HTTP or learner-use claim.
await materialize(names);
for (const path of docs) expect(missingPaths(root, preflightPaths(read(path)))).toEqual([]);
@@ -70,9 +88,25 @@ describe.each(["\n", "\r\n"])("launch journey and complete supporting trees with
const ref = /Read `([^`]+)`/.exec(readFileSync(prd, "utf8"))![1]!;
expect(resolve(dirname(prd), ref)).toBe(join(root, ".claude/skills/10x-shape/references/prd-schema.md"));
expect(readFileSync(resolve(dirname(prd), ref), "utf8")).toBe("# Synthetic schema fixture\n");
for (const reference of ideaCheckReferences) {
expect(readFileSync(join(root, `.claude/skills/10x-idea-check/references/${reference}`), "utf8"))
.toBe(`# Synthetic ${reference} fixture\n`);
}
expect(existsSync(join(root, "CLAUDE.md"))).toBe(false);
expect(existsSync(join(root, "context/foundation/prd.md"))).toBe(false);
});
it("detects every missing or empty idea-check support file", async () => {
await materialize(names);
for (const reference of ideaCheckReferences) {
const path = `.claude/skills/10x-idea-check/references/${reference}`;
const bytes = readFileSync(join(root, path));
for (const mode of ["missing", "empty"]) {
if (mode === "missing") rmSync(join(root, path)); else writeFileSync(join(root, path), "");
for (const doc of docs) expect(missingPaths(root, preflightPaths(read(doc)))).toEqual([path]);
writeFileSync(join(root, path), bytes);
}
}
});
it("rejects isolated PRD and missing or empty schema despite the presence of all entrypoints", async () => {
await materialize(["10x-prd"]);
expect(missingPaths(root, required)).toEqual(required.filter((path) => !path.endsWith("10x-prd/SKILL.md")));
@@ -81,7 +115,8 @@ describe.each(["\n", "\r\n"])("launch journey and complete supporting trees with
for (const mode of ["missing", "empty"]) {
if (mode === "missing") rmSync(path); else writeFileSync(path, "");
for (const doc of docs) expect(missingPaths(root, preflightPaths(read(doc)))).toEqual([
required[2]!, required[4]!,
".claude/skills/10x-shape/references/prd-schema.md",
`.claude/skills/10x-prd/${schema}`,
]);
}
});
+22 -6
View File
@@ -694,9 +694,10 @@ describe("10x get — course rules opt-out", () => {
describe("helper launch commands match the released lesson filter", () => {
it("executes the documented preview/write sequence and retains all three lesson-owned trees", async () => {
it("executes the documented preview/write sequence and retains all four lesson-owned trees", async () => {
writeValidAuth();
const names = ["10x-init", "10x-shape", "10x-prd"];
const names = ["10x-idea-check", "10x-init", "10x-shape", "10x-prd"];
const ideaCheckReferences = ["examples.md", "assessment-guide.md", "10xdevs-4-dates.md", "10xdevs-4-certification.md"];
const release = { course: "10xdevs4", releaseId: `r-${"a".repeat(64)}`, releaseManifestHash: "b".repeat(64) };
apiContentMockState.fetchCoursesImpl = () => ({ ok: true, status: 200, responseHeaders: new Headers(), rawBody: "", data: {
courses: [{ id: "10xdevs-4", slug: "10xdevs4", title: "Synthetic v4", edition: 4, available: true }], defaultCourse: "10xdevs4",
@@ -705,6 +706,9 @@ describe("helper launch commands match the released lesson filter", () => {
const bundle = makeBundle({ ...release, skills: names.map((name) => ({ name, files: [
{ path: "SKILL.md", content: name === "10x-prd" ? "Read ../10x-shape/references/prd-schema.md" : `Synthetic ${name}` },
...(name === "10x-shape" ? [{ path: "references/prd-schema.md", content: "Synthetic schema" }] : []),
...(name === "10x-idea-check" ? ideaCheckReferences.map((reference) => ({
path: `references/${reference}`, content: `Synthetic ${reference}`,
})) : []),
] })) });
let fetches = 0;
apiContentMockState.fetchLessonImpl = (course, lesson, _token, options) => {
@@ -714,11 +718,18 @@ describe("helper launch commands match the released lesson filter", () => {
};
const guide = readFileSync(new URL("../skills/10x-cli-guide/SKILL.md", import.meta.url), "utf8");
const commands = guide.split(/\r?\n/).filter((line) => /^10x_cli get (?!-)/.test(line));
expect(commands).toHaveLength(6);
expect(commands).toHaveLength(8);
for (const [index, command] of commands.entries()) {
const name = names[Math.floor(index / 2)]!;
const target = join(projectRoot, `.claude/skills/${name}/SKILL.md`);
if (index % 2 === 0) expect(existsSync(target)).toBe(false);
if (index % 2 === 0) {
expect(existsSync(target)).toBe(false);
if (name === "10x-idea-check") {
for (const reference of ideaCheckReferences) {
expect(existsSync(join(projectRoot, `.claude/skills/10x-idea-check/references/${reference}`))).toBe(false);
}
}
}
const result = await runGet(command.split(/\s+/).slice(1));
// Let this existing capture harness restore streams before the next invocation.
await new Promise<void>((resolve) => setImmediate(resolve));
@@ -728,16 +739,21 @@ describe("helper launch commands match the released lesson filter", () => {
for (const prior of names.slice(0, Math.floor(index / 2) + 1)) expect(existsSync(join(projectRoot, `.claude/skills/${prior}/SKILL.md`))).toBe(true);
}
}
expect(fetches).toBe(6);
expect(fetches).toBe(8);
const manifest = readManifest(join(projectRoot, ".claude"))!;
expect(Object.keys(manifest.lessons!)).toEqual(["m1l1"]);
expect(Object.keys(manifest.lessons!.m1l1!.skills)).toEqual(names);
expect(manifest.lessons!.m1l1!.representation).toBeUndefined();
for (const reference of ideaCheckReferences) {
expect(readFileSync(join(projectRoot, `.claude/skills/10x-idea-check/references/${reference}`), "utf8"))
.toBe(`Synthetic ${reference}`);
expect(manifest.lessons!.m1l1!.skills["10x-idea-check"]!.files).toContain(`references/${reference}`);
}
expect(readFileSync(join(projectRoot, ".claude/skills/10x-prd/../10x-shape/references/prd-schema.md"), "utf8")).toBe("Synthetic schema");
expect(existsSync(join(projectRoot, "CLAUDE.md"))).toBe(false);
expect(manifest.files.prompts).toEqual([]); expect(manifest.files.configs).toEqual([]);
const invalid = await runGet(["get", "10x-init", "--course", "10xdevs4"]);
expect(invalid.exitCode).toBe(2); parseErr(invalid.stdout, "invalid_lesson_ref");
expect(fetches).toBe(6);
expect(fetches).toBe(8);
});
});
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from "bun:test";
import { readFileSync } from "node:fs";
import { parse } from "yaml";
import { assertPackMatchesRegistry, classifyPublishDecision, metadataIsComplete, waitForPublishedMetadata } from "../scripts/publish-npm-verify.mjs";
const sha = (c: string) => c.repeat(40);
const integrity = "sha512-UpH51iLC2WQmjhzVj2HbfOu+79ob0EtcQ3XI2epIHrEq3Vl8NxnBoqjCSHCRVdvUNXCgmmCC8kU/06IW/lsGew==";
const tarball = "https://registry.npmjs.org/@przeprogramowani/10x-cli/-/10x-cli-1.22.1.tgz";
const complete = { version: "1.22.1", gitHead: sha("d"), dist: { tarball, integrity } };
describe("publish-npm registry wait and resume", () => {
it("polls until dist.tarball and integrity exist, then matches pack bytes", async () => {
const log: string[] = [];
let n = 0;
const fetchFn = async () => {
n += 1;
if (n === 1) return { status: 404, text: async () => '{"error":"Not found"}', ok: false };
if (n === 2) return { status: 200, text: async () => JSON.stringify({ version: "1.22.1" }), ok: true };
return { status: 200, text: async () => JSON.stringify(complete), ok: true };
};
const meta = await waitForPublishedMetadata("1.22.1", { fetchFn: fetchFn as any, sleep: async () => {}, now: (() => { let t = 0; return () => (t += 1); })(), log: (m: string) => log.push(m), timeoutMs: 100, initialDelayMs: 1, maxDelayMs: 1 });
expect(metadataIsComplete(meta)).toBe(true);
expect(n).toBe(3);
expect(log.some((line) => line.includes("attempt 1"))).toBe(true);
expect(log.at(-1)).toContain("ready");
});
it("fails closed when the wait window expires without complete metadata", async () => {
const fetchFn = async () => ({ status: 200, text: async () => JSON.stringify({ version: "1.22.1" }), ok: true });
await expect(waitForPublishedMetadata("1.22.1", { fetchFn: fetchFn as any, sleep: async () => {}, now: (() => { let t = 0; return () => (t += 50); })(), log: () => {}, timeoutMs: 100, initialDelayMs: 1, maxDelayMs: 1 })).rejects.toThrow(/never republish/);
});
it("rejects a registry copy whose gitHead or integrity differs from the pack", () => {
expect(() => assertPackMatchesRegistry({ metadata: complete, tarballBytes: Buffer.from("nope"), expectedIntegrity: integrity, sourceSha: sha("d") })).toThrow(/never republish/);
expect(() => assertPackMatchesRegistry({ metadata: { ...complete, gitHead: sha("e") }, tarballBytes: Buffer.from("x"), expectedIntegrity: integrity, sourceSha: sha("d") })).toThrow(/never republish/);
});
it("publishes when the version is absent and resumes when registry bytes already match", async () => {
const missing = async (url: string) => {
if (String(url).includes("/1.22.1") && !String(url).includes("/-/")) return { status: 404, text: async () => '{"error":"Not found"}', ok: false };
throw new Error(url);
};
expect(await classifyPublishDecision({ version: "1.22.1", expectedIntegrity: integrity, sourceSha: sha("d"), fetchFn: missing as any })).toEqual({ action: "publish" });
const bytes = Buffer.from("pack-bytes");
const { createHash } = await import("node:crypto");
const matching = "sha512-" + createHash("sha512").update(bytes).digest("base64");
const meta = { version: "1.22.1", gitHead: sha("d"), dist: { tarball, integrity: matching } };
const fetchFn = async (url: string) => {
if (String(url) === tarball) return { ok: true, arrayBuffer: async () => bytes };
return { status: 200, text: async () => JSON.stringify(meta), ok: true };
};
const decision = await classifyPublishDecision({ version: "1.22.1", expectedIntegrity: matching, sourceSha: sha("d"), fetchFn: fetchFn as any });
expect(decision.action).toBe("resume");
expect(decision.result?.gitHead).toBe(sha("d"));
expect(decision.result?.actualIntegrity).toBe(matching);
});
});
describe("publish-npm workflow uses bounded verify and resume", () => {
it("calls the verifier for decide/verify and publishes only when decide says publish", () => {
const workflow = parse(readFileSync(new URL("../.github/workflows/publish-npm.yml", import.meta.url), "utf8"));
const steps = workflow.jobs.publish.steps;
const decide = steps.find((step: any) => step.id === "decide");
expect(decide.run).toBe("node scripts/publish-npm-verify.mjs decide");
expect(decide.env.CLI_SHA).toBe("${{ inputs.cli_sha }}");
const publish = steps.find((step: any) => step.name === "Publish the exact directory once");
expect(publish.if).toBe("steps.decide.outputs.action == 'publish'");
expect(publish.run).toContain("npm publish . --ignore-scripts --access public");
const verify = steps.find((step: any) => step.name === "Verify actual registry bytes against the pack");
expect(verify.run).toBe("node scripts/publish-npm-verify.mjs verify");
expect(verify.if).toBeUndefined();
const source = readFileSync(new URL("../scripts/publish-npm-verify.mjs", import.meta.url), "utf8");
expect(source).toContain("never republish");
expect(source).not.toMatch(/timeoutMs:\s*[5-9]\d{5,}/);
});
});