diff --git a/.github/workflows/bump-aeo-audit.yml b/.github/workflows/bump-aeo-audit.yml new file mode 100644 index 00000000..ac105f6c --- /dev/null +++ b/.github/workflows/bump-aeo-audit.yml @@ -0,0 +1,106 @@ +name: Bump aeo-audit + +# Keeps the pinned @ainyc/aeo-audit engine current. aeo-audit ships often, so +# this checks npm on a schedule, bumps the exact pin, and — only if the bump +# survives typecheck, lint, build, and the full test suite — opens a PR. The +# build gate matters: aeo-audit is bundled into the published package via tsup, +# so a breaking major can break the bundle without failing typecheck. Gating +# happens IN this job (not just on the PR), so a breaking aeo-audit release +# fails here and never produces a green PR. Run it on demand from the Actions +# tab via "Run workflow". + +on: + schedule: + # Mondays 09:00 UTC. Tighten the cron if you ship aeo-audit more often. + - cron: '0 9 * * 1' + workflow_dispatch: + inputs: + version: + description: 'Target @ainyc/aeo-audit version (blank = npm latest dist-tag)' + required: false + default: '' + bump_canonry_version: + description: 'Also patch @ainyc/canonry version (default off: engine updates in-repo, ships with the next canonry release)' + type: boolean + required: false + default: false + +permissions: + contents: write + pull-requests: write + +# Never let two bump runs race to open competing PRs / branches. +concurrency: + group: bump-aeo-audit + cancel-in-progress: true + +jobs: + bump: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10.28.2 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + registry-url: https://registry.npmjs.org + + - name: Resolve target + rewrite package.json pins + id: bump + shell: bash + env: + AEO_AUDIT_VERSION: ${{ github.event.inputs.version }} + # Empty on the schedule, "false" by default on dispatch -> no canonry + # version bump. Toggle the input on (passes "true") to opt in. + BUMP_CANONRY_VERSION: ${{ github.event.inputs.bump_canonry_version }} + run: node scripts/bump-aeo-audit.mjs + + - name: Update lockfile + install + if: steps.bump.outputs.changed == 'true' + run: pnpm install --no-frozen-lockfile + + - name: Typecheck (gate the bump) + if: steps.bump.outputs.changed == 'true' + run: pnpm run typecheck + + - name: Lint (gate the bump) + if: steps.bump.outputs.changed == 'true' + run: pnpm run lint + + # Build all packages recursively (matches ci.yml's build job). tsup bundles + # @ainyc/aeo-audit into the published canonry, so this catches a breaking + # major that typecheck misses. Runs before the long test suite to fail fast. + - name: Build (gate the bump) + if: steps.bump.outputs.changed == 'true' + run: pnpm -r run build + + - name: Test (gate the bump) + if: steps.bump.outputs.changed == 'true' + run: pnpm run test + + - name: Open pull request + if: steps.bump.outputs.changed == 'true' + uses: peter-evans/create-pull-request@v7 + with: + # Prefer a configured PAT/app token so the PR triggers the CI workflow; + # fall back to GITHUB_TOKEN (PR still opens, but the default token does + # not re-trigger `ci.yml` — this job's own typecheck/lint/build/test are the gate). + token: ${{ secrets.AEO_AUDIT_BUMP_TOKEN || secrets.GITHUB_TOKEN }} + base: main + branch: chore/bump-aeo-audit + delete-branch: true + commit-message: 'chore(deps): bump @ainyc/aeo-audit to ${{ steps.bump.outputs.to }}' + title: 'chore(deps): bump @ainyc/aeo-audit to ${{ steps.bump.outputs.to }}' + labels: dependencies + body: | + Automated bump of **`@ainyc/aeo-audit`** `${{ steps.bump.outputs.from }}` → `${{ steps.bump.outputs.to }}`. + + - ${{ steps.bump.outputs.version_note }} + - This job already ran `pnpm run typecheck`, `pnpm run lint`, `pnpm -r run build`, and `pnpm run test` against the bump — all passed. + + Review the aeo-audit changelog for behavioral changes, then merge. Generated by `.github/workflows/bump-aeo-audit.yml`. diff --git a/apps/worker/package.json b/apps/worker/package.json index 83e2c97d..e96661f7 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -11,7 +11,7 @@ "lint": "eslint src/ test/" }, "dependencies": { - "@ainyc/aeo-audit": "3.0.0", + "@ainyc/aeo-audit": "4.0.1", "@ainyc/canonry-config": "workspace:*", "tsx": "^4.20.5" } diff --git a/package.json b/package.json index 8791299d..0c892766 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "canonry", "private": true, - "version": "4.82.0", + "version": "4.83.0", "type": "module", "packageManager": "pnpm@10.28.2", "scripts": { diff --git a/packages/canonry/package.json b/packages/canonry/package.json index 61dfc1b6..b486dad3 100644 --- a/packages/canonry/package.json +++ b/packages/canonry/package.json @@ -1,6 +1,6 @@ { "name": "@ainyc/canonry", - "version": "4.82.0", + "version": "4.83.0", "type": "module", "description": "Agent-first open-source AEO operating platform - track how answer engines cite your domain", "license": "FSL-1.1-ALv2", @@ -44,7 +44,7 @@ "lint": "eslint src/ test/" }, "dependencies": { - "@ainyc/aeo-audit": "3.0.0", + "@ainyc/aeo-audit": "4.0.1", "@anthropic-ai/sdk": "^0.91.1", "@fastify/static": "^9.1.1", "@google/genai": "^1.46.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e591212..128d515c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -168,8 +168,8 @@ importers: apps/worker: dependencies: '@ainyc/aeo-audit': - specifier: 3.0.0 - version: 3.0.0 + specifier: 4.0.1 + version: 4.0.1 '@ainyc/canonry-config': specifier: workspace:* version: link:../../packages/config @@ -255,8 +255,8 @@ importers: packages/canonry: dependencies: '@ainyc/aeo-audit': - specifier: 3.0.0 - version: 3.0.0 + specifier: 4.0.1 + version: 4.0.1 '@anthropic-ai/sdk': specifier: ^0.91.1 version: 0.91.1(zod@4.3.6) @@ -557,8 +557,8 @@ importers: packages: - '@ainyc/aeo-audit@3.0.0': - resolution: {integrity: sha512-n1p8QnbGk7UbRlp4eRQ7BNMhz6YVmlW6HJ93rQ+CHgN3PYPkcCdjbeT+oxlVtj/M3kJ7RH03VMvxxb5mSLfD5A==} + '@ainyc/aeo-audit@4.0.1': + resolution: {integrity: sha512-8K0fjY5uxcJSEaYW0ccNxxr3BM+nBbMrrltHaKAso6+SFJybK57gLDeIp5h7f+GSJ2QaeZR51XUxGYBgqkQKEQ==} engines: {node: '>=20'} hasBin: true @@ -4785,7 +4785,7 @@ packages: snapshots: - '@ainyc/aeo-audit@3.0.0': + '@ainyc/aeo-audit@4.0.1': dependencies: cheerio: 1.2.0 ipaddr.js: 2.3.0 diff --git a/scripts/bump-aeo-audit.mjs b/scripts/bump-aeo-audit.mjs new file mode 100644 index 00000000..e8e3ded5 --- /dev/null +++ b/scripts/bump-aeo-audit.mjs @@ -0,0 +1,152 @@ +#!/usr/bin/env node +// Bump the pinned @ainyc/aeo-audit dependency to a target version. +// +// aeo-audit is the real audit engine (runAeoAudit / runSitemapAudit). canonry +// pins it to an EXACT version on purpose: aeo-audit ships breaking majors +// (e.g. 3.x -> 4.x), and an exact pin forces every bump through CI (typecheck + +// the 4000+ test suite) instead of a floating `^` silently pulling a release +// that changes the report shape mid-build. This script is that controlled bump. +// +// By default it bumps ONLY the engine dependency, not canonry's own version — +// an aeo-audit bump and a canonry npm release are decoupled, so the engine +// updates in-repo and ships with the next canonry release. Pass `--version-bump` +// to also patch canonry so the bump publishes to npm on merge. +// +// Usage (local release step): +// node scripts/bump-aeo-audit.mjs # bump engine to the npm `latest` dist-tag +// node scripts/bump-aeo-audit.mjs 4.1.0 # bump engine to an explicit version +// node scripts/bump-aeo-audit.mjs --version-bump # ALSO patch canonry's version +// pnpm install # then refresh the lockfile + node_modules +// +// Environment (used by .github/workflows/bump-aeo-audit.yml): +// AEO_AUDIT_VERSION target version (overridden by a positional arg) +// BUMP_CANONRY_VERSION "true" to also patch the canonry version (default: no bump) +// GITHUB_OUTPUT when set, the script appends `changed`/`from`/`to`/ +// `canonry_from`/`canonry_to`/`version_note` step outputs. +// +// The script only edits files. It never runs `pnpm install`, so the caller +// controls when the lockfile is regenerated. + +import { execFileSync } from 'node:child_process' +import { appendFileSync, readFileSync, writeFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..') + +const DEP = '@ainyc/aeo-audit' +// Every package.json that pins the dependency. Add new consumers here. +const DEP_MANIFESTS = ['packages/canonry/package.json', 'apps/worker/package.json'] +// Published packages whose version must stay in lockstep (see AGENTS.md → Versioning). +const VERSION_MANIFESTS = ['package.json', 'packages/canonry/package.json'] + +function readJson(relPath) { + return JSON.parse(readFileSync(join(repoRoot, relPath), 'utf8')) +} + +/** + * Replace a `"key": "value"` string field in a manifest by exact text match so + * the file's existing formatting (indent, key order, trailing newline) is + * preserved — JSON.parse/stringify would risk reflowing the whole file. + */ +function replaceField(relPath, key, expectedValue, nextValue) { + const absPath = join(repoRoot, relPath) + const before = readFileSync(absPath, 'utf8') + const needle = `"${key}": "${expectedValue}"` + if (!before.includes(needle)) { + throw new Error(`Could not find ${needle} in ${relPath} (already bumped, or formatting drifted?)`) + } + writeFileSync(absPath, before.replace(needle, `"${key}": "${nextValue}"`)) +} + +function resolveLatestVersion() { + // `npm view` reads the npm registry; the `latest` dist-tag is the version a + // bare `npm install @ainyc/aeo-audit` would resolve to. + const out = execFileSync('npm', ['view', DEP, 'dist-tags.latest'], { encoding: 'utf8' }) + const version = out.trim() + if (!/^\d+\.\d+\.\d+/.test(version)) { + throw new Error(`Unexpected version from npm for ${DEP}: "${version}"`) + } + return version +} + +function bumpPatch(version) { + const match = /^(\d+)\.(\d+)\.(\d+)(.*)$/.exec(version) + if (!match) throw new Error(`Cannot patch-bump non-semver version: "${version}"`) + const [, major, minor, patch] = match + return `${major}.${minor}.${Number(patch) + 1}` +} + +function emitOutput(pairs) { + if (!process.env.GITHUB_OUTPUT) return + const lines = Object.entries(pairs).map(([k, v]) => `${k}=${v}`) + appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`) +} + +function main() { + const args = process.argv.slice(2) + // Canonry's own version is NOT bumped by default — an aeo-audit engine bump and + // a canonry npm release are decoupled. Opt in with `--version-bump` (or + // BUMP_CANONRY_VERSION=true) when you want the bump to ship to npm on merge. + // An explicit `--no-version-bump` still works and wins over any opt-in. + const bumpCanonryVersion = + !args.includes('--no-version-bump') && + (args.includes('--version-bump') || process.env.BUMP_CANONRY_VERSION === 'true') + const positional = args.find((arg) => !arg.startsWith('--')) + const requested = positional || process.env.AEO_AUDIT_VERSION || '' + const target = requested.trim() || resolveLatestVersion() + + // Read the currently-pinned spec from the canonical consumer. Preserve any + // leading range operator (^ / ~) so an intentional range pin stays a range, + // even though canonry pins exact today. + const canonryPkg = readJson('packages/canonry/package.json') + const currentSpec = canonryPkg.dependencies?.[DEP] + if (!currentSpec) throw new Error(`${DEP} not found in packages/canonry/package.json dependencies`) + const rangePrefix = /^[\^~]/.test(currentSpec) ? currentSpec[0] : '' + const currentVersion = currentSpec.replace(/^[\^~]/, '') + const nextSpec = `${rangePrefix}${target}` + + if (currentSpec === nextSpec) { + console.log(`${DEP} already at ${currentSpec} — nothing to bump.`) + emitOutput({ changed: 'false', from: currentVersion, to: target }) + return + } + + for (const manifest of DEP_MANIFESTS) { + const pkg = readJson(manifest) + const spec = pkg.dependencies?.[DEP] + if (!spec) throw new Error(`${DEP} not found in ${manifest} dependencies`) + replaceField(manifest, DEP, spec, nextSpec) + console.log(`${manifest}: ${DEP} ${spec} -> ${nextSpec}`) + } + + const canonryFrom = canonryPkg.version + let canonryTo = canonryFrom + if (bumpCanonryVersion) { + canonryTo = bumpPatch(canonryFrom) + for (const manifest of VERSION_MANIFESTS) { + const pkg = readJson(manifest) + replaceField(manifest, 'version', pkg.version, canonryTo) + console.log(`${manifest}: version ${pkg.version} -> ${canonryTo}`) + } + } else { + console.log(`Leaving canonry version at ${canonryFrom} (default; pass --version-bump to ship on merge).`) + } + + const versionNote = bumpCanonryVersion + ? `\`@ainyc/canonry\` ${canonryFrom} -> ${canonryTo} (ships to npm on merge).` + : '`@ainyc/canonry` version unchanged — the engine updates in-repo and ships with the next canonry release.' + + emitOutput({ + changed: 'true', + from: currentVersion, + to: target, + canonry_from: canonryFrom, + canonry_to: canonryTo, + version_note: versionNote, + }) + + console.log(`\nBumped ${DEP} ${currentVersion} -> ${target}. Next: run \`pnpm install\` to update the lockfile.`) +} + +main()