diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c3cc52..e44c71d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index ac51e8f..aff64af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 3589a56..eba5508 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/docs/release-v0.6.md b/docs/release-v0.6.md index a61f5e9..e92366b 100644 --- a/docs/release-v0.6.md +++ b/docs/release-v0.6.md @@ -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 diff --git a/fixtures/v0.6/rule-corpus.ts b/fixtures/v0.6/rule-corpus.ts index ebd9c2d..dd97479 100644 --- a/fixtures/v0.6/rule-corpus.ts +++ b/fixtures/v0.6/rule-corpus.ts @@ -126,14 +126,26 @@ export const ruleFixtureCorpus: Record = { 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 }, }, diff --git a/package.json b/package.json index 9e062a7..9540a3c 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/scripts/verify-release-candidate.sh b/scripts/verify-release-candidate.sh new file mode 100755 index 0000000..c673c8a --- /dev/null +++ b/scripts/verify-release-candidate.sh @@ -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." diff --git a/scripts/verify-release-v0.6.sh b/scripts/verify-release-v0.6.sh index 338f657..c0207a0 100755 --- a/scripts/verify-release-v0.6.sh +++ b/scripts/verify-release-v0.6.sh @@ -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 @@ -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" diff --git a/src/core/__tests__/release-contract.test.ts b/src/core/__tests__/release-contract.test.ts index 49a39ec..87eba87 100644 --- a/src/core/__tests__/release-contract.test.ts +++ b/src/core/__tests__/release-contract.test.ts @@ -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)); @@ -25,6 +25,66 @@ function makeDocument(overrides: Partial): ParsedDocument { }; } +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +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): string { + const head = Object.entries(document.metaTags ?? {}) + .map(([name, content]) => ``) + .join(''); + const headings = (document.headings ?? []) + .map(({ level, text }) => `${escapeHtml(text)}`) + .join(''); + const paragraphs = (document.paragraphs ?? []) + .map((paragraph) => `

${escapeHtml(paragraph)}

`) + .join(''); + const jsonLd = (document.jsonLd ?? []) + .map((value) => ``) + .join(''); + const links = (document.links ?? []) + .map(({ href, text, rel }) => `${escapeHtml(text)}`) + .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(`${suppliedHtml}`, 'fixture://fragment').rawText, + ]) { + if (knownText) residualText = removeFirst(residualText, knownText); + } + + return `${head}${jsonLd}
${headings}${paragraphs}${links}${suppliedHtml}
${escapeHtml(residualText)}
`; +} + +function expectIssueContract(issue: Record): 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): 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); @@ -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)); + result.suggestions.forEach((suggestion) => expectSuggestionContract(suggestion as unknown as Record)); + }); + + 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); }); } }); @@ -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}\``); @@ -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)); + report.pages[0].suggestions.forEach((suggestion) => expectSuggestionContract(suggestion as unknown as Record)); }); it('ships release and rollback instructions with the package', async () => { @@ -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(''); diff --git a/src/core/__tests__/release-verifier.test.ts b/src/core/__tests__/release-verifier.test.ts index 970e720..11fd249 100644 --- a/src/core/__tests__/release-verifier.test.ts +++ b/src/core/__tests__/release-verifier.test.ts @@ -1,6 +1,6 @@ import { spawn } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -19,15 +19,26 @@ interface CommandResult { stderr: string; } -function runVerifier(mockBin: string, packageHash: string, npmGitHead = expectedCommit): Promise { +function runVerifier( + mockBin: string, + packageHash: string, + overrides: Record = {}, +): Promise { return new Promise((resolve, reject) => { const child = spawn('bash', [verifier, expectedCommit, packageHash], { env: { ...process.env, PATH: `${mockBin}:${process.env.PATH}`, - MOCK_NPM_GIT_HEAD: npmGitHead, + MOCK_LATEST: '0.6.0', + MOCK_NPM_GIT_HEAD: expectedCommit, + MOCK_REPOSITORY_URL: 'git+https://github.com/cucuwang/aeoptimize.git', MOCK_TAG_COMMIT: expectedCommit, MOCK_TARBALL_CONTENT: tarballContent, + MOCK_RELEASE_DRAFT: 'false', + MOCK_RELEASE_PRERELEASE: 'false', + MOCK_MISSING_BINARY: '', + MOCK_NPM_ARGS_FILE: join(dirname(mockBin), 'npm-args.txt'), + ...overrides, }, }); let stdout = ''; @@ -69,13 +80,13 @@ done case "$url" in https://registry.npmjs.org/aeoptimize) - printf '{"dist-tags":{"latest":"0.6.0"},"versions":{"0.6.0":{"gitHead":"%s","repository":{"url":"git+https://github.com/cucuwang/aeoptimize.git"},"homepage":"https://github.com/cucuwang/aeoptimize","bugs":{"url":"https://github.com/cucuwang/aeoptimize/issues"},"dist":{"tarball":"https://registry.npmjs.org/aeoptimize/-/aeoptimize-0.6.0.tgz"}}}}' "$MOCK_NPM_GIT_HEAD" + printf '{"dist-tags":{"latest":"%s"},"versions":{"0.6.0":{"gitHead":"%s","repository":{"url":"%s"},"homepage":"https://github.com/cucuwang/aeoptimize","bugs":{"url":"https://github.com/cucuwang/aeoptimize/issues"},"dist":{"tarball":"https://registry.npmjs.org/aeoptimize/-/aeoptimize-0.6.0.tgz"}}}}' "$MOCK_LATEST" "$MOCK_NPM_GIT_HEAD" "$MOCK_REPOSITORY_URL" ;; https://registry.npmjs.org/aeoptimize/-/aeoptimize-0.6.0.tgz) printf '%s' "$MOCK_TARBALL_CONTENT" > "$output_file" ;; https://api.github.com/repos/cucuwang/aeoptimize/releases/tags/v0.6.0) - printf '{"tag_name":"v0.6.0","draft":false,"prerelease":false}' > "$output_file" + printf '{"tag_name":"v0.6.0","draft":%s,"prerelease":%s}' "$MOCK_RELEASE_DRAFT" "$MOCK_RELEASE_PRERELEASE" > "$output_file" printf '200' ;; *) @@ -93,6 +104,7 @@ printf '%s\trefs/tags/v0.6.0\n' "$MOCK_TAG_COMMIT" await writeExecutable(join(mockBin, 'npm'), `#!/usr/bin/env bash set -euo pipefail prefix= +printf '%s\n' "$*" > "$MOCK_NPM_ARGS_FILE" while [ "$#" -gt 0 ]; do case "$1" in --prefix) prefix=$2; shift 2 ;; @@ -101,6 +113,9 @@ while [ "$#" -gt 0 ]; do done mkdir -p "$prefix/node_modules/.bin" for binary in aeoptimize aeo aeo-cli; do + if [ "$binary" = "$MOCK_MISSING_BINARY" ]; then + continue + fi printf '#!/usr/bin/env bash\nprintf "0.6.0\\n"\n' > "$prefix/node_modules/.bin/$binary" chmod +x "$prefix/node_modules/.bin/$binary" done @@ -113,12 +128,15 @@ done it('passes only when npm metadata, tarball, aliases, tag, and Release match', async () => { const result = await runVerifier(mockBin, expectedTarballHash); + const npmArgs = await readFile(join(testRoot, 'npm-args.txt'), 'utf8'); expect(result.code).toBe(0); expect(result.stderr).toBe(''); expect(result.stdout).toContain('PASS: npm gitHead matches'); expect(result.stdout).toContain('PASS: npm tarball SHA-256 matches the verified candidate'); expect(result.stdout).toContain('All public release checks passed.'); + expect(npmArgs).toMatch(/aeoptimize-0\.6\.0\.tgz/); + expect(npmArgs).not.toContain('aeoptimize@0.6.0'); }); it('fails closed when npm serves a different tarball', async () => { @@ -131,7 +149,7 @@ done }); it('accepts a missing optional gitHead when the tarball identity matches', async () => { - const result = await runVerifier(mockBin, expectedTarballHash, ''); + const result = await runVerifier(mockBin, expectedTarballHash, { MOCK_NPM_GIT_HEAD: '' }); expect(result.code).toBe(0); expect(result.stdout).toContain('INFO: npm does not expose gitHead'); @@ -140,9 +158,41 @@ done it('fails closed when npm exposes a different gitHead', async () => { const differentCommit = 'fedcba9876543210fedcba9876543210fedcba98'; - const result = await runVerifier(mockBin, expectedTarballHash, differentCommit); + const result = await runVerifier(mockBin, expectedTarballHash, { MOCK_NPM_GIT_HEAD: differentCommit }); expect(result.code).toBe(1); expect(result.stderr).toContain(`FAIL: npm gitHead is ${differentCommit}`); }); + + it('fails closed when an alias is missing from the verified tarball', async () => { + const result = await runVerifier(mockBin, expectedTarballHash, { MOCK_MISSING_BINARY: 'aeo-cli' }); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('FAIL: aeo-cli returned no version'); + }); + + it('fails closed when the Git tag points to a different commit', async () => { + const result = await runVerifier(mockBin, expectedTarballHash, { + MOCK_TAG_COMMIT: 'fedcba9876543210fedcba9876543210fedcba98', + }); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('FAIL: v0.6.0 points to'); + }); + + it('fails closed when the GitHub Release is a draft', async () => { + const result = await runVerifier(mockBin, expectedTarballHash, { MOCK_RELEASE_DRAFT: 'true' }); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('FAIL: GitHub Release is not a published non-prerelease'); + }); + + it('fails closed when public repository identity changes', async () => { + const result = await runVerifier(mockBin, expectedTarballHash, { + MOCK_REPOSITORY_URL: 'git+https://github.com/example/other.git', + }); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('FAIL: npm repository identity does not match'); + }); });