Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,28 @@
- **Choice:** Adopt `nexus:pin-bundles` (main's rename of `nexus:vendor-tools`) and repoint the branch's remediation hint, release procedure and its AC2 test at it.
- **Why:** The step no longer vendors anything, so the merged name is the accurate one; a release procedure naming a script that does not exist fails on release day.
- **Refuted alternative:** Restoring `nexus:vendor-tools`, which re-adds an alias whose verb the code has stopped doing.

## 2026-08-27 — Invariant 15 becomes an executable release-time gate, not procedure prose
- **Choice:** `pnpm nexus:release-gate` scans the shipped component bodies and fails on any `.claude/…` path the payload itself does not carry; the release procedure runs it as step 4, ahead of tag and publish.
- **Why:** The analyze receipt's high finding was that a releaser following the procedure walks straight from re-pin to publish; a runnable check that names the 27 offending lines stops them where prose would not, and it goes green by itself when the invocation-rewrite epic lands.
- **Refuted alternative:** A prose precondition only. Cheaper, but it is the release-day habit the executed-changelog decision already rejected once.

## 2026-08-27 — The gate's rule is "the payload does not carry this path", not "no path appears"
- **Choice:** Flag a `.claude/…` reference only when that file is absent from the shipped component set, so `tsx ./.claude/skills/nxs-record-digest/scripts/record_digest.ts` passes and `python3 ./.claude/skills/nxs-gh-shared/delivery_config.py` fails.
- **Why:** Invariant 15 is about reaching a *toolkit capability* by path. A path the payload carries resolves wherever the components are deployed; a path it does not carry is a capability that moved into a toolkit and can only be reached by the toolkit's name.
- **Refuted alternative:** Flag every in-repo path reference (71 hits). Simpler regex, but it fails on component-internal scripts that work fine after deploy, so it could never go green.

## 2026-08-27 — The changelog's coverage risk is accepted in writing rather than derived from the diff
- **Choice:** Record in the release procedure that the suite checks the entry's *language*, not its *coverage*, and hand the author `git diff --name-only <previous tag>..HEAD -- .claude`; do not derive `touchedComponentBody` / `changedStageBehaviour` from the release diff.
- **Why:** Record #334 offers both exits. There is no previous tag to diff against for the first release, and wiring the unit suite to git tag history makes it fail on a shallow or tagless checkout for a fact a human still has to judge.
- **Refuted alternative:** Derive the two context facts from the release diff and fail when the entry does not account for them. Stronger, but it needs a tag history the project does not have yet.

## 2026-08-27 — Story #312's AC1 is rescoped to the identity check; cutting the release is re-filed
- **Choice:** Amend #312 so AC1 asserts `checkReleaseIdentity` reports divergence between VERSION, the manifest, the changelog entry and the tag, and file the tag/publish/releases-page tail as backlog stub #336, blocked_by #250.
- **Why:** Record #334 invariant 15 forbids the tail while a shipped body reaches a capability by an in-repository path, and `nexus:release-gate` is red on twelve such references; the epic cannot both close honestly and keep an AC that needs the forbidden action.
- **Refuted alternative:** Hold #252 open until #250 lands and cut the release under it. Keeps AC1 verbatim, but blocks a finished epic on an unstarted one and leaves four closed stories parked behind it.

## 2026-08-27 — The repository-only build scripts adopt isDirectRun
- **Choice:** Replace the `import.meta.url === \`file://${process.argv[1]}\`` guard in build-bundles.ts, pack-release.ts and vendor-bundle.ts with `isDirectRun()`.
- **Why:** Record #334 says the guard on every entry point compares fully resolved real paths; three scripts still compared strings, so the codebase carried two answers to the same question.
- **Refuted alternative:** Leave them, since none ships in the payload and no adopter can reach them. Correct today, but it is the pattern the next entry point gets copied from.
52 changes: 49 additions & 3 deletions docs/delivery/release-procedure.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,24 @@ If the release changed no stage behaviour, the entry still exists and says exact

No change to how any pipeline stage behaves.

While the version is below 1.0 the number carries no compatibility signal: a minor bump may
break a pipeline that worked before. An item describing a breaking change to stage behaviour
therefore says **breaking** in words, and says what a lead has to do differently. The release
check enforces that wording once the release's context says it broke something — set
`breakingChange` on the context the changelog spec passes; deciding that it did is yours.

**What the suite checks, and what it does not.** The suite checks the *language* of the entry —
no commit subjects, no file paths, no library versions, a named stage, the explicit no-change
statement, the breaking wording. It does **not** check *coverage*: nothing derives from the diff
which component bodies this release actually moved, so an entry that names some stage passes even
when it omits the change this release made. That is accepted, deliberately, as the author's
judgement rather than a gate. Read the release's own component diff before you write the entry:

git diff --name-only <previous tag>..HEAD -- .claude

Every path that list names is a body a lead runs; an item accounts for each behaviour change
among them, or you decide in the open that it changes nothing a lead experiences.

## 3. Re-pin and verify

pnpm nexus:pin-bundles
Expand All @@ -39,7 +57,27 @@ suite.
Commit the result — `VERSION`, `CHANGELOG.md`, `libs/portable-tools/bundle-fingerprint.json` and
`libs/portable-tools/payload-manifest.json` — and merge it to `main`.

## 4. Tag
## 4. Check the invocation gate

pnpm nexus:release-gate

The tag and the public publish must not run while a shipped component body reaches a toolkit
capability by an in-repository script path instead of by a declared toolkit name. Those paths
exist in no installed package, so a body that names one cannot work outside a source checkout —
and the changelog's claim that a stage runs without a checkout would be false on the first
release.

The gate prints every offending body, the line, and the path it names; it exits non-zero while
any remain. **A non-zero exit stops the release here.** The fix is never to add the path back to
the payload — it is to rewrite the body to invoke the capability by its declared toolkit name
(`nexus-gh <verb>`, `nexus <verb>`), which is the epic that follows this one.

Packing and installing locally is unaffected by this gate, and is the intended way to consume the
package definition before it goes green:

npm pack

## 5. Tag

From the merged commit on `main`:

Expand All @@ -49,7 +87,7 @@ From the merged commit on `main`:
The tag names the same version as `VERSION`, the manifest and the changelog entry. Nothing else
is tagged.

## 5. Publish
## 6. Publish

pnpm nexus:build-release
npm publish
Expand All @@ -66,8 +104,16 @@ Verify the published release the way an adopter meets it, from a directory that

Both print the version you tagged.

## 6. Publish the releases-page entry
## 7. Publish the releases-page entry

Create the GitHub release for the tag and paste the changelog section as its body. That page is
the changelog's home; the registry listing is not, and the two are not exclusive — Nexus lives in
a git repository whichever channel installs it.

## What a release does not check for the adopter

The manifest and the readme declare the supported platforms and the Python interpreter floor. That
declaration is the whole of the answer for this release: nothing checks the interpreter at install
time or on first run, so an adopter who installs without it meets an interpreter-not-found error
that never mentions Nexus. This is accepted rather than overlooked — a first-run prerequisite check
on the second toolkit is a later release's work.
5 changes: 0 additions & 5 deletions libs/gh-toolkit/nexus_gh/delivery_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -976,8 +976,3 @@ def _run(cmd):

return 1


if __name__ == "__main__":
import sys as _sys

raise SystemExit(_cli(_sys.argv[1:]))
8 changes: 8 additions & 0 deletions libs/gh-toolkit/tests/test_packaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ def test_no_toolkit_module_manipulates_the_import_path(self):
with self.subTest(source=source.name):
self.assertNotIn("sys.path", source.read_text(encoding="utf-8"))

def test_no_toolkit_module_offers_a_second_entry_point(self):
# Byte-code suppression lives in the single declared entry point (record #334, invariant
# 8). A module with its own `__main__` guard is a second door past it, and the first
# sibling import through that door writes byte-code into whatever repository ran it.
for source in sorted(_PACKAGE.glob("*.py")):
with self.subTest(source=source.name):
self.assertNotIn('__name__ == "__main__"', source.read_text(encoding="utf-8"))

def test_the_filers_reach_the_resolver_by_package_relative_import(self):
for name in ("create_epic.py", "create_story.py"):
with self.subTest(source=name):
Expand Down
2 changes: 1 addition & 1 deletion libs/portable-tools/bundle-fingerprint.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"nexus.mjs": "5c3bf170e8db7e89cd092bded31776dae5dbfa5d9b6e1db9c37d8a5d0d9849fb",
"payload": "0859743b731ad87929d8d574cde81d5a83d2b99e8628525f3d9b6ea05415d16b"
"payload": "0b11aa8e42f656796c3a40afa7ef15a9a5a90b457c0af7d8475e4a9219e86dd8"
}
2 changes: 1 addition & 1 deletion libs/portable-tools/payload-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,6 @@
"gh-toolkit/nexus_gh/cli.py": "0182c9c42ccb64ae2f20e5023d24fd32c782285c16f631892ad0d64061503ae6",
"gh-toolkit/nexus_gh/create_epic.py": "867eba486ed25b237af662605b460609ca216859d66510490965ce8a39802c0c",
"gh-toolkit/nexus_gh/create_story.py": "3bc2f9626d999b155e9a14b9bbaa7c1600e0158874eba358e25f642b87715deb",
"gh-toolkit/nexus_gh/delivery_config.py": "7b2458dab2310c596602978ec62a7deb8e216553deca528c9e2600ea53a292bd",
"gh-toolkit/nexus_gh/delivery_config.py": "4f2c565feff51d03f9f7f5110926426a2abfb5d411aaff989067f431d4d7b317",
"gh-toolkit/nexus_gh/release.py": "c82e51e15226eb1ec1bf91a59b3642cc27325d281c68a8b3d0976b38376d0128"
}
3 changes: 2 additions & 1 deletion libs/portable-tools/src/build-bundles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { buildBundle } from "./bundle.js";
import { isDirectRun } from "./entry-point.js";

export const ENTRY_POINTS: Record<string, string> = {
nexus: "nexus-cli.ts",
Expand All @@ -36,6 +37,6 @@ async function main(): Promise<void> {
}
}

if (import.meta.url === `file://${process.argv[1]}`) {
if (isDirectRun(import.meta.url, process.argv[1])) {
main();
}
3 changes: 2 additions & 1 deletion libs/portable-tools/src/pack-release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { buildAllBundles } from "./build-bundles.js";
import { isDirectRun } from "./entry-point.js";
import { listPayloadFiles } from "./release-payload.js";

/** The staged directory, relative to the package root. Named in the manifest's `files`. */
Expand Down Expand Up @@ -55,6 +56,6 @@ async function main(): Promise<void> {
console.log(`Release tree: ${written.length} files under ${path.join(repoRoot, RELEASE_TREE_DIRNAME)}`);
}

if (import.meta.url === `file://${process.argv[1]}`) {
if (isDirectRun(import.meta.url, process.argv[1])) {
main();
}
123 changes: 123 additions & 0 deletions libs/portable-tools/src/release-gate.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* The release tail's precondition (invariant 15, epic #252).
*
* The gate is a release-time check, not a suite check: it is expected to fail today, because the
* component bodies still reach the Python toolkit by an in-repository path. What the suite pins
* is that the detector answers correctly and that the written procedure carries the gate ahead of
* the tag — a releaser who follows the procedure must be stopped before publishing.
*/

import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { findInRepoInvocations, RELEASE_GATE_REMEDIATION, type InRepoInvocation } from "./release-gate";

const REPO_ROOT: string = path.resolve(__dirname, "../../..");
const PROCEDURE_PATH: string = path.join(REPO_ROOT, "docs", "delivery", "release-procedure.md");

const temporary: string[] = [];

function componentTree(files: Record<string, string>): string {
const root: string = fs.mkdtempSync(path.join(os.tmpdir(), "release-gate-"));
temporary.push(root);
for (const [rel, content] of Object.entries(files)) {
const abs: string = path.join(root, ...rel.split("/"));
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
}
return root;
}

afterEach(() => {
while (temporary.length > 0) {
fs.rmSync(temporary.pop() as string, { recursive: true, force: true });
}
});

describe("a shipped body may not reach a toolkit capability by an in-repository path", () => {
it("reports a body that runs a capability the payload does not carry", () => {
const claudeDir: string = componentTree({
"commands/nxs.close.md": [
"Resolve the target repository:",
"",
" ISSUES_REPO=\"$(python3 ./.claude/skills/nxs-gh-shared/delivery_config.py resolve epic-repo)\"",
].join("\n"),
});

const findings: InRepoInvocation[] = findInRepoInvocations(claudeDir);

expect(findings).toHaveLength(1);
expect(findings[0].file).toBe("commands/nxs.close.md");
expect(findings[0].line).toBe(3);
expect(findings[0].reference).toBe(".claude/skills/nxs-gh-shared/delivery_config.py");
});

it("reports a prose mention as readily as a command line, because both send a reader to the path", () => {
const claudeDir: string = componentTree({
"skills/nxs-epic-resolve/SKILL.md":
"The resolver (`.claude/skills/nxs-gh-shared/delivery_config.py resolve …`) declares the mode.\n",
});

expect(findInRepoInvocations(claudeDir)).toHaveLength(1);
});

it("passes a body that names the declared toolkit rather than a path", () => {
const claudeDir: string = componentTree({
"commands/nxs.close.md": " ISSUES_REPO=\"$(nexus-gh config resolve epic-repo)\"\n",
});

expect(findInRepoInvocations(claudeDir)).toEqual([]);
});

it("passes a path the payload itself carries — that one resolves wherever the components are deployed", () => {
const claudeDir: string = componentTree({
"skills/nxs-record-digest/SKILL.md":
" tsx ./.claude/skills/nxs-record-digest/scripts/record_digest.ts --issue <N>\n",
"skills/nxs-record-digest/scripts/record_digest.ts": "export {};\n",
});

expect(findInRepoInvocations(claudeDir)).toEqual([]);
});

it("looks only at the shipped component subtrees", () => {
const claudeDir: string = componentTree({
"settings.local.json": "{\"note\": \"python3 ./.claude/skills/nxs-gh-shared/delivery_config.py\"}\n",
});

expect(findInRepoInvocations(claudeDir)).toEqual([]);
});

it("names one finding per occurrence, with the line a reader can open", () => {
const claudeDir: string = componentTree({
"commands/nxs.epic.md": [
" python ./.claude/skills/nxs-gh-shared/delivery_config.py resolve epic-label",
" python ./.claude/skills/nxs-gh-shared/delivery_config.py resolve epic-type",
].join("\n"),
});

expect(findInRepoInvocations(claudeDir).map((finding) => finding.line)).toEqual([1, 2]);
});

it("the remediation names the declared toolkit, not an edit to the payload", () => {
expect(RELEASE_GATE_REMEDIATION).toContain("nexus-gh");
});
});

describe("the procedure carries the gate ahead of the tag (story #312 AC2)", () => {
const procedure: string = fs.readFileSync(PROCEDURE_PATH, "utf8");

it("states the precondition and how to check it", () => {
expect(procedure).toContain("nexus:release-gate");
});

it("places the gate before the tag and the publish, where it can still stop a release", () => {
expect(procedure.indexOf("nexus:release-gate")).toBeLessThan(procedure.indexOf("git tag"));
expect(procedure.indexOf("nexus:release-gate")).toBeLessThan(procedure.indexOf("npm publish"));
});

it("is a runnable step: the repository declares the script the procedure names", () => {
const manifest = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, "package.json"), "utf8"));
expect(Object.keys(manifest.scripts)).toContain("nexus:release-gate");
});
});
Loading