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
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
},
"metadata": {
"description": "Content-readiness lint with evidence-bounded AI discovery experiments",
"version": "0.6.1"
"version": "0.6.2"
},
"plugins": [
{
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
},
Expand Down
12 changes: 6 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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:
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.

## 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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,15 @@ 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
```

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'
Expand Down
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion action/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
21 changes: 11 additions & 10 deletions docs/release-v0.6.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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.

Expand All @@ -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.
Expand All @@ -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.

Expand All @@ -56,11 +57,11 @@ bash scripts/verify-release-v0.6.sh <verified-release-commit> <verified-package-

An npm dist-tag rollback changes what `npm install aeoptimize` selects; it does not remove exact-version installs. Never silently move an existing Git tag to different code.

If npm 0.6.1 is unsuitable before a corrective release is available, request separate authorization for each mutation, then:
If npm 0.6.2 is unsuitable, request separate authorization for each mutation and restore `latest` to the last fully released version rather than the provenance-mismatched 0.6.1 artifact:

```bash
npm dist-tag add aeoptimize@0.6.0 latest
npm deprecate aeoptimize@0.6.1 "Use 0.6.0 until the corrective release is available."
npm deprecate aeoptimize@0.6.2 "Use 0.6.0 until the corrective release is available."
```

Mark the GitHub Release with the same warning. Preserve the `v0.6.1` tag as evidence of what was published, fix forward in 0.6.2, rerun the complete release acceptance suite, and only then move npm `latest` to the corrective version.
Mark the GitHub Release with the same warning. Never create or retarget `v0.6.1` merely to mask its npm `gitHead` mismatch. Preserve immutable versions as evidence, fix forward in 0.6.3, rerun the complete release acceptance suite, and only then move npm `latest` to the corrective version.
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: cucuwang/aeoptimize@v0.6.1
- uses: cucuwang/aeoptimize@v0.6.2
with:
path: site
fail-on-low-score: 'false'
2 changes: 1 addition & 1 deletion examples/github-action-sample/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ This directory is a copyable end-to-end sample for the v0.6 Action contract.
- `site/index.html` is a deterministic public input.
- The Action is advisory by default. The sample does not block a pull request on an unreviewed score threshold.

The workflow becomes reproducible only after both `aeoptimize@0.6.1` exists on npm and the immutable `v0.6.1` Git tag points to the matching release commit. Until both artifacts exist, use the local CLI or the release-candidate package during controlled verification.
The workflow becomes reproducible only after both `aeoptimize@0.6.2` exists on npm and the immutable `v0.6.2` Git tag points to the matching release commit. Until both artifacts exist, use the local CLI or the release-candidate package during controlled verification.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "aeoptimize",
"version": "0.6.1",
"version": "0.6.2",
"description": "Deterministic content-readiness lint for static websites and documentation",
"type": "module",
"main": "./dist/core/index.js",
Expand All @@ -21,6 +21,7 @@
"fixtures/",
"examples/github-action-sample/",
"scripts/verify-release-candidate.sh",
"scripts/verify-publish-source.sh",
"scripts/verify-release-v0.6.sh",
".claude-plugin/",
"docs/methodology.md",
Expand All @@ -39,7 +40,7 @@
"dev": "tsc --watch",
"test": "vitest run",
"test:watch": "vitest",
"prepublishOnly": "npm run release:check"
"prepublishOnly": "npm run release:check && bash scripts/verify-publish-source.sh"
},
"keywords": [
"aeo",
Expand Down
25 changes: 25 additions & 0 deletions scripts/verify-publish-source.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -u

if ! command -v git >/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."
2 changes: 1 addition & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────

Expand Down
2 changes: 1 addition & 1 deletion src/core/__tests__/evidence-boundaries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}')`);
Expand Down
96 changes: 96 additions & 0 deletions src/core/__tests__/publish-source.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}): Promise<CommandResult> {
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');
});
});
5 changes: 4 additions & 1 deletion src/core/__tests__/release-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<verified-package-sha256>');
Expand Down
Loading
Loading