diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 81a39f0..c814bca 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -6,18 +6,103 @@ on: push: branches: [main] workflow_dispatch: + schedule: + - cron: "17 3 * * *" + +# Hold the production lane through publication and verification. Older queued +# runs must also pass the current-main guard before they can publish or deploy. +concurrency: + group: telemetry-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'production' }} + cancel-in-progress: false permissions: contents: read jobs: + discover: + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: read + statuses: read + outputs: + refresh: ${{ steps.plan.outputs.refresh }} + deploy: ${{ steps.plan.outputs.deploy }} + plan: ${{ steps.plan.outputs.plan }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version: 24 + - id: plan + env: + GH_TOKEN: ${{ github.token }} + run: node scripts/vocabulary-maintenance.mjs plan --base "$GITHUB_SHA" --event "$GITHUB_EVENT_NAME" + + generate: + needs: discover + if: needs.discover.outputs.refresh == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + path: telemetry + persist-credentials: false + - uses: actions/checkout@v7 + with: + repository: openclaw/openclaw + ref: ${{ fromJSON(needs.discover.outputs.plan).releases[0].revision }} + path: upstream-source + fetch-depth: 0 + sparse-checkout: package.json + sparse-checkout-cone-mode: false + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version: 24 + - run: npm ci + working-directory: telemetry + # This imports released upstream packaging code. No write token or + # Cloudflare secret is available; the next job checks only its data output. + - env: + VOCABULARY_PLAN: ${{ needs.discover.outputs.plan }} + run: node scripts/vocabulary-maintenance.mjs generate --source ../upstream-source + working-directory: telemetry + - uses: actions/upload-artifact@v7 + with: + name: generated-vocabulary + if-no-files-found: error + retention-days: 1 + path: | + telemetry/data/public-vocabulary.json + telemetry/src/public-vocabulary.ts + check: + needs: [discover, generate] + if: >- + always() && !cancelled() && + (github.event_name == 'pull_request' || + (needs.discover.result == 'success' && needs.discover.outputs.deploy == 'true' && + (needs.generate.result == 'success' || needs.generate.result == 'skipped'))) runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + with: + persist-credentials: false - uses: actions/setup-node@v7 with: node-version: 24 + - uses: actions/download-artifact@v8 + if: needs.discover.outputs.refresh == 'true' + with: + name: generated-vocabulary + path: ${{ runner.temp }}/vocabulary + - if: needs.discover.outputs.refresh == 'true' + env: + VOCABULARY_PLAN: ${{ needs.discover.outputs.plan }} + run: node scripts/vocabulary-maintenance.mjs apply --artifact "$RUNNER_TEMP/vocabulary" - run: npm ci - run: npm run check - name: Check vocabulary covers the latest OpenClaw release @@ -28,17 +113,104 @@ jobs: release_sha="$(gh api "repos/openclaw/openclaw/commits/$release_tag" --jq .sha)" npm run vocabulary:check -- --release-revision "$release_sha" - run: npx wrangler deploy --dry-run + - uses: actions/upload-artifact@v7 + if: needs.discover.outputs.refresh == 'true' + with: + name: checked-vocabulary + if-no-files-found: error + retention-days: 1 + path: | + data/public-vocabulary.json + src/public-vocabulary.ts + + publish: + needs: [discover, check] + if: needs.discover.outputs.refresh == 'true' && needs.check.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + sha: ${{ steps.commit.outputs.sha }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version: 24 + - uses: actions/download-artifact@v8 + with: + name: checked-vocabulary + path: ${{ runner.temp }}/vocabulary + # No dependency installation or producer-supplied executable runs here. + - env: + VOCABULARY_PLAN: ${{ needs.discover.outputs.plan }} + run: node scripts/vocabulary-maintenance.mjs apply --artifact "$RUNNER_TEMP/vocabulary" + - id: commit + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + node scripts/vocabulary-maintenance.mjs guard-main --target "$GITHUB_SHA" + changed="$(git diff --name-only)" + test -n "$changed" + test -z "$(printf '%s\n' "$changed" | grep -Ev '^(data/public-vocabulary.json|src/public-vocabulary.ts)$')" + git diff --check + git add -- data/public-vocabulary.json src/public-vocabulary.ts + git -c user.name='github-actions[bot]' -c user.email='41898282+github-actions[bot]@users.noreply.github.com' \ + -c core.hooksPath=/dev/null commit -m 'chore(telemetry): refresh released public vocabulary' + gh auth setup-git + git -c gc.auto=0 -c maintenance.auto=false push origin HEAD:refs/heads/main + sha="$(git rev-parse HEAD)" + node scripts/vocabulary-maintenance.mjs guard-main --target "$sha" + printf 'sha=%s\n' "$sha" >> "$GITHUB_OUTPUT" deploy: - needs: check - if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + needs: [discover, check, publish] + if: >- + always() && !cancelled() && github.event_name != 'pull_request' && + github.ref == 'refs/heads/main' && needs.discover.outputs.deploy == 'true' && + needs.check.result == 'success' && + (needs.publish.result == 'success' || + (needs.publish.result == 'skipped' && needs.discover.outputs.refresh == 'false')) runs-on: ubuntu-latest + permissions: + contents: read + statuses: write + env: + TARGET_SHA: ${{ needs.publish.outputs.sha || github.sha }} steps: - uses: actions/checkout@v7 + with: + ref: ${{ env.TARGET_SHA }} + persist-credentials: false - uses: actions/setup-node@v7 with: node-version: 24 - run: npm ci - - run: npx wrangler deploy + - id: admitted + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$TARGET_SHA" + node scripts/vocabulary-maintenance.mjs guard-main --target "$TARGET_SHA" + gh api "repos/openclaw/telemetry/statuses/$TARGET_SHA" --method POST \ + -f state=pending -f context=telemetry/deploy \ + -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" --silent + - id: deployed + run: | + npx wrangler deploy --tag "$TARGET_SHA" + node scripts/vocabulary-maintenance.mjs verify-deployment --target "$TARGET_SHA" env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + # GITHUB_TOKEN's data commit does not trigger another push run. This same + # run deploys it, and a failed/missing receipt makes the next poll retry. + - if: always() && steps.admitted.outcome == 'success' + env: + GH_TOKEN: ${{ github.token }} + DEPLOY_RESULT: ${{ steps.deployed.outcome == 'success' && 'success' || 'failure' }} + run: | + gh api "repos/openclaw/telemetry/statuses/$TARGET_SHA" --method POST \ + -f state="$DEPLOY_RESULT" -f context=telemetry/deploy \ + -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" --silent diff --git a/CHANGELOG.md b/CHANGELOG.md index adb5959..170108b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Maintain public vocabulary with daily checks of immutable stable releases, retained backfill, isolated generation, constrained publication, serialized deployment, and retryable exact-commit rollout verification. - Preserve all reported public channel, provider, and plugin names instead of truncating each list to 32 before validation; keep upload and Analytics Engine byte limits covered by regression tests. - Refresh retained public names from OpenClaw 2026.9.7, including GitHub, QuickJS Code Mode, and Session Share; require a reviewed snapshot for the latest published release in the existing CI check. - Accept strictly validated, identifier-free update outcomes in a separate, explicitly configured dataset; add a side-effect-free capability check while keeping production collection unbound. Thanks @roboclaw-bot, @fuller-stack-dev, and @vincentkoc. diff --git a/README.md b/README.md index 4e728b3..d30276a 100644 --- a/README.md +++ b/README.md @@ -230,9 +230,9 @@ npm run deploy # requires Cloudflare credentials for the OpenClaw account Pull requests run the typecheck, tests, a public-vocabulary release check, and a Wrangler dry-run build using the committed lockfile. The release check resolves the latest published OpenClaw release tag -to its commit and fails if that commit has no reviewed vocabulary snapshot. GitHub lookup failures +to its commit and fails if that commit has no retained vocabulary snapshot. GitHub lookup failures fail the check; they do not report the vocabulary as fresh. -Deploys run from GitHub Actions on pushes to `main` (see +Deploys run from GitHub Actions on pushes to `main`, manual runs, and vocabulary maintenance (see [`.github/workflows/deploy.yml`](.github/workflows/deploy.yml)), using the `CLOUDFLARE_API_TOKEN` repository secret. @@ -250,8 +250,50 @@ retained snapshots, and the public source of legacy aliases (`cli`, `claude`, `g [`src/public-vocabulary.ts`](src/public-vocabulary.ts) exports the complete retained `PUBLIC_NAMES` for ingestion and offline analysis. Neither file contains names learned from telemetry requests. -Before supporting a new OpenClaw release or catalog revision, use Node.js 24 and a trusted local -OpenClaw Git repository containing the candidate commit and its history: +GitHub Actions polls published stable OpenClaw releases once daily at 03:17 UTC. It fully paginates +release metadata and resolves annotated tags to immutable commits. `releases` records the release +identity, tag, commit, and publication time. Automatic bundled-release backfill starts with +`v2026.9.7`, published on September 30, 2026; the older retained snapshots and catalog history remain +intact. This does not establish complete coverage of transient bundled names from earlier releases. +Drafts and prereleases are excluded. Every missed stable release since the inclusive anchor is +processed, including releases with the same publication timestamp. + +The existing Deploy workflow owns the whole update: + +1. Discovery makes metadata requests only. An unchanged scheduled run stops before installing + dependencies or fetching source when current main already has a successful `telemetry/deploy` + status. Missing, pending, and failed statuses retry validation and deployment. Manual runs also + provide recovery without a source change. +2. Generation runs with read-only repository permission, no persisted checkout credentials, and no + Cloudflare secret. It invokes the existing generator against immutable public Git objects. +3. A fresh read-only job validates only the two generated files against trusted repository code, + then runs the normal checks, complete-vocabulary byte-budget tests, and Wrangler dry-run. +4. A separate job can publish only `data/public-vocabulary.json` and `src/public-vocabulary.ts`. + It checks the artifact again without installing dependencies or executing producer-supplied code. + Existing snapshots and aliases cannot change. Publication is a fast-forward from the exact + discovered main commit; concurrent changes stop it instead of overwriting source. +5. The existing deployment job checks out the exact checked or published commit. All production + runs share one concurrency group, and stale runs fail the current-main guard. The job tags the + Worker with its commit and verifies that the newest deployment sends 100% of traffic to that + exact tagged version before recording success. It retains the existing trusted npm/Wrangler + toolchain; the artifact cannot replace package files, scripts, or workflow code. + +Generation never learns names from client requests. The publisher alone receives `contents: write`; +the deployment job alone receives `statuses: write` and the existing `CLOUDFLARE_API_TOKEN`. +No new credential or cross-repository write is required. A commit made with `GITHUB_TOKEN` does not +start the normal push workflow, so its checked deployment happens in the same run. + +Merging this workflow into main enables daily generation, data-only publication, and deployment. +Review that operational authority before activation. Coverage can lag until the next daily run plus +GitHub queue delays. GitHub can disable schedules in inactive public repositories. Changed upstream +metadata contracts, moved or deleted tags, incomplete pagination, byte-budget growth, and blocked +fast-forward publication fail visibly and still need maintainer review; automation does not remove +those maintenance boundaries. A failed rollout leaves a retryable status even if its source commit +was already published. Inspect the failed Deploy run, repair the named contract or permission, then +run Deploy manually from current main. Do not weaken the allowlist or force-push past a guard. + +For a reviewed manual repair, use Node.js 24 and a trusted local OpenClaw Git repository containing +the candidate commit and its history: ```bash npm run vocabulary:check -- --source --revision @@ -280,7 +322,7 @@ The initial snapshot includes all catalog revisions on the public main history s `844e781ca40952c98ee997b016e3cc5d2f12f9f3`, before name allowlisting began in August 2026. Refreshes append snapshots; never remove older ones during routine updates. This retains removed or renamed public entries, including names admitted by the older -moving-catalog implementation. New public names remain rejected until reviewed metadata is deployed. +moving-catalog implementation. New public names remain rejected until validated metadata is deployed. The vocabulary is compiled into the Worker. Loading it requires neither upstream requests nor Cache API access, so old allowlist cache entries cannot be reused and cache outages cannot interrupt name validation. Ingestion checks the compiled vocabulary without exposing its mutable set. diff --git a/data/public-vocabulary.json b/data/public-vocabulary.json index 2e45000..6d5d29f 100644 --- a/data/public-vocabulary.json +++ b/data/public-vocabulary.json @@ -600,5 +600,13 @@ "zoom-meetings" ] } + ], + "releases": [ + { + "id": "RE_kwDOQb6kR84X0tXI", + "tag": "v2026.9.7", + "revision": "c074824a27c96d3983043f9eeb33823cd1772d8c", + "publishedAt": "2026-09-30T04:44:14Z" + } ] } diff --git a/scripts/public-vocabulary.mjs b/scripts/public-vocabulary.mjs index 64d16f0..a371773 100644 --- a/scripts/public-vocabulary.mjs +++ b/scripts/public-vocabulary.mjs @@ -4,7 +4,6 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { parseArgs } from "node:util"; -import { readProviderOverlays } from "./lib/public-provider-overlays.mjs"; const ROOT = fileURLToPath(new URL("../", import.meta.url)); const METADATA = join(ROOT, "data/public-vocabulary.json"); @@ -46,6 +45,9 @@ function collectCatalog(body, names) { /** Read immutable Git objects, never the source checkout's working files. */ export async function buildSnapshot(source, revision, catalogHistoryStart) { + // Publication uses the deterministic renderer without loading parser dependencies + // or executing the upstream packaging helper. Only generation needs this import. + const { readProviderOverlays } = await import("./lib/public-provider-overlays.mjs"); if (!SHA.test(revision) || !SHA.test(catalogHistoryStart)) { throw new Error("Use full immutable commit SHAs for revision and history start"); } @@ -144,7 +146,7 @@ export function renderVocabulary(metadata) { export function assertReleaseCoverage(metadata, revision) { if (!SHA.test(revision)) throw new Error("Use a full immutable released commit SHA"); if (!metadata.snapshots.some((snapshot) => snapshot.revision === revision)) { - throw new Error(`Released OpenClaw revision ${revision} has no reviewed vocabulary snapshot; refresh public metadata`); + throw new Error(`Released OpenClaw revision ${revision} has no retained vocabulary snapshot; refresh public metadata`); } } diff --git a/scripts/vocabulary-maintenance.mjs b/scripts/vocabulary-maintenance.mjs new file mode 100644 index 0000000..361999d --- /dev/null +++ b/scripts/vocabulary-maintenance.mjs @@ -0,0 +1,258 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { appendFile, lstat, readFile, readdir, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { parseArgs } from "node:util"; +import { buildSnapshot, renderVocabulary } from "./public-vocabulary.mjs"; + +const ROOT = fileURLToPath(new URL("../", import.meta.url)); +const FILES = ["data/public-vocabulary.json", "src/public-vocabulary.ts"]; +const SHA = /^[a-f0-9]{40}$/u; +const NAME = /^[a-z0-9][a-z0-9._/-]{0,63}$/u; +const CONTEXT = "telemetry/deploy"; +const json = (value) => `${JSON.stringify(value, null, 2)}\n`; +const equal = (actual, expected, message) => assert.deepEqual(actual, expected, message); +const sorted = (values) => [...new Set(values)].sort(); +const QUERY = `query($endCursor: String) { + repository(owner: "openclaw", name: "openclaw") { + releases(first: 100, after: $endCursor, orderBy: {field: CREATED_AT, direction: DESC}) { + nodes { id tagName tagCommit { oid } isDraft isPrerelease publishedAt } + pageInfo { hasNextPage endCursor } + totalCount + } + } +}`; + +function command(program, args) { + return execFileSync(program, args, { + encoding: "utf8", maxBuffer: 8 * 1024 * 1024, timeout: 120_000, + stdio: ["ignore", "pipe", "pipe"], + }); +} + +function gh(...args) { + return JSON.parse(command("gh", ["api", ...args])); +} + +function immutable(value) { + assert.equal(typeof value, "string"); + assert.match(value, SHA, "Expected an immutable commit SHA"); + return value; +} + +function keys(value, expected) { + equal(Object.keys(value).sort(), [...expected].sort(), "Unexpected metadata fields"); +} + +function releaseRecord(release) { + assert.equal(release.isDraft, false, "Tracked release became a draft"); + assert.equal(release.isPrerelease, false, "Tracked release became a prerelease"); + for (const value of [release.id, release.tagName]) { + assert.equal(typeof value, "string"); + assert(value.length > 0 && value.length <= 256 && !/[\p{Cc}]/u.test(value)); + } + assert.equal(new Date(release.publishedAt).toISOString(), release.publishedAt.replace("Z", ".000Z")); + return { id: release.id, tag: release.tagName, + revision: immutable(release.tagCommit?.oid), publishedAt: release.publishedAt }; +} + +/** Exhausted pagination is required; concurrent release changes must retry, not lose a snapshot. */ +export function collectReleases(pages) { + assert(Array.isArray(pages) && pages.length > 0, "Release provenance unavailable"); + const releases = []; + const cursors = new Set(); + const ids = new Set(); + let total; + for (const [index, page] of pages.entries()) { + assert(!page.errors, "GitHub returned incomplete release provenance"); + const connection = page.data?.repository?.releases; + assert(connection && Array.isArray(connection.nodes)); + assert(Number.isSafeInteger(connection.totalCount) && connection.totalCount >= 0); + total ??= connection.totalCount; + equal(connection.totalCount, total, "Release count changed during pagination; retry"); + equal(connection.pageInfo?.hasNextPage, index < pages.length - 1, "Incomplete release pagination"); + if (connection.nodes.length) { + const cursor = connection.pageInfo.endCursor; + assert(typeof cursor === "string" && cursor && !cursors.has(cursor), "Repeated release cursor"); + cursors.add(cursor); + } + for (const release of connection.nodes) { + assert(release?.id && !ids.has(release.id), "Duplicate release identity"); + assert.equal(typeof release.isDraft, "boolean"); + assert.equal(typeof release.isPrerelease, "boolean"); + ids.add(release.id); + releases.push(release); + } + } + equal(releases.length, total, "Release pagination count mismatch"); + return releases; +} + +/** Keep released tag identities and the inclusive backfill anchor in checked-in public metadata. */ +export function planReleases(metadata, releases) { + assert(metadata.releases?.length, "Automatic release backfill needs a reviewed anchor"); + const anchor = metadata.releases[0]; + const byTag = new Map(releases.map((release) => [release.tagName, release])); + equal(byTag.size, releases.length, "Duplicate release tag"); + for (const previous of metadata.releases) { + const release = byTag.get(previous.tag); + assert(release, "A retained release disappeared; review required"); + equal(releaseRecord(release), previous, "A retained release or tag changed; review required"); + } + const current = releases.filter((release) => !release.isDraft && !release.isPrerelease) + .map(releaseRecord).filter((release) => release.publishedAt >= anchor.publishedAt).sort((a, b) => + a.publishedAt.localeCompare(b.publishedAt) || a.tag.localeCompare(b.tag), + ); + assert(current.some((release) => release.id === anchor.id), "Release anchor is missing"); + return current; +} + +export function needsDeployment(event, refresh, statuses) { + // The combined-status API returns the latest status for each context. Missing, + // pending, and failed receipts retry even after the data commit was published. + const receipt = statuses.find((status) => status.context === CONTEXT); + return event !== "schedule" || refresh || receipt?.state !== "success"; +} + +export function assertCurrentMain(target, current) { + equal(immutable(target), immutable(current), "Main advanced; retry from current main instead of rolling back"); +} + +/** Runs from trusted base code in check/publish jobs; candidate TypeScript is never imported. */ +export function validateCandidate(previous, candidate, generated, releases) { + keys(candidate, ["schemaVersion", "legacyAliases", "snapshots", "releases"]); + equal(candidate.schemaVersion, previous.schemaVersion); + equal(candidate.legacyAliases, previous.legacyAliases, "Legacy aliases must not change automatically"); + equal(candidate.releases, releases, "Candidate release provenance differs from discovery"); + const before = new Map(previous.snapshots.map((snapshot) => [snapshot.revision, snapshot])); + const after = new Map(candidate.snapshots.map((snapshot) => [snapshot.revision, snapshot])); + equal(after.size, candidate.snapshots.length, "Duplicate snapshots"); + equal([...after.keys()], sorted([...after.keys()]), "Snapshots must remain canonical"); + for (const [revision, snapshot] of before) { + equal(after.get(revision), snapshot, "Retained immutable snapshot changed"); + } + for (const snapshot of candidate.snapshots) { + if (before.has(snapshot.revision)) continue; + keys(snapshot, ["repository", "revision", "catalogHistoryStart", "catalogRevisions", "bundledPlugins", "names"]); + equal(snapshot.repository, "openclaw/openclaw"); + assert(releases.some((release) => release.revision === snapshot.revision), "Unreleased snapshot"); + equal(snapshot.catalogHistoryStart, previous.snapshots[0].catalogHistoryStart); + for (const revision of snapshot.catalogRevisions) immutable(revision); + equal(snapshot.catalogRevisions, sorted(snapshot.catalogRevisions)); + assert(snapshot.catalogRevisions.includes(snapshot.revision) && + snapshot.catalogRevisions.includes(snapshot.catalogHistoryStart)); + equal(snapshot.bundledPlugins, sorted(snapshot.bundledPlugins)); + assert(snapshot.bundledPlugins.every((name) => snapshot.names.includes(name))); + assert(snapshot.names.every((name) => typeof name === "string" && NAME.test(name))); + } + for (const release of releases) assert(after.has(release.revision), "Released snapshot is missing"); + const output = renderVocabulary(candidate); + equal(generated, output, "Generated source contains changes outside the deterministic vocabulary"); + return output; +} + +export function deployedVersion(deployments) { + assert(Array.isArray(deployments) && deployments.length, "No deployed Worker version"); + const latest = [...deployments].sort((a, b) => a.created_on.localeCompare(b.created_on)).at(-1); + assert(latest.versions?.length === 1 && latest.versions[0].percentage === 100, + "Expected one Worker version receiving 100% of traffic"); + const id = latest.versions[0].version_id; + assert.match(id, /^[a-f0-9-]{36}$/u); + return id; +} + +export function assertDeployedCommit(version, versionId, target) { + equal(version.id, versionId, "Worker version response mismatch"); + equal(version.annotations?.["workers/tag"], immutable(target), "Deployed Worker does not match the target commit"); +} + +async function readBounded(path, limit = 2 * 1024 * 1024) { + const info = await lstat(path); + assert(info.isFile() && info.size <= limit, "Unexpected artifact file or size"); + return readFile(path, "utf8"); +} + +export async function readCandidate(directory, metadata, releases) { + equal((await readdir(directory)).sort(), ["data", "src"], "Unexpected artifact paths"); + for (const name of ["data", "src"]) { + assert((await lstat(join(directory, name))).isDirectory(), "Artifact directory is a symlink"); + equal(await readdir(join(directory, name)), [name === "data" ? "public-vocabulary.json" : "public-vocabulary.ts"]); + } + const bytes = await readBounded(join(directory, FILES[0])); + const candidate = JSON.parse(bytes); + equal(bytes, json(candidate), "Noncanonical metadata"); + const generated = await readBounded(join(directory, FILES[1]), 64 * 1024); + const output = validateCandidate(metadata, candidate, generated, releases); + return { metadata: bytes, source: output }; +} + +async function main() { + const { values, positionals } = parseArgs({ allowPositionals: true, options: { + base: { type: "string" }, event: { type: "string" }, source: { type: "string" }, + artifact: { type: "string" }, target: { type: "string" }, + } }); + const [mode] = positionals; + if (mode === "guard-main") { + assertCurrentMain(values.target, gh("repos/openclaw/telemetry/git/ref/heads/main").object.sha); + return; + } + if (mode === "verify-deployment") { + const target = immutable(values.target); + const versionId = deployedVersion(JSON.parse(command("npx", ["--no-install", "wrangler", "deployments", "list", "--json"]))); + const version = JSON.parse(command("npx", ["--no-install", "wrangler", "versions", "view", versionId, "--json"])); + assertDeployedCommit(version, versionId, target); + console.log(`Verified ${target} on Worker version ${versionId} at 100%.`); + return; + } + const metadata = JSON.parse(await readBounded(join(ROOT, FILES[0]))); + if (mode === "plan") { + const base = immutable(values.base); + assertCurrentMain(base, gh("repos/openclaw/telemetry/git/ref/heads/main").object.sha); + const releases = planReleases(metadata, collectReleases(gh("graphql", "--paginate", "--slurp", "-f", `query=${QUERY}`))); + const refresh = JSON.stringify(releases) !== JSON.stringify(metadata.releases); + const statuses = gh(`repos/openclaw/telemetry/commits/${base}/status`, "--paginate", "--slurp") + .flatMap((page) => page.statuses); + const plan = { base, releases }; + const outputs = { refresh, deploy: needsDeployment(values.event, refresh, statuses), plan: JSON.stringify(plan) }; + if (process.env.GITHUB_OUTPUT) { + await appendFile(process.env.GITHUB_OUTPUT, Object.entries(outputs).map(([key, value]) => `${key}=${value}\n`).join("")); + } + console.log(JSON.stringify(outputs)); + return; + } + const plan = JSON.parse(process.env.VOCABULARY_PLAN ?? "null"); + assert(plan, "Missing release plan from discovery"); + assertCurrentMain(plan.base, command("git", ["-C", ROOT, "rev-parse", "HEAD"]).trim()); + if (mode === "generate") { + assert(values.source, "Missing immutable source repository"); + const candidate = structuredClone(metadata); + for (const release of plan.releases) { + if (candidate.snapshots.some((snapshot) => snapshot.revision === release.revision)) continue; + candidate.snapshots.push(await buildSnapshot(resolve(values.source), release.revision, metadata.snapshots[0].catalogHistoryStart)); + } + candidate.snapshots.sort((a, b) => a.revision.localeCompare(b.revision)); + candidate.releases = plan.releases; + const output = renderVocabulary(candidate); + validateCandidate(metadata, candidate, output, plan.releases); + await writeFile(join(ROOT, FILES[0]), json(candidate)); + await writeFile(join(ROOT, FILES[1]), output); + } else if (mode === "apply") { + assert(values.artifact, "Missing generated artifact"); + const candidate = await readCandidate(resolve(values.artifact), metadata, plan.releases); + await writeFile(join(ROOT, FILES[0]), candidate.metadata); + await writeFile(join(ROOT, FILES[1]), candidate.source); + } else { + throw new Error("Use plan, generate, apply, guard-main, or verify-deployment"); + } +} + +if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { + try { await main(); } catch (error) { + // Child process stderr can include account details; expose the operation, + // never its captured output or credentials in public workflow logs. + console.error(error?.status !== undefined ? "External command failed; provenance or deployment is unverified." : error.message); + console.error("[vocabulary-maintenance] FAILED (exit 1)"); + process.exitCode = 1; + } +} diff --git a/test/public-vocabulary.test.mjs b/test/public-vocabulary.test.mjs index 8b8624d..9003c81 100644 --- a/test/public-vocabulary.test.mjs +++ b/test/public-vocabulary.test.mjs @@ -98,7 +98,7 @@ describe("public vocabulary generation", () => { const metadata = { schemaVersion: 1, legacyAliases: aliases, snapshots: [{ revision: oldRevision, names: ["openai"] }] }; expect(renderVocabulary(metadata)).toContain('"openai"'); - expect(() => assertReleaseCoverage(metadata, newRevision)).toThrow("no reviewed vocabulary snapshot"); + expect(() => assertReleaseCoverage(metadata, newRevision)).toThrow("no retained vocabulary snapshot"); expect(() => assertReleaseCoverage(metadata, "main")).toThrow("immutable released commit SHA"); expect(() => assertReleaseCoverage(metadata, oldRevision)).not.toThrow(); }); diff --git a/test/vocabulary-maintenance.test.mjs b/test/vocabulary-maintenance.test.mjs new file mode 100644 index 0000000..2e0e063 --- /dev/null +++ b/test/vocabulary-maintenance.test.mjs @@ -0,0 +1,204 @@ +import { describe, expect, it } from "vitest"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { renderVocabulary } from "../scripts/public-vocabulary.mjs"; +import { + assertCurrentMain, assertDeployedCommit, collectReleases, deployedVersion, + needsDeployment, planReleases, readCandidate, validateCandidate, +} from "../scripts/vocabulary-maintenance.mjs"; + +const oldRevision = "1".repeat(40); +const nextRevision = "2".repeat(40); +const thirdRevision = "3".repeat(40); +const publishedAt = "2026-09-30T04:44:14Z"; +const release = (tag, revision, extra = {}) => ({ + id: `release-${tag}`, tagName: tag, tagCommit: { oid: revision }, + isDraft: false, isPrerelease: false, publishedAt, ...extra, +}); +const anchor = release("v1", oldRevision); +const record = (value) => ({ id: value.id, tag: value.tagName, + revision: value.tagCommit.oid, publishedAt: value.publishedAt }); +const page = (nodes, totalCount = nodes.length, hasNextPage = false, endCursor = "first") => ({ + data: { repository: { releases: { nodes, totalCount, pageInfo: { hasNextPage, endCursor } } } }, +}); +const snapshot = (revision, names) => ({ + repository: "openclaw/openclaw", revision, catalogHistoryStart: oldRevision, + catalogRevisions: [...new Set([oldRevision, revision])].sort(), bundledPlugins: names, names, +}); +const metadata = () => ({ schemaVersion: 1, + legacyAliases: { repository: "openclaw/telemetry", revision: thirdRevision, names: ["cli"] }, + snapshots: [snapshot(oldRevision, ["old-public-plugin"])], releases: [record(anchor)], +}); + +describe("release discovery", () => { + it("exhausts all pages before admitting releases", () => { + const second = release("v2", nextRevision); + expect(collectReleases([page([second], 2, true), page([anchor], 2, false, "last")])) + .toEqual([second, anchor]); + }); + + it.each([ + [], [page([anchor], 2, true)], [page([anchor], 2)], + [page([anchor], 2, true), page([release("v2", nextRevision)], 3, false, "last")], + [page([anchor], 2, true), page([anchor], 2, false, "last")], + [page([anchor], 2, true), page([release("v2", nextRevision)], 2, false, "first")], + [{ errors: [{ message: "partial result" }], ...page([anchor]) }], + ])("rejects unavailable, incomplete, or changing pagination", (pages) => { + expect(() => collectReleases(pages)).toThrow(); + }); + + it("backfills missed stable releases, including shared publication timestamps", () => { + const second = release("v2", nextRevision); + const third = release("v3", thirdRevision, { publishedAt: "2026-10-01T01:00:00Z" }); + const earlier = release("v0", "4".repeat(40), { publishedAt: "2026-09-29T00:00:00Z" }); + expect(planReleases(metadata(), [third, earlier, second, anchor, + release("v4-beta", "5".repeat(40), { isPrerelease: true }), + release("draft", "6".repeat(40), { isDraft: true }), + ])).toEqual([anchor, second, third].map(record)); + }); + + it.each([ + [], [release("v1", nextRevision)], [release("v1", oldRevision, { id: "replacement" })], + [release("v1", oldRevision, { isPrerelease: true })], + [release("v1", oldRevision, { tagCommit: null })], + [release("v1", oldRevision, { publishedAt: "2026-10-01T00:00:00Z" })], + [anchor, anchor], + ])("requires unchanged retained release and tag identities", (releases) => { + expect(() => planReleases(metadata(), releases)).toThrow(); + }); +}); + +describe("constrained publication", () => { + const update = () => { + const next = metadata(); + next.snapshots.push(snapshot(nextRevision, ["new-public-plugin"])); + next.releases.push(record(release("v2", nextRevision))); + return next; + }; + + it("admits only complete released snapshots while retaining disappeared names", () => { + const next = update(); + const generated = validateCandidate(metadata(), next, renderVocabulary(next), next.releases); + expect(generated).toContain('"old-public-plugin"'); + expect(generated).toContain('"new-public-plugin"'); + }); + + it.each([ + (next) => { next.snapshots.shift(); }, + (next) => { next.snapshots[0].names = ["changed-history"]; }, + (next) => { next.legacyAliases.names = ["private-alias"]; }, + (next) => { next.snapshots[1].revision = thirdRevision; }, + (next) => { next.snapshots[1].repository = "untrusted/source"; }, + (next) => { next.snapshots[1].names = ["not a complete id"]; }, + (next) => { next.snapshots[1].catalogHistoryStart = thirdRevision; }, + (next) => { next.snapshots[1].catalogRevisions = [nextRevision]; }, + (next) => { next.snapshots.push(next.snapshots[1]); }, + (next) => { next.extra = "unexpected"; }, + ])("rejects altered history, unproven releases, and malformed data", (mutate) => { + const next = update(); + const releases = structuredClone(next.releases); + mutate(next); + expect(() => validateCandidate(metadata(), next, renderVocabulary(next), releases)).toThrow(); + }); + + it("never accepts executable producer changes or incomplete release coverage", () => { + const next = update(); + expect(() => validateCandidate(metadata(), next, + `${renderVocabulary(next)}\nprocess.exit(0);`, next.releases)).toThrow("deterministic vocabulary"); + expect(() => validateCandidate(metadata(), metadata(), renderVocabulary(metadata()), next.releases)) + .toThrow("release provenance"); + next.snapshots.pop(); + expect(() => validateCandidate(metadata(), next, renderVocabulary(next), next.releases)) + .toThrow("snapshot is missing"); + }); + + it("rejects an older source commit after main advances", () => { + expect(() => assertCurrentMain(oldRevision, nextRevision)).toThrow("Main advanced"); + expect(() => assertCurrentMain(oldRevision, oldRevision)).not.toThrow(); + }); + + it("rejects a native push when main advances after the publication guard", async () => { + const directory = await mkdtemp(join(tmpdir(), "vocabulary-publish-race-")); + const remote = join(directory, "remote.git"); + const checkout = join(directory, "publisher"); + const git = (...args) => execFileSync("git", ["-C", checkout, ...args], { + encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], + }); + const commit = async (value) => { + await writeFile(join(checkout, "data.json"), value); + git("add", "data.json"); + git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", + "-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", "commit", "-qm", "fixture"); + return git("rev-parse", "HEAD").trim(); + }; + try { + await mkdir(checkout); + execFileSync("git", ["init", "--bare", "--quiet", remote]); + git("init", "--quiet"); + git("remote", "add", "origin", remote); + const base = await commit("base"); + git("push", "origin", "HEAD:refs/heads/main"); + assertCurrentMain(base, git("ls-remote", "origin", "refs/heads/main").split("\t")[0]); + const concurrent = await commit("concurrent maintainer change"); + git("push", "origin", "HEAD:refs/heads/main"); + git("checkout", "--detach", base); + await commit("generated vocabulary"); + const push = spawnSync("git", ["-C", checkout, "push", "origin", "HEAD:refs/heads/main"], { encoding: "utf8" }); + expect(push.status).not.toBe(0); + expect(git("ls-remote", "origin", "refs/heads/main").split("\t")[0]).toBe(concurrent); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("reads only bounded regular artifact files and never executes generated source", async () => { + const directory = await mkdtemp(join(tmpdir(), "vocabulary-artifact-")); + const next = update(); + try { + await mkdir(join(directory, "data")); + await mkdir(join(directory, "src")); + await writeFile(join(directory, "data/public-vocabulary.json"), `${JSON.stringify(next, null, 2)}\n`); + const source = join(directory, "src/public-vocabulary.ts"); + await writeFile(source, renderVocabulary(next)); + expect((await readCandidate(directory, metadata(), next.releases)).source).toBe(renderVocabulary(next)); + await writeFile(source, `${renderVocabulary(next)}\nthrow new Error('must not execute');`); + await expect(readCandidate(directory, metadata(), next.releases)).rejects.toThrow("deterministic vocabulary"); + await rm(source); + await symlink(join(directory, "data/public-vocabulary.json"), source); + await expect(readCandidate(directory, metadata(), next.releases)).rejects.toThrow("artifact file"); + await writeFile(join(directory, "package.json"), "{}"); + await expect(readCandidate(directory, metadata(), next.releases)).rejects.toThrow("artifact paths"); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}); + +describe("deployment recovery", () => { + it.each(["failure", "error", "pending", undefined])("retries a published commit without successful rollout: %s", (state) => { + const statuses = state ? [{ context: "telemetry/deploy", state }] : []; + expect(needsDeployment("schedule", false, statuses)).toBe(true); + }); + + it("skips the expensive path only for an unchanged, successfully deployed poll", () => { + const success = [{ context: "telemetry/deploy", state: "success" }]; + expect(needsDeployment("schedule", false, success)).toBe(false); + expect(needsDeployment("schedule", true, success)).toBe(true); + expect(needsDeployment("workflow_dispatch", false, success)).toBe(true); + expect(needsDeployment("push", false, success)).toBe(true); + expect(needsDeployment("schedule", false, [{ context: "check", state: "success" }])).toBe(true); + }); + + it("requires the newest deployment to route 100% to the exact tagged commit", () => { + const id = "11111111-2222-3333-4444-555555555555"; + const current = { created_on: "2026-10-01T00:00:00Z", versions: [{ version_id: id, percentage: 100 }] }; + expect(deployedVersion([current, { created_on: "2026-09-30T00:00:00Z", versions: [] }])).toBe(id); + expect(() => deployedVersion([{ ...current, versions: [{ version_id: id, percentage: 50 }] }])).toThrow("100%"); + expect(() => assertDeployedCommit({ id, annotations: { "workers/tag": oldRevision } }, id, nextRevision)) + .toThrow("target commit"); + expect(() => assertDeployedCommit({ id, annotations: { "workers/tag": nextRevision } }, id, nextRevision)) + .not.toThrow(); + }); +});