From 1fb6fdc8faf364be63575f6189da0baf335fdeac Mon Sep 17 00:00:00 2001 From: Te-Shu Wang Date: Mon, 24 Aug 2026 19:01:35 +0800 Subject: [PATCH] chore: prepare v0.6.2 provenance release --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .github/workflows/ci.yml | 12 +-- CHANGELOG.md | 8 ++ README.md | 4 +- action.yml | 2 +- action/action.yml | 2 +- docs/release-v0.6.md | 21 ++-- .../.github/workflows/aeoptimize.yml | 2 +- examples/github-action-sample/README.md | 2 +- package-lock.json | 4 +- package.json | 5 +- scripts/verify-publish-source.sh | 25 +++++ src/cli/index.ts | 2 +- .../__tests__/evidence-boundaries.test.ts | 2 +- src/core/__tests__/publish-source.test.ts | 96 +++++++++++++++++++ src/core/__tests__/release-contract.test.ts | 5 +- src/core/__tests__/release-verifier.test.ts | 22 ++--- 18 files changed, 176 insertions(+), 42 deletions(-) create mode 100755 scripts/verify-publish-source.sh create mode 100644 src/core/__tests__/publish-source.test.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 20f857a..e0fbfe1 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,7 +5,7 @@ }, "metadata": { "description": "Content-readiness lint with evidence-bounded AI discovery experiments", - "version": "0.6.1" + "version": "0.6.2" }, "plugins": [ { diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index d72e41d..2a2d65d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "aeoptimize", "description": "Content-readiness lint and evidence-bounded discovery experiments", - "version": "0.6.1", + "version": "0.6.2", "author": { "name": "Te-Shu Wang" }, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61c2c34..68d633c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,7 +84,7 @@ jobs: with: path: .github/fixtures/action-low min-score: '100' - package-spec: ./aeoptimize-0.6.1.tgz + package-spec: ./aeoptimize-0.6.2.tgz - name: Validate Action outputs env: @@ -101,7 +101,7 @@ jobs: uses: ./ with: path: examples/github-action-sample/site - package-spec: ./aeoptimize-0.6.1.tgz + package-spec: ./aeoptimize-0.6.2.tgz - name: Validate sample outputs env: @@ -120,7 +120,7 @@ jobs: path: .github/fixtures/action-low min-score: '0' fail-on-low-score: 'true' - package-spec: ./aeoptimize-0.6.1.tgz + package-spec: ./aeoptimize-0.6.2.tgz - name: Validate blocking outputs env: @@ -140,7 +140,7 @@ jobs: path: .github/fixtures/action-low min-score: '100' fail-on-low-score: 'true' - package-spec: ./aeoptimize-0.6.1.tgz + package-spec: ./aeoptimize-0.6.2.tgz - name: Invalid choice is rejected id: invalid-choice @@ -149,7 +149,7 @@ jobs: with: path: .github/fixtures/action-low fail-on-low-score: sometimes - package-spec: ./aeoptimize-0.6.1.tgz + package-spec: ./aeoptimize-0.6.2.tgz - name: Out-of-range threshold is rejected id: invalid-threshold @@ -158,7 +158,7 @@ jobs: with: path: .github/fixtures/action-low min-score: '101' - package-spec: ./aeoptimize-0.6.1.tgz + package-spec: ./aeoptimize-0.6.2.tgz - name: Assert expected failures env: diff --git a/CHANGELOG.md b/CHANGELOG.md index 54ce665..11787f6 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. +## 0.6.2 + +### Fixed + +- Added a fail-closed publish source gate that requires `HEAD` to match the fetched `origin/main` commit before npm publication. +- Prepared a corrective release after npm 0.6.1 exposed the pre-squash PR commit as `gitHead`; the verified package contents were correct, but the release provenance did not match the merged commit. +- Kept scoring rules, output contracts, and runtime behavior unchanged. + ## 0.6.1 ### Fixed diff --git a/README.md b/README.md index e48325a..b884e7b 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ node -e "const r=require('./aeoptimize-report.json'); process.exit(r.overall.tot The v0.6 GitHub Action is advisory by default. It reports findings without blocking the workflow: ```yaml -- uses: cucuwang/aeoptimize@v0.6.1 +- uses: cucuwang/aeoptimize@v0.6.2 with: path: dist ``` @@ -72,7 +72,7 @@ The v0.6 GitHub Action is advisory by default. It reports findings without block Projects can explicitly choose blocking mode after accepting a baseline: ```yaml -- uses: cucuwang/aeoptimize@v0.6.1 +- uses: cucuwang/aeoptimize@v0.6.2 with: path: dist fail-on-low-score: 'true' diff --git a/action.yml b/action.yml index cb6b20b..f4e1a41 100644 --- a/action.yml +++ b/action.yml @@ -20,7 +20,7 @@ inputs: package-spec: description: npm package spec to install; keep the default outside prerelease testing required: false - default: 'aeoptimize@0.6.1' + default: 'aeoptimize@0.6.2' outputs: score: diff --git a/action/action.yml b/action/action.yml index 119c025..2841bd9 100644 --- a/action/action.yml +++ b/action/action.yml @@ -20,7 +20,7 @@ inputs: package-spec: description: npm package spec to install; keep the default outside prerelease testing required: false - default: 'aeoptimize@0.6.1' + default: 'aeoptimize@0.6.2' outputs: score: diff --git a/docs/release-v0.6.md b/docs/release-v0.6.md index f4560a3..b98f16a 100644 --- a/docs/release-v0.6.md +++ b/docs/release-v0.6.md @@ -1,6 +1,6 @@ # v0.6 release and rollback guide -Version 0.6.1 hardens the v0.6 evidence-bounded scoring, packaging, and GitHub Action contracts. It is not released until npm, the Git tag, and the GitHub Release are each created and read back independently. +Version 0.6.2 is the provenance-corrective release for the v0.6 evidence-bounded scoring, packaging, and GitHub Action contracts. npm 0.6.1 contained the verified candidate bytes but exposed the pre-squash PR commit as `gitHead`, so no v0.6.1 Git tag or GitHub Release was created. Version 0.6.2 is not released until npm, the Git tag, and the GitHub Release are each created and read back independently. ## Release acceptance @@ -11,9 +11,9 @@ Before publication, run `npm ci` and `npm run release:check` from the intended r 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. +6. The npm account is verified immediately before publishing, and the publish source gate confirms that `HEAD` is the exact fetched `origin/main` commit. -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. +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, refuses a dirty worktree, and refuses publication unless `HEAD` matches `origin/main` exactly. Run `git fetch origin main` immediately before the authorized publish so the remote-tracking ref is current. Publishing, tagging, creating a GitHub Release, changing npm dist-tags, and deprecating a version are separate external mutations and require separate maintainer authorization. @@ -25,6 +25,7 @@ Publishing, tagging, creating a GitHub Release, changing npm dist-tags, and depr - Require byte-identical candidate manifests from Node.js 22 and 24 before the release can proceed. - Bind the public verifier's CLI smoke checks to the downloaded, hash-verified npm tarball. - Fail closed when a CLI alias, tag target, repository identity, or GitHub Release state does not match. +- Fail closed before publication when the checked-out commit differs from the fetched `origin/main` commit. - Exercise every v0.6 rule fixture through the real HTML parser boundary. No scoring rule, rule weight, JSON field, Action input, or Action output changes in this patch. No ranking, traffic, indexing, rich-result, AI Overview, or citation outcome is claimed by this release. @@ -35,16 +36,16 @@ After an authorized npm publication: ```bash npm view aeoptimize version dist-tags --json -npm view aeoptimize@0.6.1 version gitHead repository homepage bugs dist --json -consumer_root=$(mktemp -d "${TMPDIR:-/tmp}/aeoptimize-v0.6.1-consumer.XXXXXX") -npm install --prefix "$consumer_root" aeoptimize@0.6.1 +npm view aeoptimize@0.6.2 version gitHead repository homepage bugs dist --json +consumer_root=$(mktemp -d "${TMPDIR:-/tmp}/aeoptimize-v0.6.2-consumer.XXXXXX") +npm install --prefix "$consumer_root" aeoptimize@0.6.2 "$consumer_root/node_modules/.bin/aeoptimize" --version "$consumer_root/node_modules/.bin/aeo" --version "$consumer_root/node_modules/.bin/aeo-cli" --version rm -rf -- "$consumer_root" ``` -After separately authorized tag and GitHub Release creation, verify that `v0.6.1` points to the tested release commit and that the Release is published rather than draft or prerelease. +After separately authorized tag and GitHub Release creation, verify that `v0.6.2` points to the tested release commit and that the Release is published rather than draft or prerelease. The fail-closed public verifier checks npm `latest`, the exact version, public repository identity, the downloaded tarball SHA-256, all three installed CLI aliases, the tag target, and the published GitHub Release. The tarball hash is the required artifact-identity gate. If npm exposes `gitHead`, it must match the expected release commit; absence is reported as informational because npm's publish contract guarantees tarball integrity but does not guarantee that metadata field. @@ -56,11 +57,11 @@ bash scripts/verify-release-v0.6.sh /dev/null 2>&1; then + echo "missing required command: git" >&2 + exit 2 +fi + +if ! head_commit=$(git rev-parse --verify HEAD 2>/dev/null); then + echo "publish source gate: HEAD is unavailable" >&2 + exit 1 +fi + +if ! main_commit=$(git rev-parse --verify refs/remotes/origin/main 2>/dev/null); then + echo "publish source gate: origin/main is unavailable; run git fetch origin main" >&2 + exit 1 +fi + +if [ "$head_commit" != "$main_commit" ]; then + echo "publish source gate: HEAD is $head_commit; expected origin/main $main_commit" >&2 + echo "fetch origin/main and publish from its exact commit" >&2 + exit 1 +fi + +echo "Publish source gate passed: HEAD matches origin/main at $head_commit." diff --git a/src/cli/index.ts b/src/cli/index.ts index 7b28836..7119f28 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -18,7 +18,7 @@ const program = new Command(); program .name('aeoptimize') .description('Deterministic content-readiness lint for websites and documentation') - .version('0.6.1'); + .version('0.6.2'); // ── scan command ─────────────────────────────────────────────────── diff --git a/src/core/__tests__/evidence-boundaries.test.ts b/src/core/__tests__/evidence-boundaries.test.ts index fe47ce4..08184a9 100644 --- a/src/core/__tests__/evidence-boundaries.test.ts +++ b/src/core/__tests__/evidence-boundaries.test.ts @@ -83,7 +83,7 @@ describe('public metadata', () => { const action = await readFile(join(root, 'action.yml'), 'utf8'); const compatibilityAction = await readFile(join(root, 'action/action.yml'), 'utf8'); - expect(packageJson.version).toBe('0.6.1'); + expect(packageJson.version).toBe('0.6.2'); expect(pluginJson.version).toBe(packageJson.version); expect(marketplaceJson.metadata.version).toBe(packageJson.version); expect(cli).toContain(`.version('${packageJson.version}')`); diff --git a/src/core/__tests__/publish-source.test.ts b/src/core/__tests__/publish-source.test.ts new file mode 100644 index 0000000..98cb843 --- /dev/null +++ b/src/core/__tests__/publish-source.test.ts @@ -0,0 +1,96 @@ +import { spawn } from 'node:child_process'; +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +const testDirectory = dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = join(testDirectory, '../../..'); +const verifier = join(repositoryRoot, 'scripts/verify-publish-source.sh'); +const mainCommit = '0123456789abcdef0123456789abcdef01234567'; + +interface CommandResult { + code: number | null; + stdout: string; + stderr: string; +} + +function runVerifier(mockBin: string, overrides: Record = {}): Promise { + return new Promise((resolve, reject) => { + const child = spawn('bash', [verifier], { + env: { + ...process.env, + PATH: `${mockBin}:${process.env.PATH}`, + MOCK_HEAD: mainCommit, + MOCK_MAIN: mainCommit, + MOCK_MISSING_MAIN: 'false', + ...overrides, + }, + }); + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (chunk) => { stdout += chunk.toString(); }); + child.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); + child.on('error', reject); + child.on('close', (code) => resolve({ code, stdout, stderr })); + }); +} + +describe('publish source gate', () => { + let testRoot: string; + let mockBin: string; + + beforeEach(async () => { + testRoot = await mkdtemp(join(tmpdir(), 'aeoptimize-publish-source-test-')); + mockBin = join(testRoot, 'bin'); + await mkdir(mockBin); + const gitMock = join(mockBin, 'git'); + await writeFile(gitMock, `#!/usr/bin/env bash +set -euo pipefail +case "$*" in + "rev-parse --verify HEAD") + printf '%s\\n' "$MOCK_HEAD" + ;; + "rev-parse --verify refs/remotes/origin/main") + if [ "$MOCK_MISSING_MAIN" = "true" ]; then exit 1; fi + printf '%s\\n' "$MOCK_MAIN" + ;; + *) + printf 'unexpected git arguments: %s\\n' "$*" >&2 + exit 2 + ;; +esac +`, 'utf8'); + await chmod(gitMock, 0o755); + }); + + afterEach(async () => { + await rm(testRoot, { recursive: true, force: true }); + }); + + it('passes only at the exact origin/main commit', async () => { + const result = await runVerifier(mockBin); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout).toContain('HEAD matches origin/main'); + }); + + it('fails when HEAD differs from origin/main', async () => { + const result = await runVerifier(mockBin, { + MOCK_HEAD: 'fedcba9876543210fedcba9876543210fedcba98', + }); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('expected origin/main'); + }); + + it('fails when origin/main is unavailable', async () => { + const result = await runVerifier(mockBin, { MOCK_MISSING_MAIN: 'true' }); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('run git fetch origin main'); + }); +}); diff --git a/src/core/__tests__/release-contract.test.ts b/src/core/__tests__/release-contract.test.ts index 3401359..3a0b903 100644 --- a/src/core/__tests__/release-contract.test.ts +++ b/src/core/__tests__/release-contract.test.ts @@ -185,9 +185,12 @@ describe('v0.6 JSON automation contract', () => { 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-publish-source.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(packageJson.scripts.prepublishOnly).toBe( + 'npm run release:check && bash scripts/verify-publish-source.sh', + ); expect(releaseGuide).toContain('## Rollback'); expect(releaseGuide).toContain('npm dist-tag add aeoptimize@0.6.0 latest'); expect(releaseGuide).toContain(''); diff --git a/src/core/__tests__/release-verifier.test.ts b/src/core/__tests__/release-verifier.test.ts index b3aa0fc..3d67102 100644 --- a/src/core/__tests__/release-verifier.test.ts +++ b/src/core/__tests__/release-verifier.test.ts @@ -10,7 +10,7 @@ const testDirectory = dirname(fileURLToPath(import.meta.url)); const repositoryRoot = join(testDirectory, '../../..'); const verifier = join(repositoryRoot, 'scripts/verify-release-v0.6.sh'); const expectedCommit = '0123456789abcdef0123456789abcdef01234567'; -const tarballContent = 'verified aeoptimize v0.6.1 candidate'; +const tarballContent = 'verified aeoptimize v0.6.2 candidate'; const expectedTarballHash = createHash('sha256').update(tarballContent).digest('hex'); interface CommandResult { @@ -29,7 +29,7 @@ function runVerifier( env: { ...process.env, PATH: `${mockBin}:${process.env.PATH}`, - MOCK_LATEST: '0.6.1', + MOCK_LATEST: '0.6.2', MOCK_NPM_GIT_HEAD: expectedCommit, MOCK_REPOSITORY_URL: 'git+https://github.com/cucuwang/aeoptimize.git', MOCK_TAG_COMMIT: expectedCommit, @@ -80,13 +80,13 @@ done case "$url" in https://registry.npmjs.org/aeoptimize) - printf '{"dist-tags":{"latest":"%s"},"versions":{"0.6.1":{"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.1.tgz"}}}}' "$MOCK_LATEST" "$MOCK_NPM_GIT_HEAD" "$MOCK_REPOSITORY_URL" + printf '{"dist-tags":{"latest":"%s"},"versions":{"0.6.2":{"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.2.tgz"}}}}' "$MOCK_LATEST" "$MOCK_NPM_GIT_HEAD" "$MOCK_REPOSITORY_URL" ;; - https://registry.npmjs.org/aeoptimize/-/aeoptimize-0.6.1.tgz) + https://registry.npmjs.org/aeoptimize/-/aeoptimize-0.6.2.tgz) printf '%s' "$MOCK_TARBALL_CONTENT" > "$output_file" ;; - https://api.github.com/repos/cucuwang/aeoptimize/releases/tags/v0.6.1) - printf '{"tag_name":"v0.6.1","draft":%s,"prerelease":%s}' "$MOCK_RELEASE_DRAFT" "$MOCK_RELEASE_PRERELEASE" > "$output_file" + https://api.github.com/repos/cucuwang/aeoptimize/releases/tags/v0.6.2) + printf '{"tag_name":"v0.6.2","draft":%s,"prerelease":%s}' "$MOCK_RELEASE_DRAFT" "$MOCK_RELEASE_PRERELEASE" > "$output_file" printf '200' ;; *) @@ -98,7 +98,7 @@ esac await writeExecutable(join(mockBin, 'git'), `#!/usr/bin/env bash set -euo pipefail -printf '%s\trefs/tags/v0.6.1\n' "$MOCK_TAG_COMMIT" +printf '%s\trefs/tags/v0.6.2\n' "$MOCK_TAG_COMMIT" `); await writeExecutable(join(mockBin, 'npm'), `#!/usr/bin/env bash @@ -116,7 +116,7 @@ for binary in aeoptimize aeo aeo-cli; do if [ "$binary" = "$MOCK_MISSING_BINARY" ]; then continue fi - printf '#!/usr/bin/env bash\nprintf "0.6.1\\n"\n' > "$prefix/node_modules/.bin/$binary" + printf '#!/usr/bin/env bash\nprintf "0.6.2\\n"\n' > "$prefix/node_modules/.bin/$binary" chmod +x "$prefix/node_modules/.bin/$binary" done `); @@ -135,8 +135,8 @@ done 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\.1\.tgz/); - expect(npmArgs).not.toContain('aeoptimize@0.6.1'); + expect(npmArgs).toMatch(/aeoptimize-0\.6\.2\.tgz/); + expect(npmArgs).not.toContain('aeoptimize@0.6.2'); }); it('fails closed when npm serves a different tarball', async () => { @@ -177,7 +177,7 @@ done }); expect(result.code).toBe(1); - expect(result.stderr).toContain('FAIL: v0.6.1 points to'); + expect(result.stderr).toContain('FAIL: v0.6.2 points to'); }); it('fails closed when the GitHub Release is a draft', async () => {