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
40 changes: 34 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,39 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
- run: npm run build
- run: bash action/test-contract.sh
- run: npm pack --dry-run
- run: npm audit --audit-level=high
- run: npm run release:check
env:
RELEASE_MANIFEST_OUT: ${{ runner.temp }}/release-candidate-node-${{ matrix.node-version }}.json
- uses: actions/upload-artifact@v4
with:
name: release-candidate-node-${{ matrix.node-version }}
path: ${{ runner.temp }}/release-candidate-node-${{ matrix.node-version }}.json
if-no-files-found: error

release-candidate-reproducibility:
needs: test-and-build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
pattern: release-candidate-node-*
path: release-candidates
merge-multiple: true
- name: Require byte-identical Node 22 and Node 24 candidates
run: |
node - <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const files = fs.readdirSync('release-candidates').sort();
if (files.length !== 2) throw new Error(`expected two candidate manifests, found ${files.length}`);
const manifests = files.map((file) => JSON.parse(fs.readFileSync(path.join('release-candidates', file), 'utf8')));
for (const field of ['version', 'filename', 'sha256', 'fileCount', 'unpackedSize']) {
if (manifests[0][field] !== manifests[1][field]) {
throw new Error(`${field} differs across Node 22 and Node 24 candidates`);
}
}
console.log(`reproducible candidate ${manifests[0].filename} sha256=${manifests[0].sha256}`);
NODE

lint-readme-commands:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -68,7 +96,7 @@ jobs:
esac
node -e "const r=JSON.parse(process.env.REPORT);if(r.overall.total!==Number(process.env.SCORE))process.exit(1)"

- name: Run the copyable end-to-end sample
- name: Run the sample site through the local composite Action
id: sample
uses: ./
with:
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

All notable user-visible changes will be documented here. The project follows Semantic Versioning after the v0.6 evidence baseline is released.

## Unreleased

### Fixed

- Bound public CLI verification to the downloaded tarball that passed SHA-256 verification.
- Added an executable clean-worktree release candidate gate and cross-Node package reproducibility check.
- Exercised the public rule corpus across the HTML parser boundary and strengthened release verifier failure tests.

## 0.6.0

### Changed
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Release gates:
- root GitHub Action metadata and an end-to-end sample repository;
- stable JSON output, changelog, release notes, and rollback instructions.

The release gates are enforced by `src/core/__tests__/release-contract.test.ts`, the versioned corpus in `fixtures/v0.6/`, the copyable sample in `examples/github-action-sample/`, and the release runbook in `docs/release-v0.6.md`.
The code-level gates are enforced by `npm run release:check`, `src/core/__tests__/release-contract.test.ts`, the versioned corpus in `fixtures/v0.6/`, and the local composite Action contract. CI compares real Node.js 22 and 24 package manifests and hashes. Repository rulesets and the release runbook remain separate maintainer controls.

## Product validation after v0.6

Expand Down
8 changes: 5 additions & 3 deletions docs/release-v0.6.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@ Version 0.6.0 establishes the evidence-bounded scoring, packaging, and GitHub Ac

## Release acceptance

Before publication, verify all of the following from the intended release commit:
Before publication, run `npm ci` and `npm run release:check` from the intended release commit. The candidate gate verifies all of the following:

1. `npm ci`, `npm run check`, `npm audit --audit-level=high`, and `bash action/test-contract.sh` pass.
1. `npm run check`, `npm audit --audit-level=high`, and `bash action/test-contract.sh` pass.
2. The public v0.6 rule corpus covers the positive, negative, and false-positive boundary for every scored rule.
3. `npm pack` is reproducible, its SHA-256 is recorded, and a clean consumer can invoke `aeoptimize`, `aeo`, and `aeo-cli`.
3. An actual `npm pack` candidate contains the required public files, its SHA-256 is recorded, and a clean consumer can invoke `aeoptimize`, `aeo`, and `aeo-cli` from that exact tarball.
4. CI succeeds on Node.js 22 and 24 for the release commit.
5. The JSON automation contract and Action sample tests pass.
6. The npm account is verified immediately before publishing.

CI runs the same candidate gate on Node.js 22 and 24 and compares version, filename, SHA-256, file count, and unpacked size. `prepublishOnly` invokes the candidate gate again and refuses a dirty worktree by default.

Publishing, tagging, creating a GitHub Release, changing npm dist-tags, and deprecating a version are separate external mutations and require separate maintainer authorization.

## Release notes
Expand Down
16 changes: 14 additions & 2 deletions fixtures/v0.6/rule-corpus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,14 +126,26 @@ export const ruleFixtureCorpus: Record<string, RuleFixtureSet> = {
negative: {
purpose: 'A majority of dangling pronoun or transition openings triggers a review finding.',
document: {
paragraphs: ['This is important.', 'They require context.', 'However, it varies.', 'The release is versioned.', 'The report is public.'],
paragraphs: [
'This is important for the release.',
'They require additional context.',
'However, it varies by project.',
'The release is versioned for users.',
'The report is publicly available.',
],
},
expected: { score: 3, issues: 1, suggestions: 1 },
},
boundary: {
purpose: 'Exactly twenty percent dangling openings is the non-penalized ratio boundary.',
document: {
paragraphs: ['This needs context.', 'The package is versioned.', 'The Action is advisory.', 'The report is stable.', 'The fixture is public.'],
paragraphs: [
'This needs context for the reader.',
'The package is explicitly versioned.',
'The Action is advisory by default.',
'The report remains stable for automation.',
'The fixture is publicly reviewable.',
],
},
expected: { score: 8, issues: 0, suggestions: 1 },
},
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"agents/",
"fixtures/",
"examples/github-action-sample/",
"scripts/verify-release-candidate.sh",
"scripts/verify-release-v0.6.sh",
".claude-plugin/",
"docs/methodology.md",
Expand All @@ -34,10 +35,11 @@
"scripts": {
"build": "tsc",
"check": "npm test && npm run build",
"release:check": "bash scripts/verify-release-candidate.sh",
"dev": "tsc --watch",
"test": "vitest run",
"test:watch": "vitest",
"prepublishOnly": "npm run check"
"prepublishOnly": "npm run release:check"
},
"keywords": [
"aeo",
Expand Down
86 changes: 86 additions & 0 deletions scripts/verify-release-candidate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
set -euo pipefail

REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd)
cd "$REPO_ROOT"

for command_name in git jq mktemp node npm; do
if ! command -v "$command_name" >/dev/null 2>&1; then
echo "missing required command: $command_name" >&2
exit 2
fi
done

if [ "${ALLOW_DIRTY_RELEASE_CHECK:-0}" != "1" ] && [ -n "$(git status --porcelain)" ]; then
echo "release candidate must be built from a clean worktree" >&2
exit 1
fi

VERIFY_BASE=${TMPDIR:-/tmp}
VERIFY_BASE=${VERIFY_BASE%/}
VERIFY_ROOT=$(mktemp -d "$VERIFY_BASE/aeoptimize-release-candidate.XXXXXX")
PACK_ROOT="$VERIFY_ROOT/pack"
CONSUMER_ROOT="$VERIFY_ROOT/consumer"
PACK_JSON="$VERIFY_ROOT/pack.json"

cleanup() {
case "$VERIFY_ROOT" in
"$VERIFY_BASE"/aeoptimize-release-candidate.*)
rm -rf -- "$VERIFY_ROOT"
;;
*)
echo "Refusing to remove unexpected verification path: $VERIFY_ROOT" >&2
;;
esac
}

trap cleanup EXIT
mkdir -p "$PACK_ROOT"

npm run check
bash action/test-contract.sh
npm --cache "$VERIFY_ROOT/npm-cache" audit --audit-level=high
npm_config_dry_run=false npm --cache "$VERIFY_ROOT/npm-cache" \
pack --json --pack-destination "$PACK_ROOT" > "$PACK_JSON"

PACKAGE_FILENAME=$(jq -er '.[0].filename' "$PACK_JSON")
PACKAGE_VERSION=$(jq -er '.[0].version' "$PACK_JSON")
PACKAGE_FILE_COUNT=$(jq -er '.[0].files | length' "$PACK_JSON")
PACKAGE_UNPACKED_SIZE=$(jq -er '.[0].unpackedSize' "$PACK_JSON")
PACKAGE_TARBALL="$PACK_ROOT/$PACKAGE_FILENAME"
PACKAGE_SHA256=$(node -e "const crypto=require('node:crypto');const fs=require('node:fs');console.log(crypto.createHash('sha256').update(fs.readFileSync(process.argv[1])).digest('hex'))" "$PACKAGE_TARBALL")

jq -e '
(.[0].files | map(.path) | index("dist/cli/index.js")) != null and
(.[0].files | map(.path) | index("fixtures/v0.6/rule-corpus.ts")) != null and
(.[0].files | map(.path) | index("examples/github-action-sample/.github/workflows/aeoptimize.yml")) != null and
(.[0].files | map(.path) | index("scripts/verify-release-candidate.sh")) != null and
(.[0].files | map(.path) | index("scripts/verify-release-v0.6.sh")) != null
' "$PACK_JSON" >/dev/null

npm_config_dry_run=false npm --cache "$VERIFY_ROOT/npm-cache" install \
--ignore-scripts --no-audit --no-fund \
--prefix "$CONSUMER_ROOT" "$PACKAGE_TARBALL" >/dev/null

for binary in aeoptimize aeo aeo-cli; do
BINARY_VERSION=$("$CONSUMER_ROOT/node_modules/.bin/$binary" --version)
if [ "$BINARY_VERSION" != "$PACKAGE_VERSION" ]; then
echo "$binary returned $BINARY_VERSION; expected $PACKAGE_VERSION" >&2
exit 1
fi
done

MANIFEST=$(jq -n \
--arg version "$PACKAGE_VERSION" \
--arg filename "$PACKAGE_FILENAME" \
--arg sha256 "$PACKAGE_SHA256" \
--argjson fileCount "$PACKAGE_FILE_COUNT" \
--argjson unpackedSize "$PACKAGE_UNPACKED_SIZE" \
'{version: $version, filename: $filename, sha256: $sha256, fileCount: $fileCount, unpackedSize: $unpackedSize}')

if [ -n "${RELEASE_MANIFEST_OUT:-}" ]; then
printf '%s\n' "$MANIFEST" > "$RELEASE_MANIFEST_OUT"
fi

printf '%s\n' "$MANIFEST"
echo "Release candidate checks passed."
4 changes: 2 additions & 2 deletions scripts/verify-release-v0.6.sh
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ if curl -fsS "https://registry.npmjs.org/$PACKAGE_NAME" > "$REGISTRY_JSON"; then

if npm --cache "$VERIFY_ROOT/npm-cache" install \
--ignore-scripts --no-audit --no-fund \
--prefix "$CONSUMER_ROOT" "$PACKAGE_NAME@$EXPECTED_VERSION" >/dev/null; then
--prefix "$CONSUMER_ROOT" "$PACKAGE_TARBALL" >/dev/null; then
for binary in aeoptimize aeo aeo-cli; do
binary_version=$("$CONSUMER_ROOT/node_modules/.bin/$binary" --version 2>/dev/null || true)
if [ "$binary_version" = "$EXPECTED_VERSION" ]; then
Expand All @@ -129,7 +129,7 @@ if curl -fsS "https://registry.npmjs.org/$PACKAGE_NAME" > "$REGISTRY_JSON"; then
fi
done
else
fail "clean consumer installation failed for $PACKAGE_NAME@$EXPECTED_VERSION"
fail "clean consumer installation failed for the verified package tarball"
fi
else
fail "npm does not contain exact version $EXPECTED_VERSION"
Expand Down
92 changes: 91 additions & 1 deletion src/core/__tests__/release-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { ruleFixtureCorpus, ruleFixtureCorpusVersion, type RuleFixtureKind } from '../../../fixtures/v0.6/rule-corpus.js';
import { allRules } from '../rules.js';
import { scan } from '../scanner.js';
import { parseHtml, scan } from '../scanner.js';
import type { ParsedDocument } from '../types.js';

const testDirectory = dirname(fileURLToPath(import.meta.url));
Expand All @@ -25,6 +25,66 @@ function makeDocument(overrides: Partial<ParsedDocument>): ParsedDocument {
};
}

function escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}

function removeFirst(value: string, segment: string): string {
const index = value.indexOf(segment);
return index === -1 ? value : `${value.slice(0, index)} ${value.slice(index + segment.length)}`;
}

function renderFixtureHtml(document: Partial<ParsedDocument>): string {
const head = Object.entries(document.metaTags ?? {})
.map(([name, content]) => `<meta name="${escapeHtml(name)}" content="${escapeHtml(content)}">`)
.join('');
const headings = (document.headings ?? [])
.map(({ level, text }) => `<h${level}>${escapeHtml(text)}</h${level}>`)
.join('');
const paragraphs = (document.paragraphs ?? [])
.map((paragraph) => `<p>${escapeHtml(paragraph)}</p>`)
.join('');
const jsonLd = (document.jsonLd ?? [])
.map((value) => `<script type="application/ld+json">${JSON.stringify(value)}</script>`)
.join('');
const links = (document.links ?? [])
.map(({ href, text, rel }) => `<a href="${escapeHtml(href)}"${rel ? ` rel="${escapeHtml(rel)}"` : ''}>${escapeHtml(text)}</a>`)
.join('');
const suppliedHtml = document.html ?? '';

let residualText = document.rawText ?? '';
for (const knownText of [
...(document.headings ?? []).map(({ text }) => text),
...(document.paragraphs ?? []),
...(document.links ?? []).map(({ text }) => text),
parseHtml(`<body>${suppliedHtml}</body>`, 'fixture://fragment').rawText,
]) {
if (knownText) residualText = removeFirst(residualText, knownText);
}

return `<html><head>${head}${jsonLd}</head><body><main>${headings}${paragraphs}${links}${suppliedHtml}<div>${escapeHtml(residualText)}</div></main></body></html>`;
}

function expectIssueContract(issue: Record<string, unknown>): void {
expect(['structure', 'citability', 'schema', 'aiMetadata', 'contentDensity']).toContain(issue.dimension);
expect(['critical', 'warning', 'info']).toContain(issue.severity);
expect(typeof issue.message).toBe('string');
expect((issue.message as string).length).toBeGreaterThan(0);
}

function expectSuggestionContract(suggestion: Record<string, unknown>): void {
expect(['structure', 'citability', 'schema', 'aiMetadata', 'contentDensity']).toContain(suggestion.dimension);
expect(['high', 'medium', 'low']).toContain(suggestion.impact);
expect(typeof suggestion.action).toBe('string');
expect(typeof suggestion.detail).toBe('string');
expect((suggestion.action as string).length).toBeGreaterThan(0);
expect((suggestion.detail as string).length).toBeGreaterThan(0);
}

describe('v0.6 public rule fixture corpus', () => {
const scoredRules = allRules.filter((rule) => rule.weight > 0);

Expand All @@ -44,6 +104,19 @@ describe('v0.6 public rule fixture corpus', () => {
expect(result.score).toBe(fixture.expected.score);
expect(result.issues).toHaveLength(fixture.expected.issues);
expect(result.suggestions).toHaveLength(fixture.expected.suggestions);
result.issues.forEach((issue) => expectIssueContract(issue as unknown as Record<string, unknown>));
result.suggestions.forEach((suggestion) => expectSuggestionContract(suggestion as unknown as Record<string, unknown>));
});

it(`${kind} fixture survives the HTML parser boundary`, () => {
const fixture = ruleFixtureCorpus[rule.id][kind];
const parsedDocument = parseHtml(renderFixtureHtml(fixture.document), 'fixture://v0.6-rule-corpus.html');
const result = rule.evaluate(parsedDocument);

expect(result.maxScore).toBe(rule.weight);
expect(result.score).toBe(fixture.expected.score);
expect(result.issues).toHaveLength(fixture.expected.issues);
expect(result.suggestions).toHaveLength(fixture.expected.suggestions);
});
}
});
Expand All @@ -60,6 +133,10 @@ describe('v0.6 public rule fixture corpus', () => {
expect(ruleFixtureCorpusVersion).toBe(packageJson.version);
expect(methodology).toContain(`v${packageJson.version} scoring contract`);
expect(sampleWorkflow).toContain(`uses: cucuwang/aeoptimize@v${packageJson.version}`);
expect(sampleWorkflow).toContain('permissions:\n contents: read');
expect(sampleWorkflow).toContain('path: site');
expect(sampleWorkflow).toContain("fail-on-low-score: 'false'");
expect(sampleWorkflow).not.toContain('package-spec:');

for (const rule of allRules) {
expect(methodology).toContain(`\`${rule.id}\``);
Expand Down Expand Up @@ -87,6 +164,16 @@ describe('v0.6 JSON automation contract', () => {
expect(Object.keys(report.pages[0]).sort()).toEqual(['issues', 'scores', 'suggestions', 'title', 'url']);
expect(Object.keys(report.pages[0].scores).sort()).toEqual(Object.keys(report.overall).sort());
expect(Number.isNaN(Date.parse(report.timestamp))).toBe(false);
expect(typeof report.summary).toBe('string');
expect(typeof report.pages[0].title).toBe('string');
expect(typeof report.pages[0].url).toBe('string');
for (const score of [...Object.values(report.overall), ...Object.values(report.pages[0].scores)]) {
expect(Number.isInteger(score)).toBe(true);
expect(score).toBeGreaterThanOrEqual(0);
expect(score).toBeLessThanOrEqual(100);
}
report.pages[0].issues.forEach((issue) => expectIssueContract(issue as unknown as Record<string, unknown>));
report.pages[0].suggestions.forEach((suggestion) => expectSuggestionContract(suggestion as unknown as Record<string, unknown>));
});

it('ships release and rollback instructions with the package', async () => {
Expand All @@ -97,7 +184,10 @@ describe('v0.6 JSON automation contract', () => {
expect(packageJson.files).toContain('docs/release-v0.6.md');
expect(packageJson.files).toContain('fixtures/');
expect(packageJson.files).toContain('examples/github-action-sample/');
expect(packageJson.files).toContain('scripts/verify-release-candidate.sh');
expect(packageJson.files).toContain('scripts/verify-release-v0.6.sh');
expect(packageJson.scripts['release:check']).toBe('bash scripts/verify-release-candidate.sh');
expect(packageJson.scripts.prepublishOnly).toBe('npm run release:check');
expect(releaseGuide).toContain('## Rollback');
expect(releaseGuide).toContain('npm dist-tag add aeoptimize@0.5.3 latest');
expect(releaseGuide).toContain('<verified-package-sha256>');
Expand Down
Loading
Loading