Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .github/workflows/catalog-drift.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: API catalog drift

# Regenerates the embedded API catalog from the upstream gdcorp-platform
# commerce.*-specification repos and fails if the committed catalog has drifted.
# This is the source-drift guard (catches per-operation / per-endpoint changes).
# The always-on structural guard (domains + endpoint counts vs the intentional
# list) lives in `cargo test`
# (committed_catalog_matches_expected_domains_and_manifest) and runs on every PR.
#
# Requires a token with read access to the gdcorp-platform spec repos, provisioned
# as the `CATALOG_SPEC_TOKEN` secret. The default GITHUB_TOKEN only reaches this
# repo, so the job no-ops when the secret is absent (e.g. on forks).

on:
schedule:
- cron: "17 6 * * 1" # Mondays ~06:17 UTC

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very specific, haha

workflow_dispatch:

permissions:
contents: read

jobs:
drift:
runs-on: ubuntu-latest
# Guard against a hung network fetch to the spec repos burning runner minutes.
timeout-minutes: 20
defaults:
run:
working-directory: rust
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- name: Check for spec token
id: token
working-directory: .
env:
CATALOG_SPEC_TOKEN: ${{ secrets.CATALOG_SPEC_TOKEN }}
run: |
if [ -n "$CATALOG_SPEC_TOKEN" ]; then
echo "present=true" >> "$GITHUB_OUTPUT"
else
echo "present=false" >> "$GITHUB_OUTPUT"
echo "::notice::CATALOG_SPEC_TOKEN not set — skipping catalog drift check."
fi

- name: Install Rust toolchain
if: steps.token.outputs.present == 'true'
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable

- name: Regenerate catalog from upstream specs
if: steps.token.outputs.present == 'true'
# Runs from the job-default working-directory (rust/) — cargo needs the workspace.
env:
GITHUB_TOKEN: ${{ secrets.CATALOG_SPEC_TOKEN }}
run: cargo run -p generate-api-catalog

- name: Fail on drift
if: steps.token.outputs.present == 'true'
working-directory: .
run: |
# manifest.json carries a generated-at timestamp that changes every run;
# exclude it and compare only the domain schema files.
if ! git diff --quiet -- rust/schemas/api ':(exclude)rust/schemas/api/manifest.json'; then
echo "::error::Embedded API catalog is stale vs upstream commerce.*-specification repos. Regenerate with: (cd rust && GITHUB_TOKEN=<token> cargo run -p generate-api-catalog) and commit."
git diff --stat -- rust/schemas/api ':(exclude)rust/schemas/api/manifest.json'
exit 1
fi
echo "API catalog is in sync with upstream specs."
104 changes: 104 additions & 0 deletions rust/tools/generate-api-catalog/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,37 @@ const HTTP_METHODS: &[&str] = &[
"get", "post", "put", "patch", "delete", "options", "head", "trace",
];

/// The exact set of domains the committed catalog must contain — the deliberate,
/// reviewed contract. The `committed_catalog_matches_expected_domains_and_manifest`
/// test fails if the catalog files, or `manifest.json`, drift from this list, so
/// adding or removing a domain is a conscious change (update this list in the same
/// PR). This is the always-on, no-network guard; the scheduled regen-and-diff CI
/// job catches per-operation drift against the upstream spec repos.
#[cfg(test)]
const EXPECTED_DOMAINS: &[&str] = &[
"bulk-operations",
"businesses",
"catalog-products",
"channels",
"chargebacks",
"customer-profiles",
"fulfillments",
"hosting-nodejs",
"location-addresses",
"metafields",
"onboarding",
"orders",
"payment-requests",
"payments",
"price-adjustments",
"recommendations",
"shipping",
"stores",
"subscriptions",
"taxes",
"transactions",
];

// ---------------------------------------------------------------------------
// Output catalog types
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -2070,4 +2101,77 @@ components:
"Bar"
);
}

/// Drift guard: the committed catalog files and `manifest.json` must match the
/// intentional `EXPECTED_DOMAINS` contract, and every domain's endpoint count
/// must agree between its file and the manifest. Catches accidental drift
/// (a domain added/removed, a hand-edited catalog, a stale manifest) on every
/// `cargo test` run — no network or credentials required.
#[test]
fn committed_catalog_matches_expected_domains_and_manifest() {
let dir = resolve_output_dir();

// Domain files present on disk (excluding manifest.json).
let mut files: Vec<String> = std::fs::read_dir(&dir)
.expect("read schemas/api dir")
.filter_map(|e| e.ok())
.filter_map(|e| {
let n = e.file_name().to_string_lossy().into_owned();
match n.strip_suffix(".json") {
Some(stem) if n != "manifest.json" => Some(stem.to_owned()),
_ => None,
}
})
.collect();
files.sort();

let mut expected: Vec<String> = EXPECTED_DOMAINS.iter().map(|s| (*s).to_owned()).collect();
expected.sort();

assert_eq!(
files, expected,
"catalog domain files drifted from EXPECTED_DOMAINS — if adding/removing a \
domain, update EXPECTED_DOMAINS in the same change"
);

// manifest.json must list exactly the same domains...
let manifest: Value = serde_json::from_str(
&std::fs::read_to_string(dir.join("manifest.json")).expect("read manifest.json"),
)
.expect("parse manifest.json");
let domains = manifest["domains"]
.as_object()
.expect("manifest.domains is an object");

let mut manifest_domains: Vec<String> = domains.keys().cloned().collect();
manifest_domains.sort();
assert_eq!(
manifest_domains, expected,
"manifest.json domains differ from EXPECTED_DOMAINS / catalog files"
);

// ...and each domain's endpoint count must match between file and manifest.
for domain in EXPECTED_DOMAINS {
let catalog: Value = serde_json::from_str(
&std::fs::read_to_string(dir.join(format!("{domain}.json")))
.unwrap_or_else(|e| panic!("read {domain}.json: {e}")),
)
.unwrap_or_else(|e| panic!("parse {domain}.json: {e}"));

// Fail loudly on a structurally broken file rather than comparing two
// silent zeros (which would hide a missing endpoints array / count).
let actual = catalog["endpoints"]
.as_array()
.unwrap_or_else(|| panic!("'{domain}.json' has no endpoints array"))
.len();
let manifest_count = domains[*domain]["endpointCount"]
.as_u64()
.unwrap_or_else(|| panic!("manifest.json missing endpointCount for '{domain}'"))
as usize;
assert_eq!(
actual, manifest_count,
"endpoint-count drift for '{domain}': file has {actual}, manifest says {manifest_count}"
);
}
}
}