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
30 changes: 30 additions & 0 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,36 @@ jobs:
npm run build --workspace @beyondnet/evolith-infra-providers
npm run build --workspace @beyondnet/evolith-mcp
npm run build --workspace src/sdk/cli
# GT-706: the declared-exports guard below reads each package's PACKLIST,
# and it covers `repo-facts` too. Every other workspace it scans is
# already built above.
npm run build --workspace @beyondnet/evolith-repo-facts

# GT-706 — a package must prove its own manifest before it is published.
#
# `contracts@1.1.0` declared an export subpath it did not ship. That failed
# at `infra-providers@1.2.1`'s install smoke, AFTER `core-domain@1.3.1` was
# already irreversibly on the registry, leaving the release half-shipped with
# no unpublish available past 72 hours. The release's own assertion could not
# see it: it computed "promised" as `[pkg.main, ...bin]`, and `exports` is not
# in that list. Measured — a package declaring `./ingest` with only
# `dist/index.js` on disk passes it with exit 0 while `require pkg/ingest`
# answers MODULE_NOT_FOUND.
#
# IT LIVES IN THIS JOB BECAUSE THIS JOB BUILDS. A packlist without a dist
# reports every declared target as missing, so the guard needs the same nine
# workspaces it scans to be emitted. It was tried in `governance-guards`
# first, which builds five, and it went red on the four that were not — cli,
# mcp, sdk and core. Building those four there also turned four DEAD evidence
# references into four live non-zero commands, which is a real finding and a
# different gap; it is not silently re-buried, it is recorded in the PR.
#
# `Test` being a REQUIRED check is the other half: a phantom export now
# blocks the merge rather than the release.
- name: A package's declared exports are present in its own tarball
run: |
node .harness/scripts/ci/67-validate-declared-exports.mjs --verbose
node --test .harness/scripts/ci/67-validate-declared-exports.test.mjs

# CD gate = the evolith-cli unit suite (fast, deterministic). The full e2e
# (env-sensitive: spawns servers, loads rulesets) is covered by the
Expand Down
39 changes: 21 additions & 18 deletions .github/workflows/npm-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,22 @@ jobs:
# dist/main.js`, and the job went green. Reporting is not asserting. A log line
# no step fails on is decoration.
#
# This reads the packlist and fails when `main` or any `bin` target is absent,
# which is the narrowest question that would have caught it, and is general
# enough to catch the next mechanism rather than only this one.
# GT-706 — THE NARROW VERSION OF THIS CHECK LET A SECOND DEFECT THROUGH, and the
# inline node it replaced is now a guard with its own fixtures.
#
# It read the packlist and failed when `main` or any `bin` target was absent —
# "the narrowest question that would have caught it". It was too narrow.
# `contracts@1.1.0` declared an EXPORT SUBPATH it did not ship, `exports` was
# not in the list this checked, and the failure surfaced one package later, at
# `infra-providers@1.2.1`'s install smoke, with `core-domain@1.3.1` already
# irreversibly published and no unpublish available past 72 hours.
#
# Measured rather than argued: a two-file package declaring
# `"./ingest": "./dist/ingest/index.js"` with only `dist/index.js` on disk passed
# the old assertion with exit 0, while `require pkg/ingest` answered
# MODULE_NOT_FOUND. The guard below covers `main` and `bin` too, so this is a
# superset and not a second opinion beside it.
#
# Done in node, in one process, on purpose. The first version of this check was a
# shell pipeline — `echo "$PACKED" | grep -qxF "$f"` — and it reported a FALSE
# POSITIVE on its first real run: it named dist/main.js missing from a tarball that
Expand All @@ -203,21 +216,11 @@ jobs:
#
# A `<<<` herestring fixes it too. node fixes the whole class: no pipe to break, no
# word-splitting on the promised list, and the JSON is already JSON.
echo "-- packlist assertion"
( cd "$DIR" && npm pack --dry-run --json > /tmp/packlist.json )
node -e '
const fs = require("fs");
const dir = process.argv[1];
const pkg = JSON.parse(fs.readFileSync(dir + "/package.json", "utf8"));
const packed = new Set(JSON.parse(fs.readFileSync("/tmp/packlist.json", "utf8"))[0].files.map((f) => f.path));
const promised = [...new Set([pkg.main, ...Object.values(pkg.bin || {})].filter(Boolean).map((s) => s.replace(/^\.\//, "")))];
console.log(" " + packed.size + " file(s) packed; entry points declared: " + (promised.join(", ") || "(none)"));
const missing = promised.filter((p) => !packed.has(p));
if (missing.length) {
console.log("::error::" + pkg.name + "@" + pkg.version + " would publish WITHOUT: " + missing.join(", ") + " — the tarball does not contain the entry points its own package.json declares. Publishing it would put a package on the registry that cannot be required or executed.");
process.exit(1);
}
' "$DIR"
echo "-- packlist assertion (main, bin AND every exports target)"
if ! node .harness/scripts/ci/67-validate-declared-exports.mjs --pkg "$DIR" --verbose; then
echo "::error::$NAME@$VERSION declares targets its own tarball does not carry. Publishing it would put a package on the registry whose manifest promises import paths that do not resolve — and npm forbids unpublishing after 72 hours."
exit 1
fi

if [ "$DRY_RUN" = "true" ]; then
( cd "$DIR" && npm publish --dry-run --provenance --access public )
Expand Down
39 changes: 39 additions & 0 deletions .harness/scripts/ci/41-validate-evidence-commands.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -712,10 +712,49 @@ export function classifyExecutability(cmd, root = ROOT) {
if (writer) {
return { executable: false, bucket: 'writes-to-tree', reason: `source calls ${writer} — executing it would edit the working tree` };
}
// GT-706 — a guard that reads emitted `dist/` cannot run where nothing is built.
//
// `67-validate-declared-exports.mjs` reads each package's PACKLIST, so an
// unbuilt workspace reports every declared target as missing. This job builds
// five of the nine it scans, so executing it here fails on the absence of a
// build and not on anything it was written to check — the same shape as
// GT-675's remote-ref case, where the command was fine and the ENVIRONMENT
// was the wrong one.
//
// The declaration lives in the guard, not in a list here. A hardcoded roster
// of build-dependent scripts is one more thing that silently stops covering a
// new one; a marker the script itself carries moves with it. Classifying, not
// skipping: the command stays in the census and is reported, it is simply not
// executed in a job that cannot answer it.
if (declaresBuiltWorkspace(join(root, cmd.cwd || '.', script))) {
return {
executable: false,
bucket: 'heavy-toolchain',
reason: 'declares REQUIRES_BUILT_WORKSPACE — reads emitted dist/, which this job does not produce',
};
}
}
return { executable: true };
}

/**
* Does a script declare that it needs an emitted `dist/`?
*
* Read from source rather than imported: importing every referenced script to ask
* one question would execute their module bodies, and a classifier that runs the
* thing it is classifying is not a classifier.
*
* @param {string} abs absolute path to the script
* @returns {boolean}
*/
function declaresBuiltWorkspace(abs) {
try {
return /^export const REQUIRES_BUILT_WORKSPACE = true;/m.test(readFileSync(abs, 'utf8'));
} catch {
return false;
}
}

/** First filesystem-write API found in a script's source, or null. */
function writeApiUsed(abs) {
let src;
Expand Down
27 changes: 27 additions & 0 deletions .harness/scripts/ci/41-validate-evidence-commands.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,33 @@ describe('classifyExecutability', () => {
assert.equal(c.bucket, 'writes-to-tree');
});

// GT-706 — a guard that reads emitted `dist/` fails on the absence of a build
// rather than on what it was written to check. The declaration lives in the
// guard, so the pair of tests below is the whole contract: a script that
// declares it is classified, and one that does not is still executed. A
// classification that could not be observed to NOT apply is a silent skip.
test('a script declaring REQUIRES_BUILT_WORKSPACE is classified, not executed', () => {
writeFileSync(
join(fixtureRoot, 'scripts', 'needs-build.mjs'),
'export const REQUIRES_BUILT_WORKSPACE = true;\nconsole.log("ok");\n',
);
const c = classifyExecutability(extractCommand('node scripts/needs-build.mjs'), fixtureRoot);
assert.equal(c.executable, false);
assert.equal(c.bucket, 'heavy-toolchain');
assert.match(c.reason, /REQUIRES_BUILT_WORKSPACE/);
});

test('a script that merely MENTIONS the marker is still executed', () => {
// The marker is a declaration, not a keyword: matching it in a comment or a
// string would let any script opt out of execution by talking about it.
writeFileSync(
join(fixtureRoot, 'scripts', 'mentions-marker.mjs'),
'// see REQUIRES_BUILT_WORKSPACE in 67-validate-declared-exports.mjs\nconsole.log("ok");\n',
);
const c = classifyExecutability(extractCommand('node scripts/mentions-marker.mjs'), fixtureRoot);
assert.equal(c.executable, true, 'a mention must not opt a script out of execution');
});

test('`gh api ...` is excluded as needing credentials, not silently dropped', () => {
const c = classifyExecutability(extractCommand('gh api repos/o/r/branches/main/protection'), fixtureRoot);
assert.equal(c.bucket, 'network-or-credentials');
Expand Down
Binary file not shown.
Loading
Loading