diff --git a/.agents/shortcuts/cut-minor-ga.md b/.agents/shortcuts/cut-minor-ga.md index ac639a8b1d1..81c2aab1392 100644 --- a/.agents/shortcuts/cut-minor-ga.md +++ b/.agents/shortcuts/cut-minor-ga.md @@ -197,31 +197,27 @@ marker to get unblocked. Without it, `npm run version` refuses to generate 16. **[H]** Redeploy the worker (`npm run deploy:cdn-artifacts-worker`) if `main` changed it after step 9. -17. **[H]** Refresh the root `/schemas/index.json` and `/schemas/latest.json`. - No script path refreshes them today: - - release uploads use `--skip-latest`; - - deploys use `--latest-only`, which excludes the root indexes; - - `backfill-cdn-artifacts.sh` refuses `--version` without `--skip-latest`. - - Pick one of two ways: - - land a reviewed tooling PR that adds a root-index-only mode before GA - day; or - - get explicit maintainer authorization for a tracked one-off under the - publication-authority rules in `RELEASING.md`. It uploads exactly these - two mutable files from the `v3.2.1` tag, with the headers the script - uses. - - Never touch semver paths this way. The one-off, run with R2 credentials: +17. **[A]** The root `/schemas/index.json` and `/schemas/latest.json` refresh + themselves on the next successful `main` deploy after the release + artifacts land. `backfill-cdn-artifacts.sh --latest-only` rebuilds them + through `scripts/build-schema-discovery-pointers.cjs` from the releases + the bucket actually serves, and `deploy.yml` fails if the public documents + are not the ones it just uploaded (#8318). No one-off upload here. + + Confirm. If an alias still trails, read the deploy log for the + `Schema versions missing from the CDN` warning — that names a release + whose `schemas//index.json` never reached R2, which is a release + recovery, not a pointer problem. To inspect without deploying: ```bash - git checkout v3.2.1 - for file in index.json latest.json; do - aws s3 cp "dist/schemas/$file" "s3://$ADCP_ARTIFACT_R2_BUCKET/schemas/$file" \ - --endpoint-url "$R2_ENDPOINT" --no-guess-mime-type \ - --cache-control 'public, no-cache, must-revalidate' \ - --content-type 'application/json; charset=utf-8' - done - curl -s https://adcontextprotocol.org/schemas/index.json | jq .latest_stable # "3.2.1" + npm run backfill:cdn-artifacts -- --bucket "$ADCP_ARTIFACT_R2_BUCKET" \ + --build-latest --latest-only --aws-dry-run + ``` + + ```bash + curl -s https://adcontextprotocol.org/schemas/index.json \ + | jq '{latest_stable, v3: .aliases.v3, "v3.2": .aliases["v3.2"]}' + # {"latest_stable": "3.2.1", "v3": "3.2.1", "v3.2": "3.2.1"} ``` ## Phase 6: docs snapshot diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3e2978ff6f7..66c926656eb 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -517,7 +517,19 @@ jobs: echo "::error::Missing R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, or R2_SECRET_ACCESS_KEY secret." exit 1 fi - npm run backfill:cdn-artifacts -- --bucket "${ADCP_ARTIFACT_R2_BUCKET}" --build-latest --latest-only --quiet + mkdir -p "${RUNNER_TEMP}/schema-pointers" + npm run backfill:cdn-artifacts -- --bucket "${ADCP_ARTIFACT_R2_BUCKET}" --build-latest --latest-only --quiet \ + --pointer-output-dir "${RUNNER_TEMP}/schema-pointers" + + # Uploading is not serving: R2 publication is progressive and per-object. + # #8318 went unnoticed for weeks because nothing compared the public + # discovery document against the release set it is supposed to name. + - name: Verify published schema discovery pointers + run: | + set -euo pipefail + node scripts/verify-schema-discovery.mjs \ + --expected-index "${RUNNER_TEMP}/schema-pointers/index.json" \ + --expected-latest "${RUNNER_TEMP}/schema-pointers/latest.json" # On any failure (hard or fallback-with-degraded-gates), snapshot Fly # state so the next-turn investigator doesn't have to fetch it diff --git a/RELEASING.md b/RELEASING.md index 57a978b6e26..c550744c64e 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -278,12 +278,64 @@ requalify each dependent PR on that main, including combined tests and human rev historical backfill or incident recovery. Routine app deployment is authorized to rebuild and upload only -`schemas/latest/**`, `compliance/latest/**`, and `protocol/latest.tgz*`. -It uses `backfill-cdn-artifacts.sh --latest-only`; root version indexes and -all semver paths are excluded. The app itself serves committed `dist`, so a -committed package version without a published GitHub Release and its four -assets also blocks app deployment. This can delay app-only fixes while a -release needs recovery. +`schemas/latest/**`, `compliance/latest/**`, `protocol/latest.tgz*`, and the +root discovery pointers `schemas/index.json` and `schemas/latest.json`. It +uses `backfill-cdn-artifacts.sh --latest-only`; all semver paths are excluded. +The app itself serves committed `dist`, so a committed package version without +a published GitHub Release and its four assets also blocks app deployment. +This can delay app-only fixes while a release needs recovery. + +The root pointers name versions, so routine deployment must not be able to +advertise one that release authority has not published. +`scripts/build-schema-discovery-pointers.cjs` enforces that mechanically: + +- It starts from the regenerated `dist/schemas/index.json` and refuses a + source document whose aliases disagree with its own version list, whose + `$id`/`$schema`/`channel` are not the expected literals, or that carries a + malformed entry. +- `versions[]` keeps only the releases with a `schemas//` prefix in + one delimited bucket listing. A truncated listing is refused. +- An alias (`latest_stable`, `v3`, `v3.2`, …) may only resolve to a release + whose `schemas//index.json` object was confirmed with a HEAD. A + prefix appears as soon as release publication writes its first object, and + that publication is progressive and per-object, so prefix existence alone + would let a half-uploaded release win an alias under an immutable cache + policy. The deploy walks each line newest-first and stops at the first + confirmation — typically one HEAD per release line. +- Every URL in the published document is derived from a validated version + string; nothing but two prose fields is copied from the source. +- It refuses to move any pointer backwards relative to the copy currently + served. The one exemption is a release the source marks `withdrawn` or + `unpublished`, which is a deliberate editorial decision. Any other + regression means the CDN no longer serves a release it advertises: recover + that release, do not override the guard. + +A release whose version directory never reached the CDN therefore keeps its +line pinned to the newest patch that is actually fetchable, and the deploy log +carries a `Schema versions missing from the CDN` warning naming the gap. + +`scripts/verify-schema-discovery.mjs` then fetches the canonical public URLs +and fails the deploy unless they serve byte-for-byte what this run uploaded — +uploading is not serving. Note the scope: it proves the public document is +this run's, not that the document names the newest release of every line. A +line pinned one release back by a CDN gap passes, and the warning above is the +signal for that. + +`npm run backfill:cdn-artifacts -- --bucket "$ADCP_ARTIFACT_R2_BUCKET" +--build-latest --latest-only --aws-dry-run` computes and prints both documents +against the live bucket without uploading anything, which is how to preview +pointer state during an incident. + +Pointer currency follows the deploy, not the release: `release.yml` uploads +`schemas//` with `--version V --skip-latest` and still never touches +the pointers, so a new release is advertised by the next successful `main` +deploy that sees its prefix. Releases cut on a maintenance branch wait for the +forward merge into `main`. + +Before [#8318](https://github.com/adcontextprotocol/adcp/issues/8318) no +workflow published these two objects at all: `--latest-only` excluded them and +`--version V --skip-latest` still does, so they froze at whatever the last +manual bulk backfill wrote while four releases shipped past them. Only the release workflow publishes a new immutable version. It verifies the current tested branch, the original merged release PR's final-head maintainer diff --git a/package.json b/package.json index f8a8634485a..7acf1859061 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "deploy:cdn-artifacts-cutover": "wrangler deploy --config workers/artifact-cdn/wrangler.cutover.toml", "deploy:cdn-artifacts-cutover:dry-run": "wrangler deploy --config workers/artifact-cdn/wrangler.cutover.toml --dry-run", "verify:cdn-artifacts-cutover": "node scripts/verify-cdn-artifacts-cutover.mjs", + "verify:schema-discovery": "node scripts/verify-schema-discovery.mjs", "typecheck": "tsc --project server/tsconfig.json --noEmit && npm run typecheck:fixed-trace-rollout-tests", "typecheck:fixed-trace-rollout-tests": "node scripts/typecheck-fixed-trace-rollout-tests.mjs", "test:schemas": "node tests/schema-validation.test.cjs && node --test tests/sync-platform-id.test.cjs tests/assignment-landing-page.test.cjs tests/assignment-trackers.test.cjs tests/outcome-target.test.cjs tests/trusted-match-offer-creative-data.test.cjs tests/accessibility-violation-details.test.cjs tests/portfolio-routing-scope.test.cjs tests/catalog-item-availability-updates.test.cjs tests/catalog-ingestion-capabilities.test.cjs tests/sdk-implementation-metadata.test.cjs tests/seller-optimized-package-controls.test.cjs tests/compact-product-lifecycle-storyboards.test.cjs tests/timezone-resolution-storyboards.test.cjs tests/total-budget-redistribution-currency.test.cjs tests/dooh-allocation.test.cjs tests/identity-absence-coherence.test.cjs tests/execution-requirements.test.cjs tests/schema-deprecation-metadata.test.cjs tests/products-only-brief-compatibility.test.cjs tests/async-identity-convergence.test.cjs tests/creative-rotation.test.cjs tests/canonical-forecast-point-parity.test.cjs tests/creative-revisions.test.cjs tests/creative-delivery-contracts.test.cjs tests/tracker-execution-contracts.test.cjs tests/tracker-execution-package-integration.test.cjs tests/metric-identity-coherence.test.cjs tests/print-metrics.test.cjs tests/daypart-time-of-day.test.cjs tests/audience-size-range.test.cjs tests/viewable-rate-optimization-goal.test.cjs tests/sort-contract-delivery-reporting.test.cjs tests/time-based-views-contract.test.cjs tests/metric-qualifier-parity.test.cjs tests/requested-metrics-contract.test.cjs tests/auto-breakdown-negotiation-contract.test.cjs tests/format-delivery-reporting-contract.test.cjs tests/inventory-delivery-reporting-contract.test.cjs tests/property-delivery-conformance.test.cjs tests/lint-schema-enum-drift.test.cjs tests/format-asset-type-parity.test.cjs tests/canonical-manifest-constructibility.test.cjs tests/synthetic-depiction.test.cjs tests/creative-rendering-authority.test.cjs tests/buyer-reason.test.cjs tests/error-recovery-vectors.test.cjs tests/reporting-status-contract.test.cjs tests/reporting-reconciliation-fixture.test.cjs tests/reporting-core-fixture.test.cjs tests/reporting-native-version-ref-bounds.test.cjs tests/definition-pin-vectors.test.cjs tests/trust-json-v1.test.cjs tests/refine-proposals-expected-confirmation-at.test.cjs tests/hold-policy.test.cjs tests/hold-policy-vectors.test.cjs tests/collection-catalog.test.cjs tests/collection-copies-vectors.test.cjs && npm run test:premium-display-formats && npm run test:geo-region-targeting", @@ -129,7 +130,7 @@ "test:build-schemas-hoist-by-identity": "node --test --test-force-exit --test-timeout=30000 tests/build-schemas-hoist-by-identity.test.cjs", "test:build-schemas-async-response-refs": "node --test --test-force-exit --test-timeout=30000 tests/build-schemas-async-response-refs.test.cjs", "test:build-schemas-vendor-metric-uniqueness": "node --test --test-force-exit --test-timeout=30000 tests/build-schemas-vendor-metric-uniqueness.test.cjs", - "test:release-workflow": "node tests/release-workflow-immutability.test.cjs && node --test tests/release-publication-ordering.test.cjs tests/maintenance-release-publication.test.cjs tests/changesets-fence.test.cjs tests/release-supersession.test.cjs tests/skip-withdrawn-release.test.cjs && node tests/promote-release-candidate.test.cjs && node tests/published-compliance-versions.test.cjs && node --test --test-timeout=30000 tests/cdn-artifacts.test.cjs tests/artifact-cdn-worker.test.cjs", + "test:release-workflow": "node tests/release-workflow-immutability.test.cjs && node --test tests/release-publication-ordering.test.cjs tests/maintenance-release-publication.test.cjs tests/changesets-fence.test.cjs tests/release-supersession.test.cjs tests/skip-withdrawn-release.test.cjs && node tests/promote-release-candidate.test.cjs && node tests/published-compliance-versions.test.cjs && node --test --test-timeout=30000 tests/cdn-artifacts.test.cjs tests/artifact-cdn-worker.test.cjs tests/schema-discovery-pointers.test.cjs", "promote:rc": "node scripts/promote-release-candidate.mjs", "test:immutable-release-artifacts": "node tests/immutable-release-artifacts.test.cjs", "test:runtime-artifacts": "node --test --test-force-exit --test-timeout=30000 tests/runtime-artifact-pruning.test.cjs", diff --git a/scripts/backfill-cdn-artifacts.sh b/scripts/backfill-cdn-artifacts.sh index 45f5be2182b..926b0efd180 100755 --- a/scripts/backfill-cdn-artifacts.sh +++ b/scripts/backfill-cdn-artifacts.sh @@ -17,14 +17,23 @@ Options: when present. --dry-run Print the planned AWS sync/cp operations. --aws-dry-run Execute AWS with --dryrun. Very verbose: AWS prints - every object it would upload. + every object it would upload. Also computes and prints + the root discovery pointers from the live bucket without + uploading them. --build-latest Rebuild dist/*/latest and dist/protocol/latest.tgz first. - --latest-only Upload only mutable development artifacts (routine deploy). + --latest-only Upload only mutable artifacts: dist/*/latest plus the + root discovery pointers, rebuilt from the versions the + bucket already serves (routine deploy). + --pointer-output-dir DIR + Keep the published schemas/index.json and + schemas/latest.json in DIR so a post-publication check + can verify the exact uploaded bytes. --version VERSION Publish only this already-approved, tagged GitHub release. Existing objects must match; missing objects are created conditionally. Requires --skip-latest. --skip-latest Upload only versioned artifacts; do not update mutable - schemas/latest, compliance/latest, or protocol/latest. + schemas/latest, compliance/latest, protocol/latest, or + the root discovery pointers. --apply-cors Apply public read CORS config to the bucket via Wrangler. --quiet Pass --only-show-errors to aws s3 operations. -h, --help Show this help. @@ -51,6 +60,7 @@ build_latest=0 skip_latest=0 latest_only=0 release_version="" +pointer_output_dir="" apply_cors=0 quiet=0 @@ -99,6 +109,10 @@ while [[ $# -gt 0 ]]; do latest_only=1 shift ;; + --pointer-output-dir) + pointer_output_dir="$(require_option_value "$1" "${2:-}")" + shift 2 + ;; --version) release_version="$(require_option_value "$1" "${2:-}")" shift 2 @@ -136,6 +150,10 @@ if [[ "$latest_only" -eq 1 && ( "$skip_latest" -eq 1 || -n "$release_version" ) echo "--latest-only cannot be combined with versioned publication." >&2 exit 2 fi +if [[ -n "$pointer_output_dir" && "$latest_only" -ne 1 ]]; then + echo "--pointer-output-dir only applies to --latest-only publication." >&2 + exit 2 +fi if [[ -n "$release_version" && ( ! "$release_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ || "$skip_latest" -ne 1 || "$build_latest" -eq 1 || "$apply_cors" -eq 1 ) ]]; then echo "--version requires exact semver, --skip-latest, committed artifacts, and no bucket policy changes." >&2 exit 2 @@ -214,7 +232,9 @@ Run with --build-latest if this checkout has not already run the artifact build. WARN fi -fence_script="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/check-release-state.cjs" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +fence_script="$script_dir/check-release-state.cjs" +pointer_builder="$script_dir/build-schema-discovery-pointers.cjs" if [[ "$dry_run" -eq 0 ]]; then node "$fence_script" current if [[ -n "$release_version" ]]; then @@ -483,6 +503,108 @@ cp_compliance_tree() { cp_filtered_recursive "$root" "$dest_prefix" "$cache_control" "text/plain; charset=utf-8" "*.txt" } +# Root discovery pointers (schemas/index.json, schemas/latest.json). +# +# These name versions, so routine deployment may only advertise versions that +# release authority already published. The builder enforces that: `versions[]` +# comes from the bucket's `schemas/` prefixes, but an alias may only resolve to +# a release whose `schemas//index.json` object is confirmed below. A +# prefix appears as soon as release publication writes its first object, so +# prefix existence alone would let a half-uploaded release win an alias. +# +# See RELEASING.md "Publication authority and ordering" and #8318. +pointer_scratch="" +cleanup_pointer_scratch() { + if [[ -n "$pointer_scratch" ]]; then rm -rf -- "$pointer_scratch"; fi + cleanup_immutable_publication +} +pointer_head_object() { + # 0 = present, 1 = confirmed absent. Any other outcome aborts: "cannot tell" + # must never read as "absent". + local key="$1" error="$2" + if aws s3api head-object --bucket "$bucket" --key "$key" \ + --endpoint-url "$endpoint" --region "$AWS_DEFAULT_REGION" --no-cli-pager >/dev/null 2>"$error"; then + return 0 + fi + if grep -Eq 'An error occurred \(404\)|\(NoSuchKey\)' "$error"; then + return 1 + fi + cat "$error" >&2 + echo "Could not determine whether s3://${bucket}/${key} exists; refusing to republish pointers." >&2 + exit 1 +} +publish_root_pointers() { + if [[ ! -f dist/schemas/index.json ]]; then + echo "Missing dist/schemas/index.json; run with --build-latest." >&2 + exit 1 + fi + local staging listing existing head_error confirmed candidates minor version claimed + + # Scratch inputs stay out of --pointer-output-dir, whose contract is the two + # published documents and nothing else. + pointer_scratch="$(mktemp -d "${TMPDIR:-/tmp}/adcp-schema-pointers.XXXXXX")" + trap cleanup_pointer_scratch EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + staging="${pointer_output_dir:-$pointer_scratch}" + mkdir -p "$staging" + listing="$pointer_scratch/listing.json" + existing="$pointer_scratch/published-index.json" + head_error="$pointer_scratch/head-error" + confirmed="$pointer_scratch/confirmed" + candidates="$pointer_scratch/candidates" + + # Plain --dry-run stays offline. --aws-dry-run runs the whole read-only + # computation against the real bucket and prints the documents it would + # publish, which is how to preview the pointer state before a deploy. + if [[ "$dry_run" -eq 1 && "$aws_dry_run" -eq 0 ]]; then + echo "Would rebuild and upload schemas/index.json and schemas/latest.json from the published version set" + return 0 + fi + if [[ "$dry_run" -eq 0 ]]; then node "$fence_script" current; fi + # One delimited listing names every published schemas// prefix. + aws s3api list-objects-v2 --bucket "$bucket" --prefix 'schemas/' --delimiter '/' \ + --endpoint-url "$endpoint" --region "$AWS_DEFAULT_REGION" --no-cli-pager --output json > "$listing" + + # Walk each line newest-first and take the first release whose manifest + # object actually exists. Typically one HEAD per release line. + node "$pointer_builder" candidates --source dist/schemas/index.json --listing "$listing" > "$candidates" + : > "$confirmed" + claimed="" + while read -r minor version; do + [[ -n "$minor" && -n "$version" ]] || continue + case " $claimed " in *" $minor "*) continue ;; esac + if pointer_head_object "schemas/${version}/index.json" "$head_error"; then + claimed="$claimed $minor" + printf '%s\n' "$version" >> "$confirmed" + fi + done < "$candidates" + + rm -f "$existing" + if pointer_head_object schemas/index.json "$head_error"; then + aws s3 cp "s3://${bucket}/schemas/index.json" "$existing" \ + --endpoint-url "$endpoint" --region "$AWS_DEFAULT_REGION" \ + --no-cli-pager --no-progress --only-show-errors + fi + + node "$pointer_builder" build \ + --source dist/schemas/index.json \ + --listing "$listing" \ + --confirmed "$confirmed" \ + --published-index "$existing" \ + --out-index "$staging/index.json" \ + --out-latest "$staging/latest.json" + + if [[ "$dry_run" -eq 1 ]]; then + echo "Would upload schemas/index.json and schemas/latest.json:" + cat "$staging/index.json" "$staging/latest.json" + return 0 + fi + + cp_file "$staging/index.json" schemas/index.json "$revalidate_cache" "application/json; charset=utf-8" + cp_file "$staging/latest.json" schemas/latest.json "$revalidate_cache" "application/json; charset=utf-8" +} + if [[ "$apply_cors" -eq 1 ]]; then if [[ "$dry_run" -eq 1 ]]; then echo "Would apply public GET/HEAD CORS config to R2 bucket $bucket" @@ -512,12 +634,9 @@ done fi fi if [[ "$skip_latest" -eq 0 ]]; then - if [[ "$latest_only" -eq 0 && -f dist/schemas/index.json ]]; then - cp_file dist/schemas/index.json schemas/index.json "$revalidate_cache" "application/json; charset=utf-8" - fi - if [[ "$latest_only" -eq 0 && -f dist/schemas/latest.json ]]; then - cp_file dist/schemas/latest.json schemas/latest.json "$revalidate_cache" "application/json; charset=utf-8" - fi + # Never upload dist/schemas/index.json directly: the published pointers must + # always go through the authority filter in publish_root_pointers. + publish_root_pointers if [[ -d dist/schemas/latest ]]; then cp_schema_tree dist/schemas/latest schemas/latest "$revalidate_cache" fi diff --git a/scripts/build-schema-discovery-pointers.cjs b/scripts/build-schema-discovery-pointers.cjs new file mode 100644 index 00000000000..4937cd576ab --- /dev/null +++ b/scripts/build-schema-discovery-pointers.cjs @@ -0,0 +1,481 @@ +#!/usr/bin/env node +// Root schema discovery pointers (/schemas/index.json, /schemas/latest.json) +// for publication to the artifact CDN. +// +// Why this exists: `scripts/build-schemas.cjs` regenerates both documents from +// the release directories in `dist/schemas/`, but the publication pipeline had +// no step that uploaded them. `release.yml` uploads one version with +// `--version V --skip-latest`; routine deploys upload mutable development +// artifacts with `--latest-only`. Both excluded the root pointers, so the +// published copies froze at whatever a manual bulk backfill last wrote while +// new releases kept landing (adcontextprotocol/adcp#8318). +// +// The pointers now ride along with the routine deploy, under the publication +// authority rule in RELEASING.md: routine deployment must never advertise a +// version that release authority has not already published. This builder +// enforces that mechanically, in two tiers: +// +// * `versions[]` lists the releases whose `schemas//` prefix exists +// on the CDN, from one delimited listing. +// * an alias (`latest_stable`, `v3`, `v3.2`, …) may only resolve to a +// release whose `schemas//index.json` object the caller has +// confirmed with a HEAD. A prefix appears as soon as release publication +// writes its first object, and that publication is progressive and +// per-object, so prefix existence alone would let a half-uploaded release +// win an alias under an immutable cache policy. +// +// It also refuses a source document that disagrees with its own version list, +// and refuses to move any pointer backwards relative to the copy currently +// served — except onto a release the source marks withdrawn or unpublished, +// which is the one legitimate backwards move. +// +// Every URL in the output is derived from a validated version string. Nothing +// is copied out of the source document except two prose fields. +// +// Deriving from the bucket's `schemas/` prefixes is what the CDN worker's +// dynamic `/schemas/` discovery already does +// (workers/artifact-cdn/src/index.js getVersions/findMatchingVersion), so the +// static document and the dynamic one resolve each alias the same way. + +const fs = require('node:fs'); +const path = require('node:path'); + +const SCHEMA_ORIGIN = 'https://adcontextprotocol.org'; +const EXPECTED_ID = `${SCHEMA_ORIGIN}/schemas/index.json`; +const EXPECTED_JSON_SCHEMA = 'http://json-schema.org/draft-07/schema#'; +const EXPECTED_CHANNEL = 'stable'; +const STABLE_STABILITY = 'stable'; +const KNOWN_STABILITIES = new Set(['stable', 'rc', 'beta', 'prerelease', 'withdrawn', 'unpublished']); +// A release the source has retired. scripts/build-schemas.cjs marks these +// through RELEASE_STATUS_OVERRIDES; they may legitimately lose an alias. +const RETIRED_STABILITIES = new Set(['withdrawn', 'unpublished']); +// Entry fields carried through, in the order scripts/build-schemas.cjs emits +// them. Anything else in a source entry is dropped. +const ENTRY_FLAGS = ['prerelease', 'deprecated', 'withdrawn', 'published']; +const POINTER_MAPS = ['aliases', 'latest_by_major', 'latest_by_minor']; + +// Node builtins only, like scripts/check-release-state.cjs: this runs inside +// the publication path, so it must not depend on an installed tree. Only the +// `major.minor.patch[-prerelease]` shape the release lines actually use is +// recognised, and only stable versions are ever ordered — aliases and the +// monotonic guard never compare prerelease precedence. +const VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/; + +function parseVersion(version) { + if (typeof version !== 'string') return null; + const match = VERSION_PATTERN.exec(version); + if (!match) return null; + return { + major: match[1], + minor: `${match[1]}.${match[2]}`, + order: [Number(match[1]), Number(match[2]), Number(match[3])], + prerelease: match[4] || null, + }; +} + +function isValidVersion(version) { + return parseVersion(version) !== null; +} + +function isStableVersion(version) { + const parsed = parseVersion(version); + return parsed !== null && parsed.prerelease === null; +} + +// Descending order over stable versions only. +function compareStableDescending(a, b) { + const left = parseVersion(a).order; + const right = parseVersion(b).order; + return (right[0] - left[0]) || (right[1] - left[1]) || (right[2] - left[2]); +} + +function sortedStable(versions) { + return [...new Set(versions)].filter(isStableVersion).sort(compareStableDescending); +} + +// Mirrors buildRootSchemaDiscovery() in scripts/build-schemas.cjs: newest +// first, first one wins each alias. +function derivePointers(stableVersions) { + const stable = sortedStable(stableVersions); + const aliases = {}; + const latestByMajor = {}; + const latestByMinor = {}; + for (const version of stable) { + const { major, minor } = parseVersion(version); + if (!latestByMajor[major]) { + latestByMajor[major] = version; + aliases[`v${major}`] = version; + } + if (!latestByMinor[minor]) { + latestByMinor[minor] = version; + aliases[`v${minor}`] = version; + } + } + return { latestStable: stable[0] || null, aliases, latestByMajor, latestByMinor }; +} + +function stableVersionsOf(entries) { + return entries + .filter((entry) => entry.stability === STABLE_STABILITY) + .map((entry) => entry.version) + .filter(isStableVersion); +} + +// Key order carries no meaning in these maps, so compare canonically rather +// than failing publication over a reordered alias table. +function canonical(value) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return JSON.stringify(value ?? null); + return JSON.stringify(Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)))); +} + +function assertEqualPointer(label, actual, expected) { + const left = canonical(actual); + const right = canonical(expected); + if (left !== right) { + throw new Error( + `Stale schema discovery source: ${label} is ${left} but the document's own version list derives ${right}. ` + + 'Run `npm run build:schemas` so dist/schemas/index.json matches dist/schemas/*.', + ); + } +} + +// A source document that disagrees with its own `versions` array was written +// before the release directories it now sits beside. Publishing it would +// re-freeze the pointers, which is the bug this script exists to close. +function assertSourceIsCurrent(source) { + if (!source || typeof source !== 'object' || Array.isArray(source)) { + throw new Error('Schema discovery source is not an object.'); + } + if (!Array.isArray(source.versions) || source.versions.length === 0) { + throw new Error('Schema discovery source has no versions array.'); + } + if (source.$id !== EXPECTED_ID) { + throw new Error(`Schema discovery source $id is ${JSON.stringify(source.$id)}, expected ${EXPECTED_ID}.`); + } + if (source.$schema !== EXPECTED_JSON_SCHEMA) { + throw new Error(`Schema discovery source $schema is ${JSON.stringify(source.$schema)}, expected ${EXPECTED_JSON_SCHEMA}.`); + } + if (source.channel !== EXPECTED_CHANNEL) { + throw new Error(`Schema discovery source channel is ${JSON.stringify(source.channel)}, expected ${EXPECTED_CHANNEL}.`); + } + for (const entry of source.versions) { + if (!entry || typeof entry !== 'object' || !isValidVersion(entry.version)) { + throw new Error(`Schema discovery source has an entry without a valid version: ${JSON.stringify(entry)}.`); + } + if (!KNOWN_STABILITIES.has(entry.stability)) { + throw new Error(`Schema discovery source entry ${entry.version} has unknown stability ${JSON.stringify(entry.stability)}.`); + } + if (Object.hasOwn(entry, 'superseded_by') && !isValidVersion(entry.superseded_by)) { + throw new Error(`Schema discovery source entry ${entry.version} has an invalid superseded_by.`); + } + for (const flag of ENTRY_FLAGS) { + if (Object.hasOwn(entry, flag) && typeof entry[flag] !== 'boolean') { + throw new Error(`Schema discovery source entry ${entry.version} has a non-boolean ${flag}.`); + } + } + } + const derived = derivePointers(stableVersionsOf(source.versions)); + assertEqualPointer('latest_stable', source.latest_stable, derived.latestStable); + assertEqualPointer('latest', source.latest, derived.latestStable); + assertEqualPointer('aliases', source.aliases, derived.aliases); + assertEqualPointer('latest_by_major', source.latest_by_major, derived.latestByMajor); + assertEqualPointer('latest_by_minor', source.latest_by_minor, derived.latestByMinor); +} + +// The CDN listing and the local release set are read at different moments and +// can disagree transiently. A pointer must never regress, so compare against +// the copy currently served and refuse to publish a lower target. +// +// `retired` exempts a release the source has withdrawn or unpublished: that is +// a deliberate editorial decision, and without the exemption the first such +// override on a live alias would wedge every deploy. +function assertNoRegression(published, candidate, { allowBootstrap = false, retired = new Set() } = {}) { + if (!published) { + if (allowBootstrap) return; + // The served copy is the only defense against a bad listing. On the + // routine-deploy path that object always exists, so its absence means + // something is wrong — do not respond by dropping the safety check. + throw new Error( + 'No published schemas/index.json to compare against; refusing to publish without the regression guard. ' + + 'Pass --allow-bootstrap only for a reviewed first publication.', + ); + } + if (typeof published.latest_stable !== 'string') { + throw new Error('Published schemas/index.json has no latest_stable; refusing to publish without the regression guard.'); + } + + const compare = (label, before, after) => { + if (!before || retired.has(before)) return; + if (!after) { + throw new Error( + `Refusing to drop published schema discovery pointer ${label} (was ${JSON.stringify(before)}). ` + + 'The CDN no longer serves a release it advertises; recover that release per RELEASING.md rather than overriding this.', + ); + } + if (!isStableVersion(before) || !isStableVersion(after)) { + throw new Error(`Refusing to compare non-stable schema discovery pointer ${label}: ${JSON.stringify(before)} -> ${JSON.stringify(after)}.`); + } + if (compareStableDescending(after, before) > 0) { + throw new Error( + `Refusing to regress published schema discovery pointer ${label}: ${before} -> ${after}. ` + + 'The CDN no longer serves a release it advertises; recover that release per RELEASING.md rather than overriding this.', + ); + } + }; + + compare('latest_stable', published.latest_stable, candidate.latest_stable); + for (const map of POINTER_MAPS) { + const before = published[map]; + if (!before || typeof before !== 'object') continue; + for (const key of Object.keys(before)) { + compare(`${map}.${key}`, before[key], candidate[map][key]); + } + } +} + +// Rebuild each entry from an allowlist so no URL, id, or free-form field from +// the source document reaches the public discovery index verbatim. +function publishedEntry(entry) { + const published = { version: entry.version, stability: entry.stability }; + for (const flag of ENTRY_FLAGS) { + if (Object.hasOwn(entry, flag)) published[flag] = entry[flag]; + } + if (Object.hasOwn(entry, 'superseded_by')) published.superseded_by = entry.superseded_by; + published.path = `/schemas/${entry.version}/`; + published.index = `/schemas/${entry.version}/index.json`; + return published; +} + +function retiredVersions(source) { + return new Set(source.versions + .filter((entry) => RETIRED_STABILITIES.has(entry.stability) || entry.published === false) + .map((entry) => entry.version)); +} + +/** + * Stable releases an alias could resolve to, newest first within each line, as + * ` ` pairs. The caller walks these in order and confirms each + * candidate's manifest object before letting it win its line. + */ +function aliasCandidates(source, presentVersions) { + assertSourceIsCurrent(source); + const present = new Set(presentVersions); + return sortedStable(stableVersionsOf(source.versions).filter((version) => present.has(version))) + .map((version) => ({ minor: parseVersion(version).minor, version })); +} + +/** + * @param {object} source Freshly built dist/schemas/index.json. + * @param {Iterable} presentVersions Versions with a CDN prefix. + * @param {Iterable} confirmedVersions Versions whose schemas//index.json was confirmed. + * @param {object|null} publishedIndex The schemas/index.json currently served. + * @param {boolean} allowBootstrap Permit a first publication with no served copy. + */ +function buildPublishablePointers({ + source, presentVersions, confirmedVersions, publishedIndex = null, allowBootstrap = false, +}) { + assertSourceIsCurrent(source); + + const present = new Set(presentVersions); + const confirmed = new Set(confirmedVersions); + for (const version of confirmed) { + if (!present.has(version)) { + throw new Error(`Confirmed schema version ${version} is absent from the CDN listing; refusing inconsistent inputs.`); + } + } + + const entries = source.versions.filter((entry) => present.has(entry.version)); + const absent = source.versions + .filter((entry) => !present.has(entry.version)) + .map((entry) => entry.version); + const derived = derivePointers([...confirmed]); + if (!derived.latestStable) { + throw new Error('No stable schema version has a confirmed index.json on the CDN; refusing to publish empty pointers.'); + } + + // The caller walks each line newest-first and stops at the first + // confirmation, so a present stable release newer than its line's winner is + // one it tested and rejected. Anything older was never tested — reporting + // it would bury the real gap in noise. + const unconfirmed = stableVersionsOf(entries).filter((version) => { + if (confirmed.has(version)) return false; + const winner = derived.latestByMinor[parseVersion(version).minor]; + return winner === undefined || compareStableDescending(version, winner) < 0; + }); + + const index = { + $schema: EXPECTED_JSON_SCHEMA, + $id: EXPECTED_ID, + title: String(source.title ?? ''), + description: String(source.description ?? ''), + latest: derived.latestStable, + latest_stable: derived.latestStable, + channel: EXPECTED_CHANNEL, + aliases: derived.aliases, + latest_by_major: derived.latestByMajor, + latest_by_minor: derived.latestByMinor, + versions: entries.map(publishedEntry), + }; + + const retired = retiredVersions(source); + assertNoRegression(publishedIndex, index, { allowBootstrap, retired }); + + // Losing an older entry from `versions[]` is consumer-visible but not an + // alias misdirection, and failing on it would wedge every deploy over a + // listing wobble. Report it instead; the alias guard above stays fatal. + const dropped = Array.isArray(publishedIndex?.versions) + ? publishedIndex.versions + .map((entry) => entry?.version) + .filter((version) => isValidVersion(version) && !present.has(version) && !retired.has(version)) + : []; + + const latest = { + latest: index.latest_stable, + latest_stable: index.latest_stable, + channel: EXPECTED_CHANNEL, + path: `/schemas/${index.latest_stable}/`, + index: `/schemas/${index.latest_stable}/index.json`, + }; + + return { index, latest, absent, unconfirmed, dropped }; +} + +// `aws s3api list-objects-v2 --prefix schemas/ --delimiter /` output. +function versionsFromListing(listing) { + // The AWS CLI auto-paginates and merges pages, so a truncated response here + // means something returned a partial view. Publishing from a partial version + // set is exactly how a pointer would silently regress. + if (listing?.IsTruncated === true || listing?.NextContinuationToken) { + throw new Error('Truncated schemas/ listing; refusing to publish pointers from a partial version set.'); + } + const prefixes = Array.isArray(listing?.CommonPrefixes) ? listing.CommonPrefixes : []; + return prefixes + .map((entry) => String(entry?.Prefix || '')) + .filter((prefix) => prefix.startsWith('schemas/') && prefix.endsWith('/')) + .map((prefix) => prefix.slice('schemas/'.length, -1)) + .filter(isValidVersion); +} + +function readJson(file) { + return JSON.parse(fs.readFileSync(file, 'utf8')); +} + +function readLines(file) { + if (!file || !fs.existsSync(file)) return []; + return fs.readFileSync(file, 'utf8').split('\n').map((line) => line.trim()).filter(Boolean); +} + +function writeJson(file, value) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} + +const USAGE = [ + 'Usage:', + ' build-schema-discovery-pointers.cjs candidates --source FILE --listing FILE', + ' build-schema-discovery-pointers.cjs build --source FILE --listing FILE --confirmed FILE', + ' [--published-index FILE] [--allow-bootstrap] --out-index FILE --out-latest FILE', + '', + '`candidates` prints " " lines, newest first. Confirm each', + 'candidate\'s schemas//index.json object and pass the winners to', + '`build` through --confirmed.', +].join('\n'); + +function parseArgs(argv) { + const [mode, ...rest] = argv; + if (mode === '-h' || mode === '--help') return { mode: 'help' }; + if (mode !== 'candidates' && mode !== 'build') throw new Error(USAGE); + const options = { mode }; + for (let i = 0; i < rest.length; i += 1) { + const flag = rest[i]; + if (!flag.startsWith('--')) throw new Error(`Unexpected argument: ${flag}\n${USAGE}`); + const name = flag.slice(2).replace(/-([a-z])/g, (_, character) => character.toUpperCase()); + if (name === 'allowBootstrap') { + options.allowBootstrap = true; + continue; + } + const value = rest[i + 1]; + if (value === undefined || value.startsWith('--')) throw new Error(`${flag} requires a value\n${USAGE}`); + options[name] = value; + i += 1; + } + const required = mode === 'candidates' + ? ['source', 'listing'] + : ['source', 'listing', 'confirmed', 'outIndex', 'outLatest']; + for (const key of required) { + if (!options[key]) throw new Error(USAGE); + } + return options; +} + +function main(argv) { + const options = parseArgs(argv); + if (options.mode === 'help') { + console.log(USAGE); + return; + } + const source = readJson(options.source); + const presentVersions = versionsFromListing(readJson(options.listing)); + + if (options.mode === 'candidates') { + for (const { minor, version } of aliasCandidates(source, presentVersions)) { + console.log(`${minor} ${version}`); + } + return; + } + + const publishedIndex = options.publishedIndex && fs.existsSync(options.publishedIndex) + ? readJson(options.publishedIndex) + : null; + + const { + index, latest, absent, unconfirmed, dropped, + } = buildPublishablePointers({ + source, + presentVersions, + confirmedVersions: readLines(options.confirmed), + publishedIndex, + allowBootstrap: Boolean(options.allowBootstrap), + }); + + writeJson(options.outIndex, index); + writeJson(options.outLatest, latest); + + // Not fatal: routine deployment cannot publish a version directory, only + // release authority can. Surface the gap so it stays actionable. + const gaps = [...new Set([...absent.filter(isStableVersion), ...unconfirmed])].sort(compareStableDescending); + if (gaps.length > 0) { + console.log( + `::warning title=Schema versions missing from the CDN::${gaps.join(', ')} ` + + 'are released in this checkout but have no complete schemas//index.json on the CDN, so the ' + + 'discovery pointers skip them. Recover those releases per RELEASING.md.', + ); + } + if (dropped.length > 0) { + console.log( + `::warning title=Schema versions dropped from discovery::${dropped.join(', ')} ` + + 'were listed by the published schemas/index.json but have no CDN prefix now. ' + + 'Aliases are unaffected; investigate before the enumeration matters.', + ); + } + console.log(`Schema discovery pointers: latest_stable=${index.latest_stable} aliases=${JSON.stringify(index.aliases)}`); +} + +module.exports = { + aliasCandidates, + buildPublishablePointers, + derivePointers, + parseVersion, + publishedEntry, + retiredVersions, + versionsFromListing, +}; + +if (require.main === module) { + try { + main(process.argv.slice(2)); + } catch (error) { + console.error(`::error::${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } +} diff --git a/scripts/verify-schema-discovery.mjs b/scripts/verify-schema-discovery.mjs new file mode 100644 index 00000000000..5eff501f9ed --- /dev/null +++ b/scripts/verify-schema-discovery.mjs @@ -0,0 +1,183 @@ +#!/usr/bin/env node +// Post-publication check for the root schema discovery pointers. +// +// Publication is progressive and per-object (RELEASING.md): a successful +// upload step is not evidence that the public URL serves the new bytes. This +// fetches the live documents and fails when they differ from what the +// publication step just produced — the condition that went unnoticed for +// weeks in adcontextprotocol/adcp#8318. +// +// Scope note: this proves the public URL serves this run's document. It does +// not assert that the document names the newest release of every line — a +// line whose newest patch never reached the CDN is deliberately pinned one +// release back by scripts/build-schema-discovery-pointers.cjs, which reports +// the gap as a warning instead. + +import { readFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; + +const DEFAULT_ORIGIN = 'https://adcontextprotocol.org'; +const DEFAULT_ATTEMPTS = 5; +const DEFAULT_DELAY_MS = 6000; +const FETCH_TIMEOUT_MS = 20000; + +const POINTER_FIELDS = ['latest', 'latest_stable', 'channel']; +const POINTER_MAPS = ['aliases', 'latest_by_major', 'latest_by_minor']; + +const USAGE = [ + 'Usage: node scripts/verify-schema-discovery.mjs --expected-index FILE [options]', + '', + ' --expected-index FILE Pointer document the publication step uploaded.', + ' --expected-latest FILE Optional schemas/latest.json companion to verify.', + ` --origin URL Public origin to verify (default: ${DEFAULT_ORIGIN}).`, + ` --attempts N Fetch attempts per document (default: ${DEFAULT_ATTEMPTS}).`, + ` --delay-ms N Delay between attempts (default: ${DEFAULT_DELAY_MS}).`, +].join('\n'); + +export function parseArgs(argv) { + const options = { origin: DEFAULT_ORIGIN, attempts: DEFAULT_ATTEMPTS, delayMs: DEFAULT_DELAY_MS }; + for (let i = 0; i < argv.length; i += 1) { + const flag = argv[i]; + if (flag === '-h' || flag === '--help') return { help: true }; + const value = argv[i + 1]; + if (value === undefined || value.startsWith('--')) throw new Error(`${flag} requires a value\n${USAGE}`); + i += 1; + switch (flag) { + case '--expected-index': options.expectedIndex = value; break; + case '--expected-latest': options.expectedLatest = value; break; + case '--origin': options.origin = value.replace(/\/+$/, ''); break; + case '--attempts': options.attempts = Number.parseInt(value, 10); break; + case '--delay-ms': options.delayMs = Number.parseInt(value, 10); break; + default: throw new Error(`Unexpected argument: ${flag}\n${USAGE}`); + } + } + if (!options.expectedIndex) throw new Error(USAGE); + if (!/^https?:\/\//.test(options.origin)) throw new Error('--origin must be an http(s) URL'); + if (!Number.isInteger(options.attempts) || options.attempts < 1) { + throw new Error('--attempts must be a positive integer'); + } + if (!Number.isInteger(options.delayMs) || options.delayMs < 0) { + throw new Error('--delay-ms must be a non-negative integer'); + } + return options; +} + +/** + * Human-readable pointer differences, used to explain a byte mismatch. An + * empty array with differing bytes means the pointers agree but something + * else in the document does, which is still a failure. + */ +export function pointerMismatches(label, expected, actual) { + const problems = []; + for (const field of POINTER_FIELDS) { + if (!Object.hasOwn(expected, field)) continue; + if (actual?.[field] !== expected[field]) { + problems.push(`${label}: ${field} is ${JSON.stringify(actual?.[field])}, expected ${JSON.stringify(expected[field])}`); + } + } + for (const map of POINTER_MAPS) { + if (!Object.hasOwn(expected, map)) continue; + for (const [key, value] of Object.entries(expected[map])) { + const served = actual?.[map]?.[key]; + if (served !== value) { + problems.push(`${label}: ${map}.${key} is ${JSON.stringify(served)}, expected ${JSON.stringify(value)}`); + } + } + } + return problems; +} + +function parseOrNull(text) { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +// Request the canonical URL a consumer requests, not a cache-busted variant. +// `schemas/index.json` is served with `no-cache, must-revalidate` and the CDN +// worker sets `edgeCache: false` for it (workers/artifact-cdn/src/index.js), +// so there is no edge copy to defeat — and checking the canonical URL is the +// only way to verify what consumers actually get. +async function fetchText(url, fetchImpl) { + const response = await fetchImpl(url, { + cache: 'no-store', + headers: { 'cache-control': 'no-cache', pragma: 'no-cache' }, + // A hung CDN request must not stall the deploy job until the runner times out. + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.text(); +} + +export async function verifySchemaDiscovery(options, deps = {}) { + const fetchImpl = deps.fetch ?? globalThis.fetch; + const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => { setTimeout(resolve, ms); })); + const readText = deps.readText ?? ((file) => readFile(file, 'utf8')); + const log = deps.log ?? console.log; + + const documents = [ + { label: 'schemas/index.json', path: '/schemas/index.json', file: options.expectedIndex }, + ]; + if (options.expectedLatest) { + documents.push({ label: 'schemas/latest.json', path: '/schemas/latest.json', file: options.expectedLatest }); + } + + const problems = []; + for (const document of documents) { + const expectedText = await readText(document.file); + const expected = JSON.parse(expectedText); + const url = `${options.origin}${document.path}`; + // Propagation is not instant, so retry. Then fail: a pointer that never + // lands is exactly the defect this check exists to catch. + let outstanding = []; + for (let attempt = 1; attempt <= options.attempts; attempt += 1) { + try { + const servedText = await fetchText(url, fetchImpl); + if (servedText === expectedText) { + outstanding = []; + } else { + // Byte equality is the real assertion; the pointer diff only makes + // the failure readable. + outstanding = pointerMismatches(document.label, expected, parseOrNull(servedText)); + if (outstanding.length === 0) { + outstanding = [`${document.label}: pointers match but ${url} does not serve the published bytes`]; + } + } + } catch (error) { + outstanding = [`${document.label}: could not read ${url}: ${error instanceof Error ? error.message : String(error)}`]; + } + if (outstanding.length === 0) break; + if (attempt < options.attempts) await sleep(options.delayMs); + } + if (outstanding.length > 0) problems.push(...outstanding); + else log(`✓ ${url} serves the published document`); + } + + return problems; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + console.log(USAGE); + return; + } + const problems = await verifySchemaDiscovery(options); + if (problems.length > 0) { + for (const problem of problems) console.error(`::error::${problem}`); + console.error( + 'The published schema discovery pointers do not match this build. ' + + 'See RELEASING.md "Publication authority and ordering".', + ); + process.exit(1); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(`::error::${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + }); +} diff --git a/tests/cdn-artifacts.test.cjs b/tests/cdn-artifacts.test.cjs index 091934f1c9a..b7d2389389e 100644 --- a/tests/cdn-artifacts.test.cjs +++ b/tests/cdn-artifacts.test.cjs @@ -22,6 +22,53 @@ const TYPES = { mdx: 'text/markdown; charset=utf-8', txt: 'text/plain; charset=utf-8', }; +// Stable releases the checkout knows about, newest first. PUBLISHED_STABLE is +// what the bucket actually serves: 3.1.2 is released but never reached the +// CDN, mirroring the live 3.0.27/3.0.28 gap behind #8318's stale v3.0 alias. +const SOURCE_STABLE = ['3.1.2', '3.1.1', '3.1.0', '3.0.9']; +const PUBLISHED_STABLE = ['3.1.1', '3.1.0', '3.0.9']; +const PRIOR_POINTER_STABLE = '3.1.0'; + +function discoverySource() { + const entry = (version, extra) => ({ version, ...extra, + path: `/schemas/${version}/`, index: `/schemas/${version}/index.json` }); + return { + $schema: 'http://json-schema.org/draft-07/schema#', + $id: 'https://adcontextprotocol.org/schemas/index.json', + title: 'AdCP Schema Discovery', + description: 'Root discovery document.', + latest: '3.1.2', + latest_stable: '3.1.2', + channel: 'stable', + aliases: { v3: '3.1.2', 'v3.1': '3.1.2', 'v3.0': '3.0.9' }, + latest_by_major: { 3: '3.1.2' }, + latest_by_minor: { '3.1': '3.1.2', '3.0': '3.0.9' }, + versions: [ + ...VERSIONS.map(version => entry(version, { stability: 'rc', prerelease: true, deprecated: false })), + ...SOURCE_STABLE.map(version => entry(version, { stability: 'stable', prerelease: false, deprecated: false })), + ], + }; +} + +// Bucket contents before the routine deploy runs: older version directories +// plus the frozen pointer document those deploys never refreshed. +function publishedFixtureObjects() { + const object = (key, value) => [`s3://test-bucket/${key}`, { key: `s3://test-bucket/${key}`, + body: Buffer.from(JSON.stringify(value)).toString('base64'), + contentType: 'application/json; charset=utf-8', cacheControl: REVALIDATE }]; + return Object.fromEntries([ + ...PUBLISHED_STABLE.map(version => object(`schemas/${version}/index.json`, { version })), + object('schemas/index.json', { + latest: PRIOR_POINTER_STABLE, + latest_stable: PRIOR_POINTER_STABLE, + aliases: { v3: PRIOR_POINTER_STABLE, 'v3.1': PRIOR_POINTER_STABLE, 'v3.0': '3.0.9' }, + latest_by_major: { 3: PRIOR_POINTER_STABLE }, + latest_by_minor: { '3.1': PRIOR_POINTER_STABLE, '3.0': '3.0.9' }, + versions: PUBLISHED_STABLE.map(version => ({ version })), + }), + ]); +} + const sha256 = bytes => createHash('sha256').update(bytes).digest('hex'); // The JSONL fix can land before or after the publication gate. Only the test // fixture adapts; production must never regain live unscoped bulk publication. @@ -57,6 +104,24 @@ function awsStub() { fs.writeFileSync(process.env.AWS_TEST_OBJECTS, JSON.stringify(objects)); uploads.push(upload); record(); + } else if (operation === 'list-objects-v2') { + const bucketPrefix = `s3://${value('--bucket')}/`; + const prefix = value('--prefix'); + const delimiter = value('--delimiter'); + const common = new Set(); + const contents = []; + for (const stored of Object.keys(objects)) { + if (!stored.startsWith(bucketPrefix)) continue; + const key = stored.slice(bucketPrefix.length); + if (!key.startsWith(prefix)) continue; + const rest = key.slice(prefix.length); + const cut = delimiter ? rest.indexOf(delimiter) : -1; + if (cut >= 0) common.add(prefix + rest.slice(0, cut + delimiter.length)); + else contents.push({ Key: key }); + } + record(); + console.log(JSON.stringify({ Contents: contents, + CommonPrefixes: [...common].sort().map(Prefix => ({ Prefix })) })); } else throw Error(`Unexpected s3api operation: ${operation}`); return; } @@ -175,8 +240,14 @@ function fixture(t) { } } write('dist/compliance/storyboard-runner-options.js', 'excluded runtime file'); - write('dist/schemas/index.json', '{}'); - write('dist/schemas/latest.json', '{}'); + // The routine deploy republishes the root pointers from the CDN's published + // version set, so the fixture needs a realistic discovery source and a + // bucket that already serves an older subset of it. SOURCE_STABLE's newest + // entry is deliberately absent from the bucket. + write('dist/schemas/index.json', JSON.stringify(discoverySource(), null, 2)); + write('dist/schemas/latest.json', JSON.stringify({ latest: SOURCE_STABLE[0], latest_stable: SOURCE_STABLE[0], + channel: 'stable', path: `/schemas/${SOURCE_STABLE[0]}/`, index: `/schemas/${SOURCE_STABLE[0]}/index.json` }, null, 2)); + fs.writeFileSync(path.join(dir, 'objects.json'), JSON.stringify(publishedFixtureObjects())); let authority; if (FENCED_PUBLICATION) { const git = args => execFileSync(REAL_GIT, args, { cwd: dir, encoding: 'utf8', env: { @@ -254,7 +325,10 @@ for (const skipLatest of [false, true]) { for (const [i, result] of results.entries()) { const prefix = i < VERSIONS.length ? VERSIONS[i] : 'latest'; assert.ok(result.uploads.length > 0); - assert.ok(result.uploads.every(upload => upload.key.includes(`/${prefix}/`) || upload.key.includes(`/${prefix}.`))); + assert.ok(result.uploads.every(upload => upload.key.includes(`/${prefix}/`) + || upload.key.includes(`/${prefix}.`) + // Routine deployment also refreshes the root discovery pointer. + || (prefix === 'latest' && upload.key === 's3://test-bucket/schemas/index.json'))); } const ghCalls = fs.readFileSync(path.join(dir, 'gh.jsonl'), 'utf8').trim().split('\n').map(JSON.parse); for (const version of VERSIONS) { @@ -290,8 +364,27 @@ for (const skipLatest of [false, true]) { assert.equal(upload.cacheControl, immutable ? IMMUTABLE : REVALIDATE, key); } assert.equal(uploads.some(upload => upload.key.includes('/latest')), !skipLatest); - assert.equal(uploads.some(upload => upload.key.endsWith('/schemas/index.json')), !FENCED_PUBLICATION && !skipLatest); + // #8318: routine deployment is the only step that runs on every main + // merge, so it must republish the root pointers or they freeze forever. + const pointer = uploads.filter(upload => upload.key === 's3://test-bucket/schemas/index.json'); + assert.equal(pointer.length, skipLatest ? 0 : 1); if (!skipLatest) { + const published = JSON.parse(Buffer.from(pointer[0].body, 'base64').toString('utf8')); + assert.equal(pointer[0].cacheControl, REVALIDATE); + // Aliases advance to the newest release the bucket actually serves, and + // never to 3.1.2, which is released in-repo but absent from the CDN. + assert.equal(published.latest_stable, '3.1.1'); + assert.deepEqual(published.aliases, { v3: '3.1.1', 'v3.1': '3.1.1', 'v3.0': '3.0.9' }); + assert.deepEqual(published.latest_by_minor, { '3.1': '3.1.1', '3.0': '3.0.9' }); + assert.ok(!published.versions.some(entry => entry.version === '3.1.2')); + // Prereleases published by this same run stay listed. + assert.ok(VERSIONS.every(version => published.versions.some(entry => entry.version === version))); + const latestPointer = uploads.filter(upload => upload.key === 's3://test-bucket/schemas/latest.json'); + assert.equal(latestPointer.length, 1); + assert.deepEqual(JSON.parse(Buffer.from(latestPointer[0].body, 'base64').toString('utf8')), { + latest: '3.1.1', latest_stable: '3.1.1', channel: 'stable', + path: '/schemas/3.1.1/', index: '/schemas/3.1.1/index.json', + }); assert.equal(uploads.filter(upload => upload.key === `s3://test-bucket/compliance/latest/${ROWS}`).length, 1); } }); diff --git a/tests/release-publication-ordering.test.cjs b/tests/release-publication-ordering.test.cjs index 721b919a3e9..afb2f009599 100644 --- a/tests/release-publication-ordering.test.cjs +++ b/tests/release-publication-ordering.test.cjs @@ -17,6 +17,8 @@ const step = (name) => steps.find((s) => s.name === name); const version = "3.2.0-rc.3"; const rowsPath = "test-vectors/reporting-reconciliation/rows.jsonl"; const realGit = execFileSync("which", ["git"], { encoding: "utf8" }).trim(); +// The one stable release the fixture bucket already serves. +const publishedStable = "3.1.23"; function fixture(t, fixtureVersion = version, includeReleaseHistory = false) { const version = fixtureVersion; @@ -56,7 +58,58 @@ function fixture(t, fixtureVersion = version, includeReleaseHistory = false) { write(`dist/compliance/latest/${rowsPath}`, '{"development":true}\n'); } write("dist/protocol/latest.tgz", "development"); - for (const script of ["check-release-state.cjs", "backfill-cdn-artifacts.sh"]) + // Routine deployment rebuilds schemas/index.json and schemas/latest.json + // from the version directories the bucket already serves (#8318), so the + // fixture needs a discovery source and one published stable release. + write( + "dist/schemas/index.json", + JSON.stringify({ + $schema: "http://json-schema.org/draft-07/schema#", + $id: "https://adcontextprotocol.org/schemas/index.json", + title: "AdCP Schema Discovery", + description: "Root discovery document.", + latest: publishedStable, + latest_stable: publishedStable, + channel: "stable", + aliases: { v3: publishedStable, "v3.1": publishedStable }, + latest_by_major: { 3: publishedStable }, + latest_by_minor: { "3.1": publishedStable }, + versions: [ + { + version: publishedStable, + stability: "stable", + prerelease: false, + deprecated: false, + path: `/schemas/${publishedStable}/`, + index: `/schemas/${publishedStable}/index.json`, + }, + ], + }), + ); + write( + "dist/schemas/latest.json", + JSON.stringify({ latest: publishedStable, latest_stable: publishedStable }), + ); + write(`bucket/schemas/${publishedStable}/index.json`, '{"release":true}'); + // The pointer builder refuses to publish without the served copy to compare + // against, so the bucket starts with the frozen document deploys never + // refreshed. + write( + 'bucket/schemas/index.json', + JSON.stringify({ + latest: publishedStable, + latest_stable: publishedStable, + aliases: { v3: publishedStable, "v3.1": publishedStable }, + latest_by_major: { 3: publishedStable }, + latest_by_minor: { "3.1": publishedStable }, + versions: [{ version: publishedStable }], + }), + ); + for (const script of [ + "check-release-state.cjs", + "backfill-cdn-artifacts.sh", + "build-schema-discovery-pointers.cjs", + ]) write( `scripts/${script}`, fs.readFileSync(path.join(root, "scripts", script)), @@ -162,9 +215,20 @@ function fixture(t, fixtureVersion = version, includeReleaseHistory = false) { if(fs.existsSync(object)||process.env.PUT_RACE) {console.error('PreconditionFailed');process.exit(1);} fs.mkdirSync(path.dirname(object),{recursive:true});fs.copyFileSync(value('--body'),object);log('r2 put '+key); if(process.env.ADVANCE_AWS) change(); + } else if(args[1]==='list-objects-v2') { + const prefix=value('--prefix'),base=path.join('bucket',prefix); + const entries=fs.existsSync(base)?fs.readdirSync(base,{withFileTypes:true}):[]; + console.log(JSON.stringify({ + CommonPrefixes:entries.filter(e=>e.isDirectory()).map(e=>({Prefix:prefix+e.name+'/'})), + Contents:entries.filter(e=>e.isFile()).map(e=>({Key:prefix+e.name})), + })); } else if(args[0]==='s3'&&args[1]==='cp'&&args[2].startsWith('s3://')) { fs.copyFileSync(path.join('bucket',args[2].split('/').slice(3).join('/')),args[3]); } else if(args[0]==='s3') { + if(args[1]==='cp'&&!args.includes('--recursive')) { + const dest=path.join('bucket',args[3].split('/').slice(3).join('/')); + fs.mkdirSync(path.dirname(dest),{recursive:true});fs.copyFileSync(args[2],dest); + } log('r2 mutable '+args[3]);if(process.env.ADVANCE_AWS) change(); } else throw Error('unexpected aws '+args); `, @@ -245,7 +309,23 @@ test("app-only deploy cannot backfill #7507 stable artifacts or #7509 immutable .calls() .trim() .split("\n") - .every((line) => /\/latest(?:\/|\.|$)/.test(line)), + .every((line) => + /\/latest(?:\/|\.|$)/.test(line) || + // #8318: the root discovery pointers are mutable, so routine + // deployment republishes them. They name only versions the bucket + // already serves, so this cannot advertise an unapproved release. + /\/schemas\/index\.json$/.test(line), + ), + ); + assert.ok(f.calls().includes("r2 mutable s3://test/schemas/index.json")); + assert.ok(f.calls().includes("r2 mutable s3://test/schemas/latest.json")); + const pointer = JSON.parse( + fs.readFileSync(path.join(f.dir, "bucket/schemas/index.json"), "utf8"), + ); + assert.equal(pointer.latest_stable, publishedStable); + assert.deepEqual( + pointer.versions.map((entry) => entry.version), + [publishedStable], ); }); for (const scenario of [ diff --git a/tests/schema-discovery-pointers.test.cjs b/tests/schema-discovery-pointers.test.cjs new file mode 100644 index 00000000000..7401d2a71e6 --- /dev/null +++ b/tests/schema-discovery-pointers.test.cjs @@ -0,0 +1,487 @@ +// Root schema discovery pointers (/schemas/index.json, /schemas/latest.json). +// +// adcontextprotocol/adcp#8318: the published documents froze at 3.2.1 while +// 3.2.5, 3.1.29, and 3.0.28 shipped, because no publication step uploaded +// them. These tests pin both halves of the fix: the pointers are rebuilt from +// what the CDN actually serves, and a post-publication check fails when the +// live documents are not the ones this run published. + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { test } = require('node:test'); + +const { + aliasCandidates, + buildPublishablePointers, + derivePointers, + publishedEntry, + retiredVersions, + versionsFromListing, +} = require('../scripts/build-schema-discovery-pointers.cjs'); + +const ROOT = path.resolve(__dirname, '..'); +const DEPLOY = fs.readFileSync(path.join(ROOT, '.github/workflows/deploy.yml'), 'utf8'); + +const SCHEMA_ID = 'https://adcontextprotocol.org/schemas/index.json'; +const JSON_SCHEMA = 'http://json-schema.org/draft-07/schema#'; + +const entry = (version, stability, extra = {}) => ({ + version, + stability, + prerelease: version.includes('-'), + deprecated: false, + ...extra, + path: `/schemas/${version}/`, + index: `/schemas/${version}/index.json`, +}); + +const DEFAULT_STABLE = ['3.2.5', '3.1.29', '3.0.28', '2.5.3']; + +function source({ stable = DEFAULT_STABLE, extra = [], overrides = {} } = {}) { + const versions = [...extra, ...stable.map((version) => entry(version, 'stable'))] + .sort((a, b) => (a.version < b.version ? 1 : -1)); + const derived = derivePointers(versions.filter((v) => v.stability === 'stable').map((v) => v.version)); + return { + $schema: JSON_SCHEMA, + $id: SCHEMA_ID, + title: 'AdCP Schema Discovery', + description: 'Root discovery document.', + channel: 'stable', + latest: derived.latestStable, + latest_stable: derived.latestStable, + aliases: derived.aliases, + latest_by_major: derived.latestByMajor, + latest_by_minor: derived.latestByMinor, + versions, + ...overrides, + }; +} + +// Everything present and fully published, the healthy steady state. +const build = (options) => buildPublishablePointers({ + source: source(), + presentVersions: DEFAULT_STABLE, + confirmedVersions: DEFAULT_STABLE, + allowBootstrap: true, + ...options, +}); + +test('aliases follow the newest confirmed release on each line', () => { + const { index, latest } = build(); + + assert.equal(index.latest_stable, '3.2.5'); + assert.equal(index.latest, '3.2.5'); + assert.deepEqual(index.aliases, { + v3: '3.2.5', 'v3.2': '3.2.5', 'v3.1': '3.1.29', 'v3.0': '3.0.28', v2: '2.5.3', 'v2.5': '2.5.3', + }); + assert.deepEqual(index.latest_by_major, { 2: '2.5.3', 3: '3.2.5' }); + assert.deepEqual(index.latest_by_minor, { '3.2': '3.2.5', '3.1': '3.1.29', '3.0': '3.0.28', '2.5': '2.5.3' }); + assert.deepEqual(latest, { + latest: '3.2.5', + latest_stable: '3.2.5', + channel: 'stable', + path: '/schemas/3.2.5/', + index: '/schemas/3.2.5/index.json', + }); +}); + +test('a release absent from the CDN never wins an alias', () => { + // The live condition on 2026-10-19: 3.0.27 and 3.0.28 are released and + // committed, but no schemas/3.0.28/ prefix exists, so /schemas/3.0.28/ + // silently falls back to 3.0.26 bytes. Advertising it would hand file-based + // consumers a path whose payload disagrees with the version they asked for. + const { index, absent } = build({ + source: source({ stable: ['3.2.5', '3.0.28', '3.0.27', '3.0.26'] }), + presentVersions: ['3.2.5', '3.0.26'], + confirmedVersions: ['3.2.5', '3.0.26'], + }); + + assert.equal(index.aliases['v3.0'], '3.0.26'); + assert.equal(index.latest_by_minor['3.0'], '3.0.26'); + assert.ok(!index.versions.some((candidate) => candidate.version === '3.0.28')); + assert.deepEqual(absent.sort(), ['3.0.27', '3.0.28']); +}); + +test('a prefix without a confirmed index.json is listed but never aliased', () => { + // Release publication is progressive and per-object, so schemas/3.2.6/ + // exists the moment its first object lands. Letting a prefix win an alias + // would advertise a half-uploaded release under an immutable cache policy. + const { index, unconfirmed } = build({ + source: source({ stable: ['3.2.6', '3.2.5', '3.1.29'] }), + presentVersions: ['3.2.6', '3.2.5', '3.1.29'], + confirmedVersions: ['3.2.5', '3.1.29'], + }); + + assert.equal(index.latest_stable, '3.2.5'); + assert.equal(index.aliases.v3, '3.2.5'); + assert.equal(index.aliases['v3.2'], '3.2.5'); + assert.ok(index.versions.some((candidate) => candidate.version === '3.2.6')); + // Only the candidate the caller actually tested and rejected is a gap. + // 3.1.29 won its own line and nothing older than a winner was ever tested. + assert.deepEqual(unconfirmed, ['3.2.6']); +}); + +test('a release older than its line winner is never reported as a gap', () => { + // The caller stops walking a line at the first confirmation, so older + // patches are untested, not missing. Reporting them buries the real gap. + const { unconfirmed } = build({ + source: source({ stable: ['3.2.5', '3.2.4', '3.2.3', '3.1.29'] }), + presentVersions: ['3.2.5', '3.2.4', '3.2.3', '3.1.29'], + confirmedVersions: ['3.2.5', '3.1.29'], + }); + assert.deepEqual(unconfirmed, []); +}); + +test('a version the source retired is not reported as dropped', () => { + const retired = source({ + stable: ['3.2.5'], + extra: [entry('3.2.0-rc.5', 'unpublished', { published: false })], + }); + const { dropped } = build({ + source: retired, + presentVersions: ['3.2.5'], + confirmedVersions: ['3.2.5'], + publishedIndex: { + latest_stable: '3.2.5', + aliases: { v3: '3.2.5' }, + versions: [{ version: '3.2.5' }, { version: '3.2.0-rc.5' }], + }, + }); + assert.deepEqual(dropped, []); +}); + +test('alias candidates are offered newest-first per line so the caller can stop early', () => { + const candidates = aliasCandidates( + source({ stable: ['3.2.6', '3.2.5', '3.1.29', '3.1.28', '3.0.26'] }), + ['3.2.6', '3.2.5', '3.1.29', '3.1.28'], + ); + + assert.deepEqual(candidates, [ + { minor: '3.2', version: '3.2.6' }, + { minor: '3.2', version: '3.2.5' }, + { minor: '3.1', version: '3.1.29' }, + { minor: '3.1', version: '3.1.28' }, + ]); +}); + +test('withdrawn and prerelease entries are listed but never aliased', () => { + const { index } = build({ + source: source({ + stable: ['3.2.1', '3.1.2'], + extra: [ + entry('3.2.0', 'unpublished', { published: false }), + entry('3.2.0-rc.7', 'rc', { deprecated: true, superseded_by: '3.2.1' }), + entry('3.1.3', 'withdrawn', { deprecated: true, withdrawn: true }), + ], + }), + presentVersions: ['3.2.1', '3.2.0', '3.2.0-rc.7', '3.1.3', '3.1.2'], + confirmedVersions: ['3.2.1', '3.1.2'], + }); + + assert.equal(index.latest_stable, '3.2.1'); + assert.deepEqual(index.aliases, { v3: '3.2.1', 'v3.2': '3.2.1', 'v3.1': '3.1.2' }); + assert.equal(index.versions.length, 5); +}); + +test('no URL or identifier is copied out of the source document', () => { + // The published document is what every file-based consumer resolves + // against, so each entry is rebuilt from the version string. + const poisoned = source(); + poisoned.versions[0].path = 'https://evil.example/schemas/'; + poisoned.versions[0].index = 'https://evil.example/pwn.json'; + poisoned.versions[0].extra_field = 'dropped'; + poisoned.title = 'AdCP Schema Discovery'; + + const { index } = build({ source: poisoned }); + const serialized = JSON.stringify(index); + + assert.ok(!serialized.includes('evil.example')); + assert.ok(!serialized.includes('extra_field')); + assert.equal(index.$id, SCHEMA_ID); + assert.equal(index.$schema, JSON_SCHEMA); + assert.equal(index.channel, 'stable'); + for (const published of index.versions) { + assert.equal(published.path, `/schemas/${published.version}/`); + assert.equal(published.index, `/schemas/${published.version}/index.json`); + } + assert.deepEqual(publishedEntry(entry('3.1.3', 'withdrawn', { deprecated: true, withdrawn: true })), { + version: '3.1.3', + stability: 'withdrawn', + prerelease: false, + deprecated: true, + withdrawn: true, + path: '/schemas/3.1.3/', + index: '/schemas/3.1.3/index.json', + }); +}); + +test('a source document that disagrees with its own version list is refused', () => { + // The whole defect is a frozen pointer. Publishing a document whose aliases + // predate its version list would freeze it again, one release behind. + assert.throws( + () => build({ source: source({ overrides: { aliases: { v3: '3.2.1' } } }) }), + /Stale schema discovery source: aliases .*npm run build:schemas/s, + ); + assert.throws( + () => build({ source: source({ overrides: { latest_stable: '3.2.1' } }) }), + /Stale schema discovery source: latest_stable/, + ); + assert.throws( + () => build({ source: source({ overrides: { $id: 'https://evil.example/schemas/index.json' } }) }), + /source \$id is/, + ); + assert.throws( + () => build({ source: source({ overrides: { channel: 'beta' } }) }), + /source channel is/, + ); + assert.throws( + () => build({ source: source({ extra: [entry('3.9.9', 'experimental')] }) }), + /unknown stability/, + ); +}); + +const PUBLISHED = { + latest_stable: '3.2.5', + aliases: { v3: '3.2.5', 'v3.2': '3.2.5', 'v3.1': '3.1.29' }, + latest_by_major: { 3: '3.2.5' }, + latest_by_minor: { '3.2': '3.2.5', '3.1': '3.1.29' }, + versions: [{ version: '3.2.5' }, { version: '3.1.29' }], +}; + +test('a pointer never moves backwards', () => { + // A truncated or stale view must fail loudly rather than un-release 3.2.5. + assert.throws( + () => build({ + source: source({ stable: ['3.2.5', '3.2.4', '3.1.29'] }), + presentVersions: ['3.2.4', '3.1.29'], + confirmedVersions: ['3.2.4', '3.1.29'], + publishedIndex: PUBLISHED, + }), + /Refusing to regress published schema discovery pointer (latest_stable|aliases\.v3)/, + ); + + assert.throws( + () => build({ + source: source({ stable: ['3.2.5'] }), + presentVersions: ['3.2.5'], + confirmedVersions: ['3.2.5'], + publishedIndex: PUBLISHED, + }), + /Refusing to drop published schema discovery pointer aliases\.v3\.1/, + ); + + const { index } = build({ + source: source({ stable: ['3.2.6', '3.2.5', '3.1.29'] }), + presentVersions: ['3.2.6', '3.2.5', '3.1.29'], + confirmedVersions: ['3.2.6', '3.2.5', '3.1.29'], + publishedIndex: PUBLISHED, + }); + assert.equal(index.latest_stable, '3.2.6'); +}); + +test('withdrawing the release that owns an alias is the one legitimate regression', () => { + // RELEASE_STATUS_OVERRIDES has retired 3.1.3 and 3.2.0 before. Without this + // exemption the next such override on a live alias would wedge every deploy. + const withdrawn = source({ + stable: ['3.2.4', '3.1.29'], + extra: [entry('3.2.5', 'withdrawn', { deprecated: true, withdrawn: true })], + }); + assert.ok(retiredVersions(withdrawn).has('3.2.5')); + + const { index } = build({ + source: withdrawn, + presentVersions: ['3.2.5', '3.2.4', '3.1.29'], + confirmedVersions: ['3.2.4', '3.1.29'], + publishedIndex: PUBLISHED, + }); + assert.equal(index.latest_stable, '3.2.4'); + assert.equal(index.aliases['v3.2'], '3.2.4'); +}); + +test('a missing published index disables the regression guard, so it is refused', () => { + assert.throws( + () => buildPublishablePointers({ + source: source(), + presentVersions: DEFAULT_STABLE, + confirmedVersions: DEFAULT_STABLE, + publishedIndex: null, + }), + /refusing to publish without the regression guard/, + ); + assert.throws( + () => build({ publishedIndex: {}, allowBootstrap: false }), + /Published schemas\/index\.json has no latest_stable/, + ); +}); + +test('a dropped enumeration entry is reported, not fatal', () => { + // Losing an older entry is consumer-visible but not an alias misdirection, + // and failing on it would wedge every deploy over a listing wobble. + const { dropped, index } = build({ + source: source({ stable: ['3.2.5', '3.1.29'] }), + presentVersions: ['3.2.5'], + confirmedVersions: ['3.2.5'], + publishedIndex: { ...PUBLISHED, aliases: { v3: '3.2.5' }, latest_by_major: {}, latest_by_minor: {} }, + }); + assert.deepEqual(dropped, ['3.1.29']); + assert.equal(index.latest_stable, '3.2.5'); +}); + +test('an empty confirmed set is refused rather than published as empty pointers', () => { + assert.throws( + () => build({ presentVersions: [], confirmedVersions: [] }), + /No stable schema version has a confirmed index\.json/, + ); + assert.throws( + () => build({ presentVersions: ['3.2.5'], confirmedVersions: ['3.1.29'] }), + /Confirmed schema version 3\.1\.29 is absent from the CDN listing/, + ); +}); + +test('version directories come from the delimited bucket listing', () => { + assert.deepEqual( + versionsFromListing({ + CommonPrefixes: [ + { Prefix: 'schemas/3.2.5/' }, + { Prefix: 'schemas/3.2.0-rc.7/' }, + { Prefix: 'schemas/latest/' }, + { Prefix: 'schemas/v3/' }, + ], + Contents: [{ Key: 'schemas/index.json' }], + }), + ['3.2.5', '3.2.0-rc.7'], + ); + assert.deepEqual(versionsFromListing({}), []); + // A partial version set is how a pointer would silently regress. + assert.throws(() => versionsFromListing({ IsTruncated: true, CommonPrefixes: [] }), /Truncated schemas\/ listing/); + assert.throws(() => versionsFromListing({ NextContinuationToken: 'x' }), /Truncated schemas\/ listing/); +}); + +test('a compromised published index cannot pollute or pass', () => { + // The served copy is attacker-reachable if the bucket is ever compromised. + // JSON.parse, unlike an object literal, makes `__proto__` an own property. + assert.throws( + () => build({ + publishedIndex: JSON.parse('{"latest_stable":"3.2.5","aliases":{"__proto__":"9.9.9","v3":"3.2.5"}}'), + }), + /Refusing to (compare non-stable|regress|drop) .*__proto__/, + ); + assert.equal({}.polluted, undefined); + assert.equal(Object.prototype.v3, undefined); + assert.throws( + () => build({ publishedIndex: { latest_stable: '9.9.9' } }), + /Refusing to regress published schema discovery pointer latest_stable: 9\.9\.9 -> 3\.2\.5/, + ); +}); + +test('deploy publishes the pointers and verifies them against the public origin', () => { + // No execution path covers the workflow wiring itself. + assert.match(DEPLOY, /--build-latest --latest-only --quiet/); + assert.match(DEPLOY, /--pointer-output-dir "\$\{RUNNER_TEMP\}\/schema-pointers"/); + assert.match(DEPLOY, /node scripts\/verify-schema-discovery\.mjs/); +}); + +// ── Post-publication verification ─────────────────────────────────── + +const verifier = () => import('../scripts/verify-schema-discovery.mjs'); + +const EXPECTED = { + latest: '3.2.5', + latest_stable: '3.2.5', + channel: 'stable', + aliases: { v3: '3.2.5', 'v3.1': '3.1.29' }, + latest_by_major: { 3: '3.2.5' }, + latest_by_minor: { '3.2': '3.2.5', '3.1': '3.1.29' }, +}; +const EXPECTED_TEXT = `${JSON.stringify(EXPECTED, null, 2)}\n`; + +function origin(documents) { + const requests = []; + const fetch = async (url) => { + requests.push(url); + const key = new URL(url).pathname; + const body = typeof documents[key] === 'function' ? documents[key](requests.length) : documents[key]; + if (body === undefined) return { ok: false, status: 404, text: async () => '' }; + return { ok: true, status: 200, text: async () => body }; + }; + return { fetch, requests }; +} + +const run = async (documents, overrides = {}) => { + const { verifySchemaDiscovery } = await verifier(); + const remote = origin(documents); + const problems = await verifySchemaDiscovery( + { origin: 'https://cdn.invalid', attempts: 3, delayMs: 0, expectedIndex: 'index', ...overrides }, + { fetch: remote.fetch, sleep: async () => {}, readText: async () => EXPECTED_TEXT, log: () => {} }, + ); + return { problems, requests: remote.requests }; +}; + +test('a current published pointer passes on the first fetch', async () => { + const { problems, requests } = await run({ '/schemas/index.json': EXPECTED_TEXT }); + assert.deepEqual(problems, []); + assert.equal(requests.length, 1); + // The canonical URL is what consumers request; no cache-busted variant. + assert.equal(requests[0], 'https://cdn.invalid/schemas/index.json'); +}); + +test('a trailing alias fails after exhausting retries', async () => { + const stale = `${JSON.stringify({ + ...EXPECTED, latest: '3.2.1', latest_stable: '3.2.1', aliases: { v3: '3.2.1', 'v3.1': '3.1.24' }, + }, null, 2)}\n`; + const { problems, requests } = await run({ '/schemas/index.json': stale }); + assert.equal(requests.length, 3); + assert.ok(problems.some((problem) => problem.includes('latest_stable is "3.2.1", expected "3.2.5"'))); + assert.ok(problems.some((problem) => problem.includes('aliases.v3 is "3.2.1", expected "3.2.5"'))); + assert.ok(problems.some((problem) => problem.includes('aliases.v3.1 is "3.1.24", expected "3.1.29"'))); +}); + +test('matching pointers over different bytes still fail', async () => { + // Uploading is not serving. A document whose pointers happen to agree but + // whose body differs is not the document this run published. + const { problems } = await run({ + '/schemas/index.json': `${JSON.stringify({ ...EXPECTED, versions: [] }, null, 2)}\n`, + }); + assert.equal(problems.length, 1); + assert.match(problems[0], /pointers match but .* does not serve the published bytes/); +}); + +test('propagation lag is retried rather than failed', async () => { + const stale = `${JSON.stringify({ ...EXPECTED, latest_stable: '3.2.1' }, null, 2)}\n`; + const { problems, requests } = await run({ + '/schemas/index.json': (attempt) => (attempt < 3 ? stale : EXPECTED_TEXT), + }); + assert.deepEqual(problems, []); + assert.equal(requests.length, 3); +}); + +test('an unreachable or unparseable document is a failure, never a pass', async () => { + const missing = await run({}); + assert.equal(missing.problems.length, 1); + assert.match(missing.problems[0], /could not read .*\/schemas\/index\.json.*HTTP 404/); + + const garbage = await run({ '/schemas/index.json': '503' }); + assert.ok(garbage.problems.length > 0); + assert.ok(garbage.problems.some((problem) => /latest is undefined, expected "3\.2\.5"/.test(problem))); +}); + +test('schemas/latest.json is verified when the publication step produced it', async () => { + const { problems, requests } = await run( + { '/schemas/index.json': EXPECTED_TEXT, '/schemas/latest.json': '{"latest":"3.2.1"}' }, + { expectedLatest: 'latest' }, + ); + assert.ok(requests.some((url) => url.endsWith('/schemas/latest.json'))); + assert.ok(problems.some((problem) => problem.startsWith('schemas/latest.json: latest is "3.2.1"'))); +}); + +test('parseArgs rejects unusable settings', async () => { + const { parseArgs } = await verifier(); + assert.equal(parseArgs(['--expected-index', 'a']).origin, 'https://adcontextprotocol.org'); + assert.equal(parseArgs(['--expected-index', 'a', '--origin', 'https://cdn.invalid/']).origin, 'https://cdn.invalid'); + assert.equal(parseArgs(['--help']).help, true); + assert.throws(() => parseArgs(['--expected-index', 'a', '--attempts', '0']), /--attempts must be a positive integer/); + assert.throws(() => parseArgs(['--expected-index', 'a', '--delay-ms', '-1']), /--delay-ms must be a non-negative integer/); + assert.throws(() => parseArgs(['--expected-index', 'a', '--origin', 'file:///etc']), /--origin must be an http\(s\) URL/); + assert.throws(() => parseArgs([]), /--expected-index/); +});