From 9e300f98c7b7379750bb6118a330fd80e6b7fb25 Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Fri, 7 Aug 2026 13:22:29 -0500 Subject: [PATCH 1/4] Report Compile and publication progress Expose the retained lifecycle without leaking private projection data, and keep stdout stable for repository URL consumers. Coordinate the strict progress response with API contract 0.2.0. --- README.md | 39 ++- RELEASING.md | 11 +- package-lock.json | 4 +- package.json | 2 +- release/compatibility.json | 4 +- scripts/check-pack.js | 1 + scripts/smoke-package.js | 15 +- src/cli.js | 21 +- src/commands/plan-compile.js | 5 + src/commands/plan-publish.js | 143 ++++++++++- src/plan-compile-progress.js | 111 ++++++++ test/plan-compile.test.js | 62 +++-- test/plan-publish.test.js | 400 ++++++++++++++++++++++++++--- test/release-compatibility.test.js | 4 +- 14 files changed, 737 insertions(+), 85 deletions(-) create mode 100644 src/plan-compile-progress.js diff --git a/README.md b/README.md index 120af62..7384441 100644 --- a/README.md +++ b/README.md @@ -165,22 +165,32 @@ authorization to request that lifecycle. Immediately before its conditional muta Plan and requires its exact bytes to match the accepted Head, so bytes changed after analysis cannot be published. It extracts the accepted source SHA-256 from the saved ETag, hashes the current local bytes, and then sends that complete ETag in `If-Match`. -The command writes no progress output to stdout. Success is exactly one validated JSON object containing the -retained `project`, `compilation`, and `publication`, including the private GitHub repository URL. +The command writes stable human-readable progress to stderr, with every line prefixed by `First Draft:`. It reports +analysis, completed compilation, the current GitHub phase, and an allowlisted reason, retry count, and exact UTC +retry time when a GitHub preflight check is delayed. A retained retry with no next time is reported as paused and +requiring operator recovery. Progress never includes IDs, hashes, repository names or URLs, raw server projections, +local paths, or environment values. Success writes exactly the validated private GitHub repository URL plus a +newline to stdout. If the command fails after progress has begun, its existing structured JSON error envelope is the +final stderr document after the progress lines. The internal Publication is a Project singleton in this release. A repeat safely receives the same Publication instead of creating another. If the first conditional `PUT` has an ambiguous result, the CLI reconciles it with -one read-only singleton `GET` and never automatically repeats the mutation. Rerunning `plan compile` safely -replays the singleton request. Publication polling is sequential, bounded to ten minutes, and pinned to the -retained Project Head, Compilation input, Publication identity, and repository identity. +one read-only singleton `GET` and never automatically repeats the mutation within that invocation. Do not run +concurrent Compile commands. After an invocation exits because the initial outcome or a later status read is +unavailable, wait and rerun `plan compile` with unchanged Plan bytes; its conditional request safely reconciles or +resumes the same retained singleton without creating another Compilation, repository, or push. Publication polling +is sequential, bounded to ten minutes, and pinned to the retained Project Head, Compilation input, Publication +identity, and repository identity. This release cannot repoint a Project's Publication to a later accepted Head. The public CLI therefore has no -`plan publish` command and no local-start `plan compile --output` mode. Use the standalone retained-Compilation -commands below when local generated source is useful. +`plan publish` command and no local-start `plan compile --output` mode. It retains lower-level Compilation commands +for operational callers that acquire an ID separately, but they are intentionally not a continuation of the +URL-only `plan compile` journey. ## Inspect a retained Compilation -Read one Compilation ID returned by `plan compile`: +These lower-level commands are for callers that already hold a retained Compilation ID from authenticated API +metadata or operational tooling; `plan compile` prints only the final repository URL: ```sh firstdraft compilation status 01900000-0000-7000-8000-000000000001 @@ -216,13 +226,16 @@ structure, contents, and digests without claiming POSIX mode bits. ## Handled failures -Every handled subcommand failure writes exactly one JSON object to standard error. Branch on its stable `error` -value rather than the human-readable `detail`; `plan compile` also supplies `phase: "push" | "publication"` when -`request_outcome_unknown` requires phase-specific recovery: +Every handled subcommand failure ends with exactly one JSON object on standard error. `plan compile` may first write +progress lines; machine consumers can remove only lines beginning with the exact `First Draft: ` prefix and parse +the remaining JSON document. Branch on its stable `error` value rather than the human-readable `detail`; `plan +compile` also supplies `phase: "push" | "publication"` when `request_outcome_unknown` requires phase-specific +recovery: - `phase: "push"` means the Plan mutation may have been accepted; stop and reconcile local Head state. -- `phase: "publication"` means the singleton Publication mutation was not resolved; rerunning `plan compile` is a - safe replay. +- `phase: "publication"` means the singleton Publication mutation was not resolved. Do not run concurrent Compile + commands. After the prior invocation exits, wait and rerun `plan compile` with unchanged Plan bytes to safely + reconcile or resume the retained singleton. | Commands | `error` | Exit | Meaning | | -------------------------------------------- | -------------------------------------------------------------------------------------------------- | ---: | -------------------------------------------------------------------------------------------------------------- | diff --git a/RELEASING.md b/RELEASING.md index e832663..bdb24c7 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -12,8 +12,9 @@ binds its Foundation Plan format to the implemented CLI constant. The `script/release_compatibility_check` evaluator in `firstdraft/firstdraft` reads this declaration with the matching declarations from exact, clean checkouts of `firstdraft/firstdraft` and `firstdraft/skills`. It implements SemVer 2.0 -precedence. The service API contract is currently the stable `0.1.0` contract line even though this CLI package is a -prerelease. Comparator arrays form one conjunction, while `foundation_plan_formats` lists alternatives. A +precedence. This CLI requires the service's `0.2.0` API contract because the always-present GitHub Publication +progress projection is incompatible with the strict alpha.2 response shape. Comparator arrays form one conjunction, +while `foundation_plan_formats` lists alternatives. A prerelease satisfies a comparator set only when a comparator explicitly names a prerelease with the same major, minor, and patch numbers; Skills therefore names this CLI alpha explicitly. `firstdraft.release-compatibility/1` is intentionally closed. The evaluator in `firstdraft/firstdraft` rejects an unrecognized format and unknown keys, so @@ -122,7 +123,7 @@ Do not move or reuse that tag or version. The first organization-scoped candidat ## Publish The manual boundary is creation of the version tag. From an up-to-date, clean `main`, verify the intended commit and -then create and push `v`. For version `0.1.0-alpha.2`, the tag is `v0.1.0-alpha.2`. +then create and push `v`. For version `0.1.0-alpha.3`, the tag is `v0.1.0-alpha.3`. Push one release tag at a time; the workflow serializes publication, but GitHub retains at most one pending run in a concurrency group. @@ -151,12 +152,12 @@ After publication, inspect the registry before retrying any reported failure; th the exact version, `next` dist-tag, integrity metadata, and provenance metadata: ```sh -npm view '@firstdraft.com/cli@0.1.0-alpha.2' \ +npm view '@firstdraft.com/cli@0.1.0-alpha.3' \ version dist.integrity dist.shasum repository.url engines bin --json npm dist-tag ls '@firstdraft.com/cli' ``` -Install `@firstdraft.com/cli@0.1.0-alpha.2` into a fresh temporary prefix, confirm `firstdraft --version`, compare the +Install `@firstdraft.com/cli@0.1.0-alpha.3` into a fresh temporary prefix, confirm `firstdraft --version`, compare the packed file list with the release workflow, and run `npm audit signatures` after an exact installation. A published version cannot be overwritten or reused. For a bad release, move `next` to a known-good version, diff --git a/package-lock.json b/package-lock.json index 3bacafd..6f7c8bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@firstdraft.com/cli", - "version": "0.1.0-alpha.2", + "version": "0.1.0-alpha.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@firstdraft.com/cli", - "version": "0.1.0-alpha.2", + "version": "0.1.0-alpha.3", "license": "MIT", "bin": { "firstdraft": "bin/firstdraft.js" diff --git a/package.json b/package.json index 36c4a0c..1b30953 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@firstdraft.com/cli", - "version": "0.1.0-alpha.2", + "version": "0.1.0-alpha.3", "description": "Command-line interface for First Draft", "license": "MIT", "type": "module", diff --git a/release/compatibility.json b/release/compatibility.json index 48cca0b..fe13913 100644 --- a/release/compatibility.json +++ b/release/compatibility.json @@ -1,9 +1,9 @@ { "format": "firstdraft.release-compatibility/1", "component": "cli", - "version": "0.1.0-alpha.2", + "version": "0.1.0-alpha.3", "requires": { - "api_contract": [">= 0.1.0", "< 0.2.0"], + "api_contract": [">= 0.2.0", "< 0.3.0"], "foundation_plan_formats": ["firstdraft.foundation-plan.sketch/0.19"] } } diff --git a/scripts/check-pack.js b/scripts/check-pack.js index 243d63e..ef635d7 100644 --- a/scripts/check-pack.js +++ b/scripts/check-pack.js @@ -37,6 +37,7 @@ if (result.status !== 0) { "src/commands/plan-status.js", "src/compilation-artifact.js", "src/file-system.js", + "src/plan-compile-progress.js", "src/plan-state.js", "src/uuid-v7.js", "src/version.js", diff --git a/scripts/smoke-package.js b/scripts/smoke-package.js index 518998d..e53ad95 100644 --- a/scripts/smoke-package.js +++ b/scripts/smoke-package.js @@ -452,6 +452,12 @@ async function exercisePackedCompilation(projectDirectory) { commit_sha: "6".repeat(40), }, failure: null, + progress: { + phase: "completed", + retry_at: null, + retry_count: 0, + reason_code: null, + }, created_at: "2026-07-30T12:00:00.000Z", started_at: "2026-07-30T12:00:01.000Z", completed_at: "2026-07-30T12:00:02.000Z", @@ -560,8 +566,13 @@ async function exercisePackedCompilation(projectDirectory) { ); assert.deepEqual(compiled, { status: 0, - stdout: `${JSON.stringify(publication, null, 2)}\n`, - stderr: "", + stdout: `${publication.publication.repository.html_url}\n`, + stderr: `First Draft: Analyzing Foundation Plan... +First Draft: Foundation Plan analysis valid. +First Draft: Compiling application... +First Draft: Application compiled. +First Draft: GitHub publication complete. +`, }); const status = await spawnPackedCliAsync( diff --git a/src/cli.js b/src/cli.js index 7397b28..d8f921d 100644 --- a/src/cli.js +++ b/src/cli.js @@ -62,6 +62,7 @@ import { readPlanStatus, } from "./commands/plan-status.js"; import { isFileSystemError } from "./file-system.js"; +import { createPlanCompileProgressReporter } from "./plan-compile-progress.js"; import { isUuidV7 } from "./plan-state.js"; import { generateUuidV7 } from "./uuid-v7.js"; import { VERSION } from "./version.js"; @@ -181,9 +182,9 @@ Environment: FIRSTDRAFT_API_URL Override the initial API origin The command submits the exact current whole-file Plan, waits for its analysis, -and proceeds only when that analysis is valid. It then conditionally creates -or replays the internal GitHub Publication lifecycle and prints its complete -validated Project, Compilation, and Publication projection. +and proceeds only when that analysis is valid. It then conditionally creates or +replays the internal GitHub Publication lifecycle. Progress is written to +stderr. Success prints only the validated private GitHub repository URL. `; const COMPILATION_HELP = `First Draft CLI @@ -318,17 +319,17 @@ const PLAN_PUBLISH_NOT_PUSHED_DETAIL = const PLAN_PUBLISH_LOCAL_PLAN_CHANGED_DETAIL = "The local Foundation Plan changed after validation. Run 'firstdraft plan compile' again to submit the current bytes."; const PLAN_PUBLISH_REQUEST_OUTCOME_UNKNOWN_DETAIL = - "The Publication may have started, but its singleton status could not be verified. No mutation was retried. Running 'firstdraft plan compile' again is safe."; + "The Publication may have started, but its retained singleton status could not be verified. No mutation was retried. Do not run concurrent Compile commands. Wait, then rerun 'firstdraft plan compile' with unchanged Plan bytes to safely reconcile or resume the retained singleton."; const PLAN_PUBLISH_START_REJECTED_DETAIL = "First Draft rejected the publication request."; const PLAN_PUBLISH_STATUS_UNAVAILABLE_DETAIL = - "Could not read the pinned publication status. The command stopped without starting another Publication."; + "Could not read the retained Publication status. The command stopped without starting another Publication. Do not run concurrent Compile commands. Wait, then rerun 'firstdraft plan compile' with unchanged Plan bytes to safely resume the retained singleton."; const PLAN_PUBLISH_STATUS_INVALID_DETAIL = "First Draft returned an invalid publication status response. Retrying unchanged will not repair this protocol mismatch."; const PLAN_PUBLISH_CHANGED_DETAIL = "The pinned Publication changed while being polled. The command stopped without following a replacement."; const PLAN_PUBLISH_TIMEOUT_DETAIL = - "The pinned Publication is still processing after the bounded ten-minute wait."; + "The retained Publication is still processing after the bounded ten-minute wait. This invocation stopped waiting, but retained work may continue. Do not run concurrent Compile commands. Wait, then rerun 'firstdraft plan compile' with unchanged Plan bytes to safely resume the retained singleton."; const PLAN_PUBLISH_FAILED_DETAIL = "The pinned Publication failed. Its validated status identifies the failed phase."; const PLAN_PUBLISH_CANCELLED_DETAIL = "The pinned Publication was cancelled."; @@ -1495,6 +1496,7 @@ async function runPlanCompile({ } let result; + const reportProgress = createPlanCompileProgressReporter(stderr); try { result = await compilePlan({ cwd, @@ -1510,12 +1512,17 @@ async function runPlanCompile({ push: planCompilePush, readStatus: planCompileReadStatus, publish: planCompilePublish, + onProgress: reportProgress, }); } catch (error) { return writePlanCompileError(stderr, error); } - writeJson(stdout, result); + const repository = + /** @type {NonNullable} */ ( + result.publication.repository + ); + stdout.write(`${repository.html_url}\n`); return 0; } diff --git a/src/commands/plan-compile.js b/src/commands/plan-compile.js index 5de61b4..1828737 100644 --- a/src/commands/plan-compile.js +++ b/src/commands/plan-compile.js @@ -62,6 +62,7 @@ export class PlanCompileAnalysisInvalidError extends Error { * @property {() => number} [analysisNow] * @property {(delayMs: number) => Promise} [publicationSleep] * @property {() => number} [publicationNow] + * @property {(progress: import("../plan-compile-progress.js").PlanCompileProgress) => void} [onProgress] * @property {typeof pushPlan} [push] * @property {typeof readPlanStatus} [readStatus] * @property {typeof publishPlan} [publish] @@ -85,6 +86,7 @@ export async function compilePlan({ analysisNow, publicationSleep, publicationNow, + onProgress = () => {}, push = pushPlan, readStatus = readPlanStatus, publish = publishPlan, @@ -105,6 +107,7 @@ export async function compilePlan({ ).graph_version; let status; + onProgress({ phase: "analysis", status: "waiting" }); try { status = await readStatus({ cwd, @@ -137,6 +140,7 @@ export async function compilePlan({ if (status.body.analysis.status !== "valid") { throw new PlanCompileAnalysisNotValidError(status.body); } + onProgress({ phase: "analysis", status: "valid" }); return publish({ cwd, @@ -146,5 +150,6 @@ export async function compilePlan({ sleep: publicationSleep, now: publicationNow, expectedEtag: pushed.etag, + onProgress, }); } diff --git a/src/commands/plan-publish.js b/src/commands/plan-publish.js index 39cdc49..858790d 100644 --- a/src/commands/plan-publish.js +++ b/src/commands/plan-publish.js @@ -69,11 +69,13 @@ const PUBLICATION_KEYS = [ "created_at", "failure", "id", + "progress", "repository", "started_at", "status", ]; const FAILURE_KEYS = ["code", "phase"]; +const PROGRESS_KEYS = ["phase", "reason_code", "retry_at", "retry_count"]; const REPOSITORY_KEYS = [ "commit_sha", "default_branch", @@ -85,6 +87,33 @@ const REPOSITORY_KEYS = [ "tree_sha", ]; const OWNER_KEYS = ["id", "login", "type"]; +const PROGRESS_PHASES = new Set([ + "compiling", + "preparing_repository", + "github_preflight", + "creating_repository", + "preparing_repository_reconciliation", + "reconciling_repository", + "preparing_artifact", + "publishing_artifact", + "preparing_publication_reconciliation", + "reconciling_publication", + "completed", + "failed", + "cancelled", +]); +const PROGRESS_REASON_CODES = new Set([ + "github.configuration_missing", + "github.oauth_unavailable", + "github.api_unavailable", + "github.reauthorization_required", + "github.account_mismatch", + "github.installation_unavailable", + "github.installation_not_ready", + "github.preflight_unavailable", +]); +const MAX_PROGRESS_RETRY_COUNT = 7; +const RETRY_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z$/; /** * @typedef {object} PlanPublishFileSystem @@ -183,7 +212,7 @@ export class PublicationCancelledError extends Error { * @typedef {object} PublicationResponse * @property {{id: string, graph_version: number, head_source_sha256: string}} project * @property {{id: string, analysis_run_id: string, graph_version: number, head_source_sha256: string, status: string, compiler_release: string, target: {id: string, profile: string}, artifact: null | {sha256: string, manifest_sha256: string, file_count: number}}} compilation - * @property {{id: string, status: string, repository: null | {id: number, private: true, owner: {id: number, login: string, type: "User"}, full_name: string, default_branch: string, html_url: string, tree_sha: string | null, commit_sha: string | null}, failure: null | {phase: string, code: string}, created_at: string, started_at: string | null, completed_at: string | null}} publication + * @property {{id: string, status: string, repository: null | {id: number, private: true, owner: {id: number, login: string, type: "User"}, full_name: string, default_branch: string, html_url: string, tree_sha: string | null, commit_sha: string | null}, failure: null | {phase: string, code: string}, progress: {phase: string, retry_at: string | null, retry_count: number, reason_code: string | null}, created_at: string, started_at: string | null, completed_at: string | null}} publication */ /** @@ -195,6 +224,7 @@ export class PublicationCancelledError extends Error { * @property {(timeoutMs: number) => AbortSignal} [createRequestSignal] * @property {(delayMs: number) => Promise} [sleep] * @property {() => number} [now] + * @property {(progress: import("../plan-compile-progress.js").PlanCompileProgress) => void} [onProgress] */ /** @@ -209,6 +239,7 @@ export async function publishPlan({ createRequestSignal = (timeoutMs) => AbortSignal.timeout(timeoutMs), sleep = sleepFor, now = Date.now, + onProgress = () => {}, }) { const state = readPlanState({ cwd, fileSystem }); if (state.api_url === undefined || state.foundation_plan_etag === undefined) { @@ -252,6 +283,8 @@ export async function publishPlan({ const deadline = now() + WAIT_TIMEOUT_MS; let initial; + onProgress({ phase: "compilation", status: "waiting" }); + try { initial = await startPublication({ endpoint, @@ -309,6 +342,7 @@ export async function publishPlan({ } let current = initial; + reportProgress(onProgress, current); while (!TERMINAL_PUBLICATION_STATUSES.has(current.publication.status)) { const remaining = deadline - now(); if (remaining <= 0) throw new PublicationTimeoutError(current); @@ -331,6 +365,7 @@ export async function publishPlan({ throw new PublicationChangedError(current, next); } current = next; + reportProgress(onProgress, current); } if ( @@ -502,6 +537,7 @@ function parsePublicationResponse(value, projectId, headSourceSha256) { !PUBLICATION_STATUSES.has(publication.status) || !isNullableRepository(publication.repository) || !isNullableFailure(publication.failure) || + !isProgress(publication.progress, publication.status) || !isTimestamp(publication.created_at) || !isNullableTimestamp(publication.started_at) || !isNullableTimestamp(publication.completed_at) || @@ -525,6 +561,10 @@ function parsePublicationResponse(value, projectId, headSourceSha256) { const failure = /** @type {null | {phase: string, code: string}} */ ( publication.failure ); + const progress = + /** @type {{phase: string, retry_at: string | null, retry_count: number, reason_code: string | null}} */ ( + publication.progress + ); return { project: { @@ -571,6 +611,12 @@ function parsePublicationResponse(value, projectId, headSourceSha256) { }, failure: failure === null ? null : { phase: failure.phase, code: failure.code }, + progress: { + phase: progress.phase, + retry_at: progress.retry_at, + retry_count: progress.retry_count, + reason_code: progress.reason_code, + }, created_at: /** @type {string} */ (publication.created_at), started_at: /** @type {string | null} */ (publication.started_at), completed_at: /** @type {string | null} */ (publication.completed_at), @@ -670,6 +716,82 @@ function isNullableFailure(value) { ); } +/** @param {unknown} value @param {unknown} publicationStatus */ +function isProgress(value, publicationStatus) { + if ( + !hasExactKeySet(value, PROGRESS_KEYS) || + typeof value.phase !== "string" || + !PROGRESS_PHASES.has(value.phase) || + !Number.isSafeInteger(value.retry_count) || + Number(value.retry_count) < 0 || + Number(value.retry_count) > MAX_PROGRESS_RETRY_COUNT || + !isNullableRetryTimestamp(value.retry_at) || + !( + value.reason_code === null || + (typeof value.reason_code === "string" && + PROGRESS_REASON_CODES.has(value.reason_code)) + ) || + !progressMatchesPublicationStatus(value.phase, publicationStatus) + ) { + return false; + } + + if (value.phase !== "github_preflight") { + return ( + value.retry_at === null && + value.retry_count === 0 && + value.reason_code === null + ); + } + + return value.retry_count === 0 + ? value.retry_at === null && value.reason_code === null + : value.reason_code !== null; +} + +/** @param {string} progressPhase @param {unknown} publicationStatus */ +function progressMatchesPublicationStatus(progressPhase, publicationStatus) { + if (progressPhase === "compiling") return publicationStatus === "compiling"; + if (progressPhase === "completed") return publicationStatus === "succeeded"; + if (progressPhase === "failed") { + return ( + publicationStatus === "failed" || + publicationStatus === "repository_conflict" + ); + } + if (progressPhase === "cancelled") return publicationStatus === "cancelled"; + if (progressPhase === "github_preflight") { + return [ + "provisioning_repository", + "repository_unknown", + "publishing", + "publication_unknown", + ].includes(String(publicationStatus)); + } + + const expectedStatus = new Map([ + ["preparing_repository", "provisioning_repository"], + ["creating_repository", "provisioning_repository"], + ["preparing_repository_reconciliation", "repository_unknown"], + ["reconciling_repository", "repository_unknown"], + ["preparing_artifact", "publishing"], + ["publishing_artifact", "publishing"], + ["preparing_publication_reconciliation", "publication_unknown"], + ["reconciling_publication", "publication_unknown"], + ]).get(progressPhase); + return publicationStatus === expectedStatus; +} + +/** @param {unknown} value */ +function isNullableRetryTimestamp(value) { + return ( + value === null || + (typeof value === "string" && + RETRY_TIMESTAMP_PATTERN.test(value) && + isTimestamp(value)) + ); +} + /** @param {unknown} value */ function isNullableRepository(value) { if (value === null) return true; @@ -817,6 +939,25 @@ function validRepositoryTransition(previous, current) { ); } +/** + * @param {(progress: import("../plan-compile-progress.js").PlanCompileProgress) => void} onProgress + * @param {PublicationResponse} current + */ +function reportProgress(onProgress, current) { + const progress = current.publication.progress; + onProgress({ + phase: "publication", + compilationStatus: + /** @type {"queued" | "running" | "succeeded" | "failed" | "cancelled"} */ ( + current.compilation.status + ), + publicationPhase: progress.phase, + retryAt: progress.retry_at, + retryCount: progress.retry_count, + reasonCode: progress.reason_code, + }); +} + /** @param {Response} response @param {unknown} body */ function safeProblem(response, body) { if (!isProblemBody(response, body)) return null; diff --git a/src/plan-compile-progress.js b/src/plan-compile-progress.js new file mode 100644 index 0000000..38f80b1 --- /dev/null +++ b/src/plan-compile-progress.js @@ -0,0 +1,111 @@ +const PREFIX = "First Draft: "; + +const PUBLICATION_MESSAGES = new Map([ + ["preparing_repository", "Preparing private GitHub repository..."], + ["creating_repository", "Creating private GitHub repository..."], + [ + "preparing_repository_reconciliation", + "Preparing to verify GitHub repository creation...", + ], + ["reconciling_repository", "Verifying GitHub repository creation..."], + ["preparing_artifact", "Preparing compiled application..."], + ["publishing_artifact", "Publishing compiled application to GitHub..."], + [ + "preparing_publication_reconciliation", + "Preparing to verify GitHub publication...", + ], + ["reconciling_publication", "Verifying GitHub publication..."], + ["completed", "GitHub publication complete."], + ["failed", "GitHub publication failed."], + ["cancelled", "GitHub publication cancelled."], +]); + +/** + * @typedef {object} AnalysisProgress + * @property {"analysis"} phase + * @property {"waiting" | "valid"} status + */ + +/** + * @typedef {object} CompilationProgress + * @property {"compilation"} phase + * @property {"waiting"} status + */ + +/** + * @typedef {object} PublicationProgress + * @property {"publication"} phase + * @property {"queued" | "running" | "succeeded" | "failed" | "cancelled"} compilationStatus + * @property {string} publicationPhase + * @property {string | null} retryAt + * @property {number} retryCount + * @property {string | null} reasonCode + */ + +/** + * @typedef {AnalysisProgress | CompilationProgress | PublicationProgress} PlanCompileProgress + */ + +/** + * Convert deliberately narrow lifecycle observations into stable progress. + * This boundary never receives response projections, identifiers, hashes, + * repository names, URLs, or local state. + * + * @param {{write: (text: string) => unknown}} writer + * @returns {(progress: PlanCompileProgress) => void} + */ +export function createPlanCompileProgressReporter(writer) { + let compilationSucceeded = false; + /** @type {string | null} */ + let previousProgress = null; + + /** @param {string} message */ + function write(message) { + writer.write(`${PREFIX}${message}\n`); + } + + return (progress) => { + const serialized = JSON.stringify(progress); + if (serialized === previousProgress) return; + previousProgress = serialized; + + if (progress.phase === "analysis") { + write( + progress.status === "waiting" + ? "Analyzing Foundation Plan..." + : "Foundation Plan analysis valid.", + ); + return; + } + + if (progress.phase === "compilation") { + write("Compiling application..."); + return; + } + + if (progress.compilationStatus === "succeeded" && !compilationSucceeded) { + write("Application compiled."); + compilationSucceeded = true; + } + + if (progress.publicationPhase === "compiling") return; + if (progress.publicationPhase === "github_preflight") { + write(githubPreflightMessage(progress)); + return; + } + + const message = PUBLICATION_MESSAGES.get(progress.publicationPhase); + if (message !== undefined) write(message); + }; +} + +/** @param {PublicationProgress} progress */ +function githubPreflightMessage(progress) { + if (progress.retryCount === 0) return "Checking GitHub access..."; + + const retry = + progress.retryAt === null + ? "automatic retries paused; operator recovery required" + : `next retry: ${progress.retryAt}`; + return `Checking GitHub access (reason: ${progress.reasonCode}; retry count: ${progress.retryCount}; ${retry}).`; +} diff --git a/test/plan-compile.test.js b/test/plan-compile.test.js index 90d9312..1b0f17b 100644 --- a/test/plan-compile.test.js +++ b/test/plan-compile.test.js @@ -22,6 +22,13 @@ const ETAG = `"sha256:${HEAD_SHA256}"`; const CREATED_AT = "2026-08-04T12:00:00.000Z"; const STARTED_AT = "2026-08-04T12:00:01.000Z"; const COMPLETED_AT = "2026-08-04T12:00:02.000Z"; +const REPOSITORY_URL = "https://github.com/octocat/movie-catalog"; +const SUCCESS_PROGRESS = `First Draft: Analyzing Foundation Plan... +First Draft: Foundation Plan analysis valid. +First Draft: Compiling application... +First Draft: Application compiled. +First Draft: GitHub publication complete. +`; test("plan compile submits exact bytes, waits for valid analysis, and publishes once", async (context) => { /** @type {{method: string | undefined, url: string | undefined, headers: import("node:http").IncomingHttpHeaders, body: Buffer}[]} */ @@ -54,8 +61,8 @@ test("plan compile submits exact bytes, waits for valid analysis, and publishes const result = await invoke(["plan", "compile"], { cwd, apiUrl }); assert.equal(result.status, 0); - assert.equal(result.stderr, ""); - assert.deepEqual(result.stdoutWrites, [jsonLine(publicationBody())]); + assert.equal(result.stderr, SUCCESS_PROGRESS); + assert.deepEqual(result.stdoutWrites, [`${REPOSITORY_URL}\n`]); assert.deepEqual( requests.map(({ method, url }) => [method, url]), [ @@ -97,15 +104,27 @@ test("plan compile may push unchanged bytes before analysis and Publication", as order.push(["analysis", options.wait]); return { status: 200, body: analysisBody("valid") }; }, - planCompilePublish: async () => { + planCompilePublish: async ( + /** @type {{onProgress: (progress: unknown) => void}} */ options, + ) => { order.push(["publication"]); + options.onProgress({ phase: "compilation", status: "waiting" }); + options.onProgress({ + phase: "publication", + compilationStatus: "succeeded", + publicationPhase: "completed", + retryAt: null, + retryCount: 0, + reasonCode: null, + }); return expected; }, }); assert.equal(result.status, 0); assert.deepEqual(order, [["push", cwd], ["analysis", true], ["publication"]]); - assert.deepEqual(JSON.parse(result.stdout), expected); + assert.equal(result.stdout, `${REPOSITORY_URL}\n`); + assert.equal(result.stderr, SUCCESS_PROGRESS); }); test("plan compile waits past a terminal analysis for the prior graph version", async (context) => { @@ -170,7 +189,10 @@ test("invalid JSON and schema diagnostics stop before analysis or Publication", assertHandledFailure(result, "server_rejected"); assert.equal(calls.length, 1); - assert.equal(JSON.parse(result.stderr).response.diagnostics[0].code, code); + assert.equal( + errorEnvelope(result.stderr).response.diagnostics[0].code, + code, + ); } }); @@ -193,7 +215,7 @@ test("semantic and failed analysis stop before Publication with structured statu }); assertHandledFailure(result, "plan_not_valid"); - assert.deepEqual(JSON.parse(result.stderr).current, current); + assert.deepEqual(errorEnvelope(result.stderr).current, current); assert.equal(publications, 0); } }); @@ -219,7 +241,7 @@ test("recurring diagnostics remain repairable and never trigger Publication", as const result = await invoke(["plan", "compile"], options); assertHandledFailure(result, "plan_not_valid"); assert.equal( - JSON.parse(result.stderr).current.analysis.diagnostics[0].code, + errorEnvelope(result.stderr).current.analysis.diagnostics[0].code, "reference_missing", ); } @@ -275,7 +297,7 @@ test("push ambiguity, analysis failures, and rejected reads have distinct errors }, }); assertHandledFailure(push, "request_outcome_unknown"); - assert.equal(JSON.parse(push.stderr).phase, "push"); + assert.equal(errorEnvelope(push.stderr).phase, "push"); assert.doesNotMatch(push.stderr, /canary/); /** @type {[Response | (() => Promise), string][]} */ @@ -407,11 +429,17 @@ function publicationBody() { owner: { id: 456, login: "octocat", type: "User" }, full_name: "octocat/movie-catalog", default_branch: "main", - html_url: "https://github.com/octocat/movie-catalog", + html_url: REPOSITORY_URL, tree_sha: "3".repeat(40), commit_sha: "4".repeat(40), }, failure: null, + progress: { + phase: "completed", + retry_at: null, + retry_count: 0, + reason_code: null, + }, created_at: CREATED_AT, started_at: STARTED_AT, completed_at: COMPLETED_AT, @@ -492,7 +520,16 @@ async function invoke(argv, options = {}) { function assertHandledFailure(result, error, status = 1) { assert.equal(result.status, status); assert.equal(result.stdout, ""); - assert.equal(JSON.parse(result.stderr).error, error); + assert.equal(errorEnvelope(result.stderr).error, error); +} + +/** @param {string} stderr */ +function errorEnvelope(stderr) { + const structured = stderr + .split("\n") + .filter((line) => !line.startsWith("First Draft: ")) + .join("\n"); + return JSON.parse(structured); } /** @param {(Response | (() => Promise))[]} responses @param {unknown[]} [calls] */ @@ -568,8 +605,3 @@ async function listen(context, server) { function sha256(value) { return createHash("sha256").update(value).digest("hex"); } - -/** @param {unknown} value */ -function jsonLine(value) { - return `${JSON.stringify(value, null, 2)}\n`; -} diff --git a/test/plan-publish.test.js b/test/plan-publish.test.js index 59519ec..edf7ad4 100644 --- a/test/plan-publish.test.js +++ b/test/plan-publish.test.js @@ -23,6 +23,7 @@ const API_TOKEN = `fd_${"a".repeat(43)}`; const CREATED_AT = "2026-08-01T12:00:00.000Z"; const STARTED_AT = "2026-08-01T12:00:01.000Z"; const COMPLETED_AT = "2026-08-01T12:00:02.000Z"; +const RETRY_AT = "2026-08-07T16:15:00.000000Z"; const COMPILER_RELEASE = "foundation-plan-rails/compiler-2026-08"; const TARGET = { id: "rails", profile: "rails-sketch/2026-08" }; const ARTIFACT = { @@ -42,6 +43,23 @@ const REPOSITORY = { tree_sha: TREE_SHA, commit_sha: COMMIT_SHA, }; +const REPOSITORY_URL = REPOSITORY.html_url; +const REPLAY_PROGRESS = `First Draft: Analyzing Foundation Plan... +First Draft: Foundation Plan analysis valid. +First Draft: Compiling application... +First Draft: Application compiled. +First Draft: GitHub publication complete. +`; +const LIFECYCLE_PROGRESS = `First Draft: Analyzing Foundation Plan... +First Draft: Foundation Plan analysis valid. +First Draft: Compiling application... +First Draft: Application compiled. +First Draft: Preparing private GitHub repository... +First Draft: Preparing to verify GitHub repository creation... +First Draft: Preparing compiled application... +First Draft: Preparing to verify GitHub publication... +First Draft: GitHub publication complete. +`; const PLAN_COMPILE_HELP = `First Draft CLI Usage: @@ -55,9 +73,9 @@ Environment: FIRSTDRAFT_API_URL Override the initial API origin The command submits the exact current whole-file Plan, waits for its analysis, -and proceeds only when that analysis is valid. It then conditionally creates -or replays the internal GitHub Publication lifecycle and prints its complete -validated Project, Compilation, and Publication projection. +and proceeds only when that analysis is valid. It then conditionally creates or +replays the internal GitHub Publication lifecycle. Progress is written to +stderr. Success prints only the validated private GitHub repository URL. `; test("plan compile invokes Publication and one conditional singleton PUT and polls sequentially", async (context) => { @@ -121,8 +139,8 @@ test("plan compile invokes Publication and one conditional singleton PUT and pol assert.deepEqual(result, { status: 0, - stdout: jsonLine(publicationBody("succeeded")), - stderr: "", + stdout: `${REPOSITORY_URL}\n`, + stderr: LIFECYCLE_PROGRESS, }); assert.deepEqual(delays, [1000, 1000, 1000, 1000, 1000, 1000]); assert.deepEqual( @@ -162,6 +180,174 @@ test("plan compile invokes Publication and one conditional singleton PUT and pol assert.doesNotMatch(result.stdout, /canary-secret/); }); +test("progress reports each safe GitHub phase, scheduled retry, and parked retry once", async (context) => { + const cwd = remoteDirectory(context, "https://api.example.test"); + const scheduled = { + phase: "github_preflight", + retry_at: RETRY_AT, + retry_count: 2, + reason_code: "github.api_unavailable", + }; + const parked = { + phase: "github_preflight", + retry_at: null, + retry_count: 7, + reason_code: "github.installation_not_ready", + }; + const responses = [ + jsonResponse(publicationBody("compiling"), 201), + jsonResponse( + publicationBody("compiling", { + compilation: { status: "running" }, + }), + ), + jsonResponse( + publicationBody("compiling", { + compilation: { status: "running" }, + }), + ), + jsonResponse( + publicationBody("compiling", { + compilation: { status: "succeeded", artifact: ARTIFACT }, + }), + ), + jsonResponse(publicationBody("provisioning_repository")), + jsonResponse( + publicationBody("provisioning_repository", { + publication: { + progress: { + phase: "github_preflight", + retry_at: null, + retry_count: 0, + reason_code: null, + }, + }, + }), + ), + jsonResponse( + publicationBody("provisioning_repository", { + publication: { + progress: { + phase: "github_preflight", + retry_at: null, + retry_count: 0, + reason_code: null, + }, + }, + }), + ), + jsonResponse( + publicationBody("provisioning_repository", { + publication: { progress: scheduled }, + }), + ), + jsonResponse( + publicationBody("provisioning_repository", { + publication: { progress: scheduled }, + }), + ), + jsonResponse( + publicationBody("provisioning_repository", { + publication: { progress: parked }, + }), + ), + jsonResponse( + publicationBody("provisioning_repository", { + publication: { + progress: defaultProgress("provisioning_repository", { + phase: "creating_repository", + }), + }, + }), + ), + jsonResponse(publicationBody("repository_unknown")), + jsonResponse( + publicationBody("repository_unknown", { + publication: { + progress: defaultProgress("repository_unknown", { + phase: "reconciling_repository", + }), + }, + }), + ), + jsonResponse(publicationBody("publishing")), + jsonResponse( + publicationBody("publishing", { + publication: { + progress: { + phase: "github_preflight", + retry_at: null, + retry_count: 0, + reason_code: null, + }, + }, + }), + ), + jsonResponse( + publicationBody("publishing", { + publication: { + progress: defaultProgress("publishing", { + phase: "publishing_artifact", + }), + }, + }), + ), + jsonResponse(publicationBody("publication_unknown")), + jsonResponse( + publicationBody("publication_unknown", { + publication: { + progress: defaultProgress("publication_unknown", { + phase: "reconciling_publication", + }), + }, + }), + ), + jsonResponse(publicationBody("succeeded")), + ]; + + const result = await invoke(["plan", "compile"], { + cwd, + fetchFunction: sequenceFetch(responses), + planPublishSleep: async () => {}, + }); + + assert.equal(result.status, 0); + assert.equal(result.stdout, `${REPOSITORY_URL}\n`); + assert.equal( + result.stderr, + `First Draft: Analyzing Foundation Plan... +First Draft: Foundation Plan analysis valid. +First Draft: Compiling application... +First Draft: Application compiled. +First Draft: Preparing private GitHub repository... +First Draft: Checking GitHub access... +First Draft: Checking GitHub access (reason: github.api_unavailable; retry count: 2; next retry: ${RETRY_AT}). +First Draft: Checking GitHub access (reason: github.installation_not_ready; retry count: 7; automatic retries paused; operator recovery required). +First Draft: Creating private GitHub repository... +First Draft: Preparing to verify GitHub repository creation... +First Draft: Verifying GitHub repository creation... +First Draft: Preparing compiled application... +First Draft: Checking GitHub access... +First Draft: Publishing compiled application to GitHub... +First Draft: Preparing to verify GitHub publication... +First Draft: Verifying GitHub publication... +First Draft: GitHub publication complete. +`, + ); + for (const privateValue of [ + PROJECT_ID, + COMPILATION_ID, + PUBLICATION_ID, + HEAD_SHA256, + TREE_SHA, + COMMIT_SHA, + REPOSITORY.full_name, + REPOSITORY_URL, + ]) { + assert.doesNotMatch(result.stderr, new RegExp(privateValue)); + } +}); + test("a repeated singleton PUT accepts provenance matching local Plan state", async (context) => { const cwd = remoteDirectory(context, "https://api.example.test"); /** @type {FetchCall[]} */ @@ -175,7 +361,8 @@ test("a repeated singleton PUT accepts provenance matching local Plan state", as }); assert.equal(result.status, 0); - assert.equal(result.stdout, jsonLine(publicationBody("succeeded"))); + assert.equal(result.stdout, `${REPOSITORY_URL}\n`); + assert.equal(result.stderr, REPLAY_PROGRESS); assert.equal(calls.length, 1); assert.equal(calls[0]?.init?.method, "PUT"); assert.equal(new Headers(calls[0]?.init?.headers).get("if-match"), ETAG); @@ -200,8 +387,8 @@ test("an ambiguous PUT is reconciled by one safe singleton GET", async (context) assert.deepEqual(result, { status: 0, - stdout: jsonLine(publicationBody("succeeded")), - stderr: "", + stdout: `${REPOSITORY_URL}\n`, + stderr: REPLAY_PROGRESS, }); assert.deepEqual( calls.map(({ init }) => init?.method), @@ -240,10 +427,10 @@ test("an ambiguous PUT does not adopt a singleton from a different Plan Head", a }); assertHandledFailure(result, "request_outcome_unknown"); - assert.equal(JSON.parse(result.stderr).status, 200); + assert.equal(errorEnvelope(result.stderr).status, 200); assert.match( - JSON.parse(result.stderr).detail, - /Running 'firstdraft plan compile' again is safe/, + errorEnvelope(result.stderr).detail, + /Do not run concurrent Compile commands.*unchanged Plan bytes.*retained singleton/, ); assert.deepEqual( calls.map(({ init }) => init?.method), @@ -271,8 +458,8 @@ test("an invalid successful PUT response can reconcile to the exact singleton", assert.deepEqual(result, { status: 0, - stdout: jsonLine(publicationBody("succeeded")), - stderr: "", + stdout: `${REPOSITORY_URL}\n`, + stderr: REPLAY_PROGRESS, }); assert.deepEqual( calls.map(({ init }) => init?.method), @@ -299,9 +486,9 @@ test("an unresolved ambiguous PUT remains outcome unknown without replaying the }); assertHandledFailure(result, "request_outcome_unknown"); - assert.equal(JSON.parse(result.stderr).status, 404); + assert.equal(errorEnvelope(result.stderr).status, 404); assert.equal( - JSON.parse(result.stderr).response.code, + errorEnvelope(result.stderr).response.code, "publication_not_found", ); assert.deepEqual( @@ -438,7 +625,7 @@ test("missing and rejected credentials use the stable authentication error", asy ]), }); assertHandledFailure(rejected, "authentication_required"); - assert.equal(JSON.parse(rejected.stderr).status, 401); + assert.equal(errorEnvelope(rejected.stderr).status, 401); const reconciliation = await invoke(["plan", "compile"], { cwd, @@ -454,7 +641,7 @@ test("missing and rejected credentials use the stable authentication error", asy ]), }); assertHandledFailure(reconciliation, "authentication_required"); - assert.equal(JSON.parse(reconciliation.stderr).status, 401); + assert.equal(errorEnvelope(reconciliation.stderr).status, 401); }); test("validated start rejections are distinct from unknown mutation outcomes", async (context) => { @@ -466,7 +653,7 @@ test("validated start rejections are distinct from unknown mutation outcomes", a ]), }); - assert.deepEqual(JSON.parse(rejected.stderr), { + assert.deepEqual(errorEnvelope(rejected.stderr), { error: "publication_start_rejected", detail: "First Draft rejected the publication request.", status: 412, @@ -508,8 +695,8 @@ test("validated timeout and server errors reconcile without replaying the PUT", assert.deepEqual(result, { status: 0, - stdout: jsonLine(publicationBody("succeeded")), - stderr: "", + stdout: `${REPOSITORY_URL}\n`, + stderr: REPLAY_PROGRESS, }); assert.deepEqual( calls.map(({ init }) => init?.method), @@ -527,8 +714,8 @@ test("validated timeout and server errors reconcile without replaying the PUT", }); assertHandledFailure(unresolved, "request_outcome_unknown"); - assert.equal(JSON.parse(unresolved.stderr).status, 503); - assert.deepEqual(JSON.parse(unresolved.stderr).response, { + assert.equal(errorEnvelope(unresolved.stderr).status, 503); + assert.deepEqual(errorEnvelope(unresolved.stderr).response, { type: "about:blank", title: "Service Unavailable", status: 503, @@ -549,11 +736,22 @@ test("polling distinguishes unavailable and invalid status responses", async (co planPublishSleep: async () => {}, }); assertHandledFailure(unavailable, "publication_status_unavailable"); - assert.equal(JSON.parse(unavailable.stderr).status, 503); assert.equal( - JSON.parse(unavailable.stderr).response.code, + progressOutput(unavailable.stderr), + `First Draft: Analyzing Foundation Plan... +First Draft: Foundation Plan analysis valid. +First Draft: Compiling application... +`, + ); + assert.equal(errorEnvelope(unavailable.stderr).status, 503); + assert.equal( + errorEnvelope(unavailable.stderr).response.code, "publication_unavailable", ); + assert.match( + errorEnvelope(unavailable.stderr).detail, + /Do not run concurrent Compile commands.*unchanged Plan bytes.*retained singleton/, + ); const invalidCwd = remoteDirectory(context, "https://api.example.test"); const invalidBody = { @@ -622,7 +820,7 @@ test("polling rejects replacement identities, regressions, and repository mutati }); assertHandledFailure(result, "publication_changed"); - const envelope = JSON.parse(result.stderr); + const envelope = errorEnvelope(result.stderr); assert.deepEqual(envelope.current, initial); assert.deepEqual(envelope.rejected, changed); } @@ -644,9 +842,13 @@ test("the bounded wait reports its last validated status", async (context) => { assertHandledFailure(result, "publication_wait_timed_out"); assert.equal( - JSON.parse(result.stderr).current.publication.status, + errorEnvelope(result.stderr).current.publication.status, "compiling", ); + assert.match( + errorEnvelope(result.stderr).detail, + /stopped waiting.*Do not run concurrent Compile commands.*unchanged Plan bytes.*retained singleton/, + ); }); test("failed, conflicted, and cancelled publications preserve terminal status", async (context) => { @@ -667,7 +869,10 @@ test("failed, conflicted, and cancelled publications preserve terminal status", }); assertHandledFailure(result, expectedError); - assert.equal(JSON.parse(result.stderr).current.publication.status, status); + assert.equal( + errorEnvelope(result.stderr).current.publication.status, + status, + ); } for (const status of ["failed", "cancelled"]) { @@ -687,7 +892,7 @@ test("failed, conflicted, and cancelled publications preserve terminal status", status === "failed" ? "publication_failed" : "publication_cancelled", ); assert.equal( - JSON.parse(result.stderr).current.publication.started_at, + errorEnvelope(result.stderr).current.publication.started_at, null, ); } @@ -696,6 +901,86 @@ test("failed, conflicted, and cancelled publications preserve terminal status", test("exact response shapes and coherent terminal projections are required", async (context) => { const invalidBodies = [ { ...publicationBody("succeeded"), canary: "canary-secret" }, + withoutPublicationProgress(publicationBody("succeeded")), + publicationBody("succeeded", { + publication: { + progress: { + ...defaultProgress("succeeded"), + additive: "canary-secret", + }, + }, + }), + publicationBody("succeeded", { + publication: { + progress: defaultProgress("succeeded", { + phase: "canary-secret", + }), + }, + }), + publicationBody("succeeded", { + publication: { + progress: defaultProgress("succeeded", { + phase: "preparing_repository", + }), + }, + }), + publicationBody("provisioning_repository", { + publication: { + progress: defaultProgress("provisioning_repository", { + retry_count: 1, + }), + }, + }), + publicationBody("provisioning_repository", { + publication: { + progress: { + phase: "github_preflight", + retry_at: null, + retry_count: 0, + reason_code: "github.api_unavailable", + }, + }, + }), + publicationBody("provisioning_repository", { + publication: { + progress: { + phase: "github_preflight", + retry_at: null, + retry_count: 1, + reason_code: null, + }, + }, + }), + publicationBody("provisioning_repository", { + publication: { + progress: { + phase: "github_preflight", + retry_at: RETRY_AT, + retry_count: 8, + reason_code: "github.api_unavailable", + }, + }, + }), + publicationBody("provisioning_repository", { + publication: { + progress: { + phase: "github_preflight", + retry_at: "2026-08-07T16:15:00.000Z", + retry_count: 1, + reason_code: "github.api_unavailable", + }, + }, + }), + publicationBody("provisioning_repository", { + publication: { + progress: { + phase: "github_preflight", + retry_at: RETRY_AT, + retry_count: 1, + reason_code: "github.canary-secret", + }, + }, + }), publicationBody("succeeded", { project: { head_source_sha256: "f".repeat(64) }, }), @@ -822,6 +1107,7 @@ function publicationBody(status, changes = {}) { status, repository, failure, + progress: defaultProgress(status), created_at: CREATED_AT, started_at: status === "compiling" ? null : STARTED_AT, completed_at: terminal ? COMPLETED_AT : null, @@ -830,6 +1116,38 @@ function publicationBody(status, changes = {}) { }; } +/** @param {string} status @param {Record} [changes] */ +function defaultProgress(status, changes = {}) { + /** @type {Record} */ + const phases = { + compiling: "compiling", + provisioning_repository: "preparing_repository", + repository_unknown: "preparing_repository_reconciliation", + publishing: "preparing_artifact", + publication_unknown: "preparing_publication_reconciliation", + succeeded: "completed", + repository_conflict: "failed", + failed: "failed", + cancelled: "cancelled", + }; + return { + phase: phases[status], + retry_at: null, + retry_count: 0, + reason_code: null, + ...changes, + }; +} + +/** @param {ReturnType} body */ +function withoutPublicationProgress(body) { + const publication = /** @type {Record} */ ({ + ...body.publication, + }); + delete publication.progress; + return { ...body, publication }; +} + function publicationPath() { return `/v1/projects/${PROJECT_ID}/github-publication`; } @@ -995,11 +1313,6 @@ function validAnalysis() { }; } -/** @param {unknown} value */ -function jsonLine(value) { - return `${JSON.stringify(value, null, 2)}\n`; -} - /** * @param {{status: number, stdout: string, stderr: string}} result * @param {string} error @@ -1008,12 +1321,29 @@ function jsonLine(value) { function assertHandledFailure(result, error, status = 1) { assert.equal(result.status, status); assert.equal(result.stdout, ""); - assert.equal(JSON.parse(result.stderr).error, error); + assert.equal(errorEnvelope(result.stderr).error, error); if (error === "request_outcome_unknown") { - assert.equal(JSON.parse(result.stderr).phase, "publication"); + assert.equal(errorEnvelope(result.stderr).phase, "publication"); } } +/** @param {string} stderr */ +function errorEnvelope(stderr) { + const structured = stderr + .split("\n") + .filter((line) => !line.startsWith("First Draft: ")) + .join("\n"); + return JSON.parse(structured); +} + +/** @param {string} stderr */ +function progressOutput(stderr) { + const lines = stderr + .split("\n") + .filter((line) => line.startsWith("First Draft: ")); + return lines.length === 0 ? "" : `${lines.join("\n")}\n`; +} + function inaccessibleFetch() { return async () => { throw new Error("network must remain inaccessible"); diff --git a/test/release-compatibility.test.js b/test/release-compatibility.test.js index e52bfdf..6220688 100644 --- a/test/release-compatibility.test.js +++ b/test/release-compatibility.test.js @@ -35,8 +35,8 @@ test("release compatibility declares the coordinated CLI contract", () => { "foundation_plan_formats", ]); assert.deepEqual(compatibility.requires.api_contract, [ - ">= 0.1.0", - "< 0.2.0", + ">= 0.2.0", + "< 0.3.0", ]); assert.deepEqual(compatibility.requires.foundation_plan_formats, [ FOUNDATION_PLAN_FORMAT, From 176546f8b0edb59f0cb51e7b0b97bc073bd599d6 Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Fri, 7 Aug 2026 13:32:01 -0500 Subject: [PATCH 2/4] Distinguish Compilation terminal progress Publication failure can mirror an unsuccessful Compilation before any GitHub work. Select terminal progress from the validated Compilation state so the CLI never attributes that outcome to GitHub. --- README.md | 12 ++++---- src/plan-compile-progress.js | 18 ++++++++++-- test/plan-publish.test.js | 55 +++++++++++++++++++++++++++++++----- 3 files changed, 70 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 7384441..8648363 100644 --- a/README.md +++ b/README.md @@ -166,12 +166,12 @@ Plan and requires its exact bytes to match the accepted Head, so bytes changed a It extracts the accepted source SHA-256 from the saved ETag, hashes the current local bytes, and then sends that complete ETag in `If-Match`. The command writes stable human-readable progress to stderr, with every line prefixed by `First Draft:`. It reports -analysis, completed compilation, the current GitHub phase, and an allowlisted reason, retry count, and exact UTC -retry time when a GitHub preflight check is delayed. A retained retry with no next time is reported as paused and -requiring operator recovery. Progress never includes IDs, hashes, repository names or URLs, raw server projections, -local paths, or environment values. Success writes exactly the validated private GitHub repository URL plus a -newline to stdout. If the command fails after progress has begun, its existing structured JSON error envelope is the -final stderr document after the progress lines. +analysis, compilation completion or terminal failure or cancellation, the current GitHub phase, and an allowlisted +reason, retry count, and exact UTC retry time when a GitHub preflight check is delayed. A retained retry with no next +time is reported as paused and requiring operator recovery. Progress never includes IDs, hashes, repository names or +URLs, raw server projections, local paths, or environment values. Success writes exactly the validated private +GitHub repository URL plus a newline to stdout. If the command fails after progress has begun, its existing +structured JSON error envelope is the final stderr document after the progress lines. The internal Publication is a Project singleton in this release. A repeat safely receives the same Publication instead of creating another. If the first conditional `PUT` has an ambiguous result, the CLI reconciles it with diff --git a/src/plan-compile-progress.js b/src/plan-compile-progress.js index 38f80b1..e60f24a 100644 --- a/src/plan-compile-progress.js +++ b/src/plan-compile-progress.js @@ -16,8 +16,6 @@ const PUBLICATION_MESSAGES = new Map([ ], ["reconciling_publication", "Verifying GitHub publication..."], ["completed", "GitHub publication complete."], - ["failed", "GitHub publication failed."], - ["cancelled", "GitHub publication cancelled."], ]); /** @@ -89,6 +87,22 @@ export function createPlanCompileProgressReporter(writer) { } if (progress.publicationPhase === "compiling") return; + if (progress.publicationPhase === "failed") { + write( + progress.compilationStatus === "failed" + ? "Application compilation failed." + : "GitHub publication failed.", + ); + return; + } + if (progress.publicationPhase === "cancelled") { + write( + progress.compilationStatus === "cancelled" + ? "Application compilation cancelled." + : "GitHub publication cancelled.", + ); + return; + } if (progress.publicationPhase === "github_preflight") { write(githubPreflightMessage(progress)); return; diff --git a/test/plan-publish.test.js b/test/plan-publish.test.js index edf7ad4..ccc8578 100644 --- a/test/plan-publish.test.js +++ b/test/plan-publish.test.js @@ -851,24 +851,65 @@ test("the bounded wait reports its last validated status", async (context) => { ); }); -test("failed, conflicted, and cancelled publications preserve terminal status", async (context) => { - /** @type {[string, string][]} */ +test("terminal progress distinguishes Compilation outcomes from later GitHub outcomes", async (context) => { + /** @type {[string, string, Parameters[1], string][]} */ const cases = [ - ["failed", "publication_failed"], - ["repository_conflict", "publication_failed"], - ["cancelled", "publication_cancelled"], + [ + "failed", + "publication_failed", + { publication: { started_at: null } }, + "Application compilation failed.", + ], + [ + "cancelled", + "publication_cancelled", + { publication: { started_at: null } }, + "Application compilation cancelled.", + ], + [ + "failed", + "publication_failed", + { + compilation: { status: "succeeded", artifact: ARTIFACT }, + publication: { + failure: { phase: "publish", code: "publication_failed" }, + }, + }, + "GitHub publication failed.", + ], + [ + "cancelled", + "publication_cancelled", + { compilation: { status: "succeeded", artifact: ARTIFACT } }, + "GitHub publication cancelled.", + ], + [ + "repository_conflict", + "publication_failed", + { compilation: { status: "succeeded", artifact: ARTIFACT } }, + "GitHub publication failed.", + ], ]; - for (const [status, expectedError] of cases) { + for (const [status, expectedError, changes, terminalProgress] of cases) { const cwd = remoteDirectory(context, "https://api.example.test"); const result = await invoke(["plan", "compile"], { cwd, fetchFunction: sequenceFetch([ - jsonResponse(publicationBody(status), 201), + jsonResponse(publicationBody(status, changes), 201), ]), }); assertHandledFailure(result, expectedError); + const compiled = changes?.compilation?.status === "succeeded"; + assert.equal( + progressOutput(result.stderr), + `First Draft: Analyzing Foundation Plan... +First Draft: Foundation Plan analysis valid. +First Draft: Compiling application... +${compiled ? "First Draft: Application compiled.\n" : ""}First Draft: ${terminalProgress} +`, + ); assert.equal( errorEnvelope(result.stderr).current.publication.status, status, From 2a1e4a95b2a89cd7890ca001eb72c97376c7e018 Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Fri, 7 Aug 2026 13:37:45 -0500 Subject: [PATCH 3/4] Accept finalized preflight reason codes Preserve the closed safe-output boundary while accepting legacy preflight evidence and stage-specific fallback classifications from the coordinated API 0.2 contract. --- README.md | 9 +++++ src/commands/plan-publish.js | 8 +++++ test/plan-publish.test.js | 69 ++++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+) diff --git a/README.md b/README.md index 8648363..ec0290c 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,15 @@ URLs, raw server projections, local paths, or environment values. Success writes GitHub repository URL plus a newline to stdout. If the command fails after progress has begun, its existing structured JSON error envelope is the final stderr document after the progress lines. +The closed API `0.2.x` progress-reason allowlist is `github.configuration_missing`, `github.oauth_unavailable`, +`github.api_unavailable`, `github.reauthorization_required`, `github.account_mismatch`, +`github.installation_unavailable`, `github.installation_not_ready`, `github.preflight_unavailable`, the legacy-only +`github.preflight_unclassified`, and these stage-specific fallbacks: `github.preflight_unavailable.configuration`, +`github.preflight_unavailable.authorization`, `github.preflight_unavailable.repository_client`, +`github.preflight_unavailable.artifact_preparation`, `github.preflight_unavailable.installation_token`, +`github.preflight_unavailable.publication_preparation`, and `github.preflight_unavailable.repository_ref_client`. +Other values make the response invalid rather than becoming terminal output. + The internal Publication is a Project singleton in this release. A repeat safely receives the same Publication instead of creating another. If the first conditional `PUT` has an ambiguous result, the CLI reconciles it with one read-only singleton `GET` and never automatically repeats the mutation within that invocation. Do not run diff --git a/src/commands/plan-publish.js b/src/commands/plan-publish.js index 858790d..610c617 100644 --- a/src/commands/plan-publish.js +++ b/src/commands/plan-publish.js @@ -111,6 +111,14 @@ const PROGRESS_REASON_CODES = new Set([ "github.installation_unavailable", "github.installation_not_ready", "github.preflight_unavailable", + "github.preflight_unclassified", + "github.preflight_unavailable.configuration", + "github.preflight_unavailable.authorization", + "github.preflight_unavailable.repository_client", + "github.preflight_unavailable.artifact_preparation", + "github.preflight_unavailable.installation_token", + "github.preflight_unavailable.publication_preparation", + "github.preflight_unavailable.repository_ref_client", ]); const MAX_PROGRESS_RETRY_COUNT = 7; const RETRY_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z$/; diff --git a/test/plan-publish.test.js b/test/plan-publish.test.js index ccc8578..fe9e556 100644 --- a/test/plan-publish.test.js +++ b/test/plan-publish.test.js @@ -33,6 +33,24 @@ const ARTIFACT = { }; const TREE_SHA = "3".repeat(40); const COMMIT_SHA = "4".repeat(40); +const SAFE_PROGRESS_REASON_CODES = [ + "github.configuration_missing", + "github.oauth_unavailable", + "github.api_unavailable", + "github.reauthorization_required", + "github.account_mismatch", + "github.installation_unavailable", + "github.installation_not_ready", + "github.preflight_unavailable", + "github.preflight_unclassified", + "github.preflight_unavailable.configuration", + "github.preflight_unavailable.authorization", + "github.preflight_unavailable.repository_client", + "github.preflight_unavailable.artifact_preparation", + "github.preflight_unavailable.installation_token", + "github.preflight_unavailable.publication_preparation", + "github.preflight_unavailable.repository_ref_client", +]; const REPOSITORY = { id: 1_234_567, private: true, @@ -348,6 +366,47 @@ First Draft: GitHub publication complete. } }); +test("progress accepts every coordinated safe reason code", async (context) => { + const cwd = remoteDirectory(context, "https://api.example.test"); + const responses = SAFE_PROGRESS_REASON_CODES.map((reasonCode) => + jsonResponse( + publicationBody("provisioning_repository", { + publication: { + progress: { + phase: "github_preflight", + retry_at: RETRY_AT, + retry_count: 1, + reason_code: reasonCode, + }, + }, + }), + ), + ); + responses.push(jsonResponse(publicationBody("succeeded"))); + + const result = await invoke(["plan", "compile"], { + cwd, + fetchFunction: sequenceFetch(responses), + planPublishSleep: async () => {}, + }); + + assert.equal(result.status, 0); + assert.equal(result.stdout, `${REPOSITORY_URL}\n`); + assert.equal( + result.stderr, + `First Draft: Analyzing Foundation Plan... +First Draft: Foundation Plan analysis valid. +First Draft: Compiling application... +First Draft: Application compiled. +${SAFE_PROGRESS_REASON_CODES.map( + (reasonCode) => + `First Draft: Checking GitHub access (reason: ${reasonCode}; retry count: 1; next retry: ${RETRY_AT}).`, +).join("\n")} +First Draft: GitHub publication complete. +`, + ); +}); + test("a repeated singleton PUT accepts provenance matching local Plan state", async (context) => { const cwd = remoteDirectory(context, "https://api.example.test"); /** @type {FetchCall[]} */ @@ -1022,6 +1081,16 @@ test("exact response shapes and coherent terminal projections are required", asy }, }, }), + publicationBody("provisioning_repository", { + publication: { + progress: { + phase: "github_preflight", + retry_at: RETRY_AT, + retry_count: 1, + reason_code: "github.preflight_unavailable.canary-secret", + }, + }, + }), publicationBody("succeeded", { project: { head_source_sha256: "f".repeat(64) }, }), From 3e39a437d62a897895b2a940b9baa925dd9d1efd Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Fri, 7 Aug 2026 14:31:17 -0500 Subject: [PATCH 4/4] Adopt ordinary pre-1.0 versioning Separate compatibility-line semantics from the approval-gated npm channel. Release the coordinated API 0.2 candidate as ordinary 0.1.0 while keeping next as the initial distribution path and latest promotion separate. --- .github/workflows/publish.yml | 2 +- README.md | 26 +++++--- RELEASING.md | 117 ++++++++++++++++++++++------------ SECURITY.md | 9 ++- package-lock.json | 4 +- package.json | 2 +- release/compatibility.json | 2 +- test/package.test.js | 35 ++++++++-- 8 files changed, 132 insertions(+), 65 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 40d087f..f48b69a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -101,6 +101,6 @@ jobs: # release-source-checks:end - name: Publish verified source run: npm publish --access public --tag next --provenance --ignore-scripts - # Remove this credential after the first publish enables trusted publishing. + # Remove after v0.1.0 and before any later release tag. env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/README.md b/README.md index ec0290c..2a298ec 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,13 @@ `firstdraft` is the command-line client for [First Draft](https://github.com/firstdraft/firstdraft). It is being built for agents that author and review Foundation Plans with their users. -Public alpha releases use npm's `next` tag. This release line contains the auditable command shell, local Foundation -Plan initialization, local application-key and UUID generation, conditional whole-document push, whole-graph -analysis status polling, compile-and-publish orchestration, and read-only retained-Compilation download. -Interfaces may change between prereleases, and publishing the CLI does not make the wider First Draft service -generally available. +The current `0.1.x` line contains the auditable command shell, local Foundation Plan initialization, local +application-key and UUID generation, conditional whole-document push, whole-graph analysis status polling, +compile-and-publish orchestration, and read-only retained-Compilation download. Before `1.0.0`, increasing the minor +version starts a breaking compatibility line; increasing the patch version is otherwise backward-compatible within +that line. This policy applies to ordinary versions; historical prereleases are outside those compatibility +guarantees. `0.1.0` intentionally supersedes `0.1.0-alpha.2` and requires the service's `0.2.x` API contract. +Publishing the CLI does not make the wider First Draft service generally available. ## Requirements @@ -16,18 +18,22 @@ generally available. ## Installation -Once npm reports a public alpha, install the current prerelease explicitly: +Once npm reports an approved coordinated release, install it from the approval-gated `next` channel: ```sh npm install --global @firstdraft.com/cli@next firstdraft --version ``` -The npm package is `@firstdraft.com/cli`; it installs the `firstdraft` executable. +The npm package is `@firstdraft.com/cli`; it installs the `firstdraft` executable. `next` is a distribution channel, +not a claim that the selected version has SemVer prerelease syntax. -There is intentionally no stable `latest` release yet. Pin an exact prerelease version instead of `next` when a -repeatable installation matters. Remote Plan and Compilation commands require a -compatible First Draft service and are currently intended for coordinated trials. +Pin an exact compatible version, such as `@firstdraft.com/cli@0.1.0` after the registry reports it, instead of +`next` when a repeatable installation matters. Moving npm's `latest` tag is a separate approval-gated promotion +after qualification; the initial release workflow does not move it. As observed on August 7, 2026, `latest` still +identifies `0.1.0-alpha.2`. After `0.1.0` is published under `next`, an untagged npm install will continue to select +that historical alpha rather than `0.1.0` until the separate promotion occurs. Remote Plan and Compilation commands +require a compatible First Draft service and are currently intended for coordinated trials. ## Authenticate API commands diff --git a/RELEASING.md b/RELEASING.md index bdb24c7..835872c 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -3,6 +3,20 @@ Publishing is a separate, explicit action after a release-preparation pull request has merged. npm registry bytes and package versions cannot be replaced, so do not create or push a release tag as a dry run. +## Pre-1.0 version and channel policy + +Before `1.0.0`, First Draft CLI uses ordinary `0.MINOR.PATCH` versions. Increase `MINOR` for a breaking +compatibility-line change. Increase `PATCH` for a change that is otherwise backward-compatible within the current +minor line. Never reuse a published version to preserve compatibility; publish the next version required by this +policy. Do not add aliases or shims solely to make a breaking compatibility line look patch-compatible. +The policy applies to ordinary versions. Historical prereleases do not establish an ordinary compatibility line; +`0.1.0` intentionally supersedes `0.1.0-alpha.2` and requires the service's `0.2.x` API contract. + +Version semantics and npm distribution channels are independent. An approved candidate is published first under +the approval-gated `next` tag even when it has an ordinary version such as `0.1.0`. The release workflow does not +move `latest`. Moving `latest` requires a later, separate approval after the exact `next` candidate has completed +qualification. + ## Coordinated candidate eligibility `release/compatibility.json` declares this package's SemVer version, the First Draft API-contract range it accepts, @@ -13,12 +27,13 @@ binds its Foundation Plan format to the implemented CLI constant. The `script/release_compatibility_check` evaluator in `firstdraft/firstdraft` reads this declaration with the matching declarations from exact, clean checkouts of `firstdraft/firstdraft` and `firstdraft/skills`. It implements SemVer 2.0 precedence. This CLI requires the service's `0.2.0` API contract because the always-present GitHub Publication -progress projection is incompatible with the strict alpha.2 response shape. Comparator arrays form one conjunction, -while `foundation_plan_formats` lists alternatives. A -prerelease satisfies a comparator set only when a comparator explicitly names a prerelease with the same major, -minor, and patch numbers; Skills therefore names this CLI alpha explicitly. `firstdraft.release-compatibility/1` is -intentionally closed. The evaluator in `firstdraft/firstdraft` rejects an unrecognized format and unknown keys, so -adding a key requires a coordinated compatibility-format bump rather than silently changing version 1. +progress projection is incompatible with the strict response shape accepted by the published +`@firstdraft.com/cli@0.1.0-alpha.2`. Comparator arrays form one conjunction, while `foundation_plan_formats` lists +alternatives. A prerelease satisfies a comparator set only when a comparator explicitly names a prerelease with the +same major, minor, and patch numbers. Skills names the candidate CLI version explicitly, so a stale comparator makes +the three-repository candidate ineligible. `firstdraft.release-compatibility/1` is intentionally closed. The +evaluator in `firstdraft/firstdraft` rejects an unrecognized format and unknown keys, so adding a key requires a +coordinated compatibility-format bump rather than silently changing version 1. A compatible result establishes candidate eligibility, not authorization or runtime proof. Exact Git SHAs identify the three-repository candidate. A merge to `main` is integration only: report the merged SHA and ask the user whether @@ -30,9 +45,9 @@ obtain human approval, and only then promote the approved service revision to pr corresponding npm and plugin releases. Do not publish npm, deploy either environment, or release the plugin merely because the compatibility check passes. -## Repository and registry setup +## Repository and registry controls -Before the first release, a repository administrator must: +The published scoped package already exists. Before another release, a repository administrator must confirm: 1. Confirm `firstdraft/cli` is public. The release workflow deliberately removes checkout credentials and re-fetches the public release refs anonymously. @@ -41,29 +56,24 @@ Before the first release, a repository administrator must: 3. Create a GitHub environment named `npm`, restrict it to release tags, require an explicit reviewer, disable administrator bypass, and add the environment variable `NPM_RELEASE_ENABLED=true`. The workflow fails before publishing when this variable is absent. -4. Confirm that the `firstdraft.com` npm organization exists and that the bootstrap publisher belongs to it with - permission to publish public packages. The publisher account must have write-protecting 2FA enabled. Before the - first scoped tag, verify the authenticated identity, organization membership, and absence of an existing package: +4. Confirm that the `firstdraft.com` npm organization and `@firstdraft.com/cli` package still identify the intended + publisher and repository. The publisher account must have write-protecting 2FA enabled. Verify authenticated + identity, organization membership, package identity, and current tags: ```sh npm whoami npm org ls firstdraft.com --json - npm view '@firstdraft.com/cli' name --json + npm view '@firstdraft.com/cli' name repository.url versions dist-tags --json ``` - The last command should return `E404` before the first publication. It proves only that the package is absent; - the first two commands establish authority over the scope. npm provides no non-mutating registry preflight that - guarantees a new package will be accepted, but using an owned scope is npm's documented remedy for an unscoped - similarity rejection. +5. If trusted publishing has not yet been verified, `v0.1.0` is the final release permitted to use the bootstrap + credential. Create a one-day granular npm token with read/write access limited to the existing + `@firstdraft.com/cli` package, no organization-management access, and bypass 2FA enabled. Add it directly as the + `npm` environment secret `NPM_TOKEN`; never put it in an Issue, chat, workflow file, repository file, or command + history. -5. Create a one-day granular npm token with read/write access limited to the `@firstdraft.com` scope, no - organization-management access, and bypass 2FA enabled. The not-yet-created package cannot be selected - individually. Add the token directly as the `npm` environment secret `NPM_TOKEN`; never put it in an Issue, - chat, workflow file, repository file, or command history. - -The token is a one-time bootstrap credential. After the package exists, use the repository-pinned Node.js 24.18.0 -toolchain with npm 11.16.0 to verify the organization's durable read/write access. Grant it only if the package did -not inherit access for the `developers` team: +Use the repository-pinned Node.js 24.18.0 toolchain with npm 11.16.0 to verify the organization's durable read/write +access. Grant it only if the package did not inherit access for the `developers` team: ```sh npm --version @@ -84,9 +94,9 @@ npm trust list '@firstdraft.com/cli' ``` Confirm the listed relationship identifies `firstdraft/cli`, `publish.yml`, the `npm` environment, and publish -permission. Before creating another release tag, merge a follow-up pull request that removes the `NODE_AUTH_TOKEN` -environment from the publish step. Then remove the GitHub secret, revoke the bootstrap token, and configure the -package to disallow token publication: +permission. After publishing `v0.1.0` and before creating any later release tag, merge a follow-up pull request that +removes the `NODE_AUTH_TOKEN` environment from the publish step. Then remove the GitHub secret, revoke the bootstrap +token, and configure the package to disallow token publication: ```sh npm access set mfa=publish '@firstdraft.com/cli' @@ -96,21 +106,29 @@ Confirm that the package's npm Publishing access now requires 2FA and disallows through GitHub OIDC without a persistent npm credential. Apply this restriction only after the trusted publisher has been verified. -## Previous publication attempt +## Historical alpha publications The immutable `v0.1.0-alpha.1` tag records the first reviewed release candidate. On July 31, 2026, npm rejected its unscoped `firstdraft` name as too similar to the existing `first-draft` package before creating a registry package. -Do not move or reuse that tag or version. The first organization-scoped candidate is `@firstdraft.com/cli` version -`0.1.0-alpha.2`. +Do not move or reuse that tag or version. The immutable `v0.1.0-alpha.2` tag identifies the first +organization-scoped package, `@firstdraft.com/cli@0.1.0-alpha.2`, published on August 5, 2026. As observed on August +7, 2026, that is the only published scoped version and both npm's `next` and `latest` tags identify it. Those are +historical release and registry facts; they do not require prerelease syntax, aliases, or compatibility shims for +the ordinary `0.1.0` candidate. Preparing this source does not mutate either dist-tag. A later approved publication +under `next` will repoint `next` to `0.1.0`; `latest` will continue to identify `0.1.0-alpha.2` until a separate +approved promotion. ## Prepare a release 1. Update `package.json`, `package-lock.json`, and `release/compatibility.json` to the exact release version. 2. When that version changes, coordinate the matching explicit CLI comparator in `firstdraft/skills` before - qualification; an old alpha comparator intentionally makes the three-repository candidate ineligible. -3. Keep prereleases on the `next` dist-tag. Do not create `latest` until a stable release is intentionally approved. -4. Update user-facing documentation and release notes for behavior changes. -5. Run: + qualification; a stale comparator intentionally makes the three-repository candidate ineligible. +3. Apply the pre-1.0 policy: use a minor increment for a breaking compatibility line and a patch increment for a + change that is otherwise backward-compatible. Keep the initial distribution under `next` independently of that + version choice. Do not move `latest` during release publication. +4. Confirm neither the exact package version nor its `v` tag already exists. +5. Update user-facing documentation and release notes for behavior changes. +6. Run: ```sh npm ci --ignore-scripts @@ -118,12 +136,12 @@ Do not move or reuse that tag or version. The first organization-scoped candidat npm run check ``` -6. Merge the reviewed pull request only after local and hosted checks pass. +7. Merge the reviewed pull request only after local and hosted checks pass. ## Publish The manual boundary is creation of the version tag. From an up-to-date, clean `main`, verify the intended commit and -then create and push `v`. For version `0.1.0-alpha.3`, the tag is `v0.1.0-alpha.3`. +then create and push `v`. For version `0.1.0`, the tag is `v0.1.0`. Push one release tag at a time; the workflow serializes publication, but GitHub retains at most one pending run in a concurrency group. @@ -149,17 +167,32 @@ change and prepare a new version rather than moving an already shared tag. ## Verify and recover After publication, inspect the registry before retrying any reported failure; the package may already exist. Verify -the exact version, `next` dist-tag, integrity metadata, and provenance metadata: +the exact version, `next` dist-tag, unchanged `latest` dist-tag, integrity metadata, and provenance metadata: ```sh -npm view '@firstdraft.com/cli@0.1.0-alpha.3' \ +npm view '@firstdraft.com/cli@0.1.0' \ version dist.integrity dist.shasum repository.url engines bin --json npm dist-tag ls '@firstdraft.com/cli' ``` -Install `@firstdraft.com/cli@0.1.0-alpha.3` into a fresh temporary prefix, confirm `firstdraft --version`, compare the +Install `@firstdraft.com/cli@0.1.0` into a fresh temporary prefix, confirm `firstdraft --version`, compare the packed file list with the release workflow, and run `npm audit signatures` after an exact installation. -A published version cannot be overwritten or reused. For a bad release, move `next` to a known-good version, -deprecate the bad version, and publish a corrected higher version. Treat unpublishing as an exceptional incident -response, not a routine rollback. +A published version cannot be overwritten or reused. For a bad release, move `next` only to a known-good compatible +version if one exists; otherwise deprecate the bad version and publish a corrected higher version. Treat +unpublishing as an exceptional incident response, not a routine rollback. + +## Promote the qualified release + +Publishing under `next` is not promotion to the default install channel. After the exact `next` version completes +qualification and a human separately approves promotion, one operator may move `latest` to that exact version: + +```sh +npm dist-tag add '@firstdraft.com/cli@0.1.0' latest +npm dist-tag ls '@firstdraft.com/cli' +``` + +Verify both tags after the mutation, then land a documentation change that replaces README's dated `latest` +observation with the promoted version. Do not move `latest` merely because a release merged, published successfully, +or passed candidate compatibility checks, and do not use a dist-tag change to repair or disguise a bad immutable +version. diff --git a/SECURITY.md b/SECURITY.md index eb05054..0c132b2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,6 +6,9 @@ sensitive details in a public Issue. ## Supported versions -No version is supported until the first public prerelease is published. During alpha, only the prerelease currently -identified by npm's `next` tag receives security fixes. Earlier prereleases and unreleased source snapshots are not -supported release lines. The alpha interface may change without backward compatibility between versions. +During coordinated trials, only the release currently identified by npm's approval-gated `next` tag receives +security fixes. That channel is independent of version syntax. Before `1.0.0`, increasing the minor version starts a +breaking compatibility line; increasing the patch version is otherwise backward-compatible within that line. All +other older ordinary versions, historical prereleases, and unreleased source snapshots are not supported unless a +separate support policy says otherwise. Historical prereleases are outside the ordinary version compatibility +guarantee even while one is the current `next` release. diff --git a/package-lock.json b/package-lock.json index 6f7c8bf..0710e03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@firstdraft.com/cli", - "version": "0.1.0-alpha.3", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@firstdraft.com/cli", - "version": "0.1.0-alpha.3", + "version": "0.1.0", "license": "MIT", "bin": { "firstdraft": "bin/firstdraft.js" diff --git a/package.json b/package.json index 1b30953..4e075b8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@firstdraft.com/cli", - "version": "0.1.0-alpha.3", + "version": "0.1.0", "description": "Command-line interface for First Draft", "license": "MIT", "type": "module", diff --git a/release/compatibility.json b/release/compatibility.json index fe13913..6d96ce4 100644 --- a/release/compatibility.json +++ b/release/compatibility.json @@ -1,7 +1,7 @@ { "format": "firstdraft.release-compatibility/1", "component": "cli", - "version": "0.1.0-alpha.3", + "version": "0.1.0", "requires": { "api_contract": [">= 0.2.0", "< 0.3.0"], "foundation_plan_formats": ["firstdraft.foundation-plan.sketch/0.19"] diff --git a/test/package.test.js b/test/package.test.js index 357f88d..41a7ba0 100644 --- a/test/package.test.js +++ b/test/package.test.js @@ -5,6 +5,9 @@ import test from "node:test"; const metadata = JSON.parse( await readFile(new URL("../package.json", import.meta.url), "utf8"), ); +const packageLock = JSON.parse( + await readFile(new URL("../package-lock.json", import.meta.url), "utf8"), +); const publishWorkflow = await readFile( new URL("../.github/workflows/publish.yml", import.meta.url), "utf8", @@ -63,7 +66,10 @@ test("package metadata preserves the audited runtime boundary", () => { } }); -test("package metadata preserves the public prerelease boundary", () => { +test("ordinary pre-1.0 versions use the approval-gated distribution channel", () => { + assert.match(metadata.version, /^0\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/); + assert.equal(packageLock.version, metadata.version); + assert.equal(packageLock.packages[""].version, metadata.version); assert.deepEqual(metadata.repository, { type: "git", url: "git+https://github.com/firstdraft/cli.git", @@ -91,8 +97,10 @@ test("privileged publication repeats every release source check", () => { const npmApprovalGates = publishChecks.filter( (line) => line === npmApprovalGate, ); - const publishCommand = "npm publish"; + const publishCommand = + "npm publish --access public --tag next --provenance --ignore-scripts"; const publishCommandIndex = publishJob.indexOf(publishCommand); + const publishInvocation = "npm publish"; const publishChecksEndIndex = publishJob.indexOf( "# release-source-checks:end", ); @@ -114,14 +122,31 @@ test("privileged publication repeats every release source check", () => { "approval must immediately follow shell safeguards", ); assert.ok(publishCommandIndex >= 0, "publish command must exist"); + assert.equal( + publishJob.indexOf(publishInvocation), + publishCommandIndex, + "the only publish invocation must use the exact approved command", + ); assert.equal( publishJob.indexOf( - publishCommand, - publishCommandIndex + publishCommand.length, + publishInvocation, + publishCommandIndex + publishInvocation.length, ), -1, - "publish command must be unique", + "the publish job must contain only one npm publish invocation", + ); + assert.equal( + publishJob.includes("npm dist-tag"), + false, + "publication must not mutate a dist-tag separately", ); + if (metadata.version !== "0.1.0") { + assert.equal( + publishWorkflow.includes("NODE_AUTH_TOKEN"), + false, + "only v0.1.0 may use the bootstrap publication credential", + ); + } assert.ok( publishChecksEndIndex < publishCommandIndex, "release checks must precede publication",