From a1bab38e164dcf6a65d11e3892677f8a79b0be62 Mon Sep 17 00:00:00 2001 From: Westin Wrzesinski Date: Mon, 25 May 2026 11:27:55 -0500 Subject: [PATCH 1/2] chore(protocol): add UCP snapshot provenance Document the vendored UCP snapshot, record the current 2026-04-08 source metadata, and add a drift check command to `dev.yml`. --- dev.yml | 6 + protocol/scripts/check_ucp_snapshot.sh | 166 +++++++++++++++++ protocol/source-lock.json | 235 +++++++++++++++++++++++++ 3 files changed, 407 insertions(+) create mode 100755 protocol/scripts/check_ucp_snapshot.sh create mode 100644 protocol/source-lock.json diff --git a/dev.yml b/dev.yml index 2715347a..5661096d 100644 --- a/dev.yml +++ b/dev.yml @@ -96,6 +96,12 @@ commands: kotlin|swift|typescript|ts) ./protocol/scripts/generate_models.sh --lang "$1" ;; *) echo "Usage: dev codegen "; exit 1 ;; esac + protocol: + desc: "Protocol maintenance commands" + subcommands: + check-upstream: + desc: "Compare vendored UCP snapshot files against upstream" + run: ./protocol/scripts/check_ucp_snapshot.sh "$@" apollo: subcommands: download_schema: diff --git a/protocol/scripts/check_ucp_snapshot.sh b/protocol/scripts/check_ucp_snapshot.sh new file mode 100755 index 00000000..4b0c4498 --- /dev/null +++ b/protocol/scripts/check_ucp_snapshot.sh @@ -0,0 +1,166 @@ +#!/bin/bash +# MIT License +# +# Copyright 2023-present, Shopify Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +LOCK_FILE="${REPO_ROOT}/protocol/source-lock.json" + +usage() { + cat <<'EOF' +Usage: check_ucp_snapshot.sh [--ref ] + +Compares the vendored UCP snapshot files in protocol/source-lock.json with +Universal-Commerce-Protocol/ucp. The check does not modify local files. + +Options: + --ref Upstream branch, tag, or commit to compare against. + Defaults to upstreamCommit from the lock file. + -h, --help Show this help message. +EOF +} + +REF="" +while [[ $# -gt 0 ]]; do + case "$1" in + --ref) + if [[ $# -lt 2 || -z "$2" ]]; then + echo "Missing value for --ref" >&2 + usage >&2 + exit 2 + fi + REF="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +missing_tools=() +for tool in gh jq diff mktemp; do + if ! command -v "$tool" >/dev/null 2>&1; then + missing_tools+=("$tool") + fi +done + +if [[ ${#missing_tools[@]} -gt 0 ]]; then + echo "Missing required tools: ${missing_tools[*]}" >&2 + exit 2 +fi + +if [[ ! -f "$LOCK_FILE" ]]; then + echo "Missing lock file: $LOCK_FILE" >&2 + exit 2 +fi + +if ! jq -e ' + (.protocolVersion | type == "string") and + (.upstreamRepository | type == "string") and + (.upstreamCommit | type == "string") and + (.files | type == "array") and + all(.files[]; (.local | type == "string") and (.upstream | type == "string")) +' "$LOCK_FILE" >/dev/null; then + echo "Invalid lock file shape: $LOCK_FILE" >&2 + exit 2 +fi + +UPSTREAM_REPO="$(jq -r '.upstreamRepository' "$LOCK_FILE")" + +if [[ -z "$REF" ]]; then + REF="$(jq -r '.upstreamCommit // "main"' "$LOCK_FILE")" +fi + +TMP_DIR="$(mktemp -d)" +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +drift=0 +tool_error=0 +checked=0 + +echo "Comparing vendored UCP snapshot with ${UPSTREAM_REPO}@${REF}" + +while IFS=$'\t' read -r local_path upstream_path; do + checked=$((checked + 1)) + local_file="${REPO_ROOT}/${local_path}" + upstream_file="${TMP_DIR}/upstream-${checked}" + upstream_error="${TMP_DIR}/upstream-${checked}.err" + + if [[ ! -f "$local_file" ]]; then + echo "Missing local file: ${local_path}" >&2 + drift=1 + continue + fi + + endpoint="repos/${UPSTREAM_REPO}/contents/${upstream_path}?ref=${REF}" + if ! gh api -H "Accept: application/vnd.github.raw" "$endpoint" >"$upstream_file" 2>"$upstream_error"; then + echo "Missing or unreadable upstream file: ${upstream_path} at ${REF}" >&2 + if [[ -s "$upstream_error" ]]; then + while IFS= read -r error_line; do + echo " ${error_line}" >&2 + done < "$upstream_error" + fi + drift=1 + continue + fi + + diff_rc=0 + diff -u -L "$local_path" -L "${upstream_path}@${REF}" "$local_file" "$upstream_file" || diff_rc=$? + case "$diff_rc" in + 0) + ;; + 1) + drift=1 + ;; + *) + echo "diff error comparing ${local_path} with ${upstream_path}@${REF}" >&2 + tool_error=1 + ;; + esac +done < <(jq -r '.files[] | [.local, .upstream] | @tsv' "$LOCK_FILE") + +if [[ "$checked" -eq 0 ]]; then + echo "No files listed in $LOCK_FILE" >&2 + exit 2 +fi + +if [[ "$tool_error" -ne 0 ]]; then + echo "UCP snapshot comparison failed due to tooling errors." >&2 + exit 2 +fi + +if [[ "$drift" -eq 0 ]]; then + echo "UCP snapshot matches ${UPSTREAM_REPO}@${REF} (${checked} files)." +else + echo "UCP snapshot drift detected against ${UPSTREAM_REPO}@${REF} (${checked} files checked)." >&2 +fi + +exit "$drift" diff --git a/protocol/source-lock.json b/protocol/source-lock.json new file mode 100644 index 00000000..cf66bf16 --- /dev/null +++ b/protocol/source-lock.json @@ -0,0 +1,235 @@ +{ + "protocolVersion": "2026-04-08", + "upstreamRepository": "Universal-Commerce-Protocol/ucp", + "upstreamCommit": "e627e4c9c84b5a9b54fa19c7e0c2231e78c00d8e", + "files": [ + { + "local": "protocol/schemas/capability.json", + "upstream": "source/schemas/capability.json" + }, + { + "local": "protocol/schemas/payment_handler.json", + "upstream": "source/schemas/payment_handler.json" + }, + { + "local": "protocol/schemas/service.json", + "upstream": "source/schemas/service.json" + }, + { + "local": "protocol/schemas/shopping/ap2_mandate.json", + "upstream": "source/schemas/shopping/ap2_mandate.json" + }, + { + "local": "protocol/schemas/shopping/buyer_consent.json", + "upstream": "source/schemas/shopping/buyer_consent.json" + }, + { + "local": "protocol/schemas/shopping/cart.json", + "upstream": "source/schemas/shopping/cart.json" + }, + { + "local": "protocol/schemas/shopping/checkout.json", + "upstream": "source/schemas/shopping/checkout.json" + }, + { + "local": "protocol/schemas/shopping/discount.json", + "upstream": "source/schemas/shopping/discount.json" + }, + { + "local": "protocol/schemas/shopping/fulfillment.json", + "upstream": "source/schemas/shopping/fulfillment.json" + }, + { + "local": "protocol/schemas/shopping/order.json", + "upstream": "source/schemas/shopping/order.json" + }, + { + "local": "protocol/schemas/shopping/payment.json", + "upstream": "source/schemas/shopping/payment.json" + }, + { + "local": "protocol/schemas/shopping/types/account_info.json", + "upstream": "source/schemas/shopping/types/account_info.json" + }, + { + "local": "protocol/schemas/shopping/types/adjustment.json", + "upstream": "source/schemas/shopping/types/adjustment.json" + }, + { + "local": "protocol/schemas/shopping/types/amount.json", + "upstream": "source/schemas/shopping/types/amount.json" + }, + { + "local": "protocol/schemas/shopping/types/available_payment_instrument.json", + "upstream": "source/schemas/shopping/types/available_payment_instrument.json" + }, + { + "local": "protocol/schemas/shopping/types/binding.json", + "upstream": "source/schemas/shopping/types/binding.json" + }, + { + "local": "protocol/schemas/shopping/types/business_fulfillment_config.json", + "upstream": "source/schemas/shopping/types/business_fulfillment_config.json" + }, + { + "local": "protocol/schemas/shopping/types/buyer.json", + "upstream": "source/schemas/shopping/types/buyer.json" + }, + { + "local": "protocol/schemas/shopping/types/card_credential.json", + "upstream": "source/schemas/shopping/types/card_credential.json" + }, + { + "local": "protocol/schemas/shopping/types/card_payment_instrument.json", + "upstream": "source/schemas/shopping/types/card_payment_instrument.json" + }, + { + "local": "protocol/schemas/shopping/types/context.json", + "upstream": "source/schemas/shopping/types/context.json" + }, + { + "local": "protocol/schemas/shopping/types/error_code.json", + "upstream": "source/schemas/shopping/types/error_code.json" + }, + { + "local": "protocol/schemas/shopping/types/error_response.json", + "upstream": "source/schemas/shopping/types/error_response.json" + }, + { + "local": "protocol/schemas/shopping/types/expectation.json", + "upstream": "source/schemas/shopping/types/expectation.json" + }, + { + "local": "protocol/schemas/shopping/types/fulfillment.json", + "upstream": "source/schemas/shopping/types/fulfillment.json" + }, + { + "local": "protocol/schemas/shopping/types/fulfillment_available_method.json", + "upstream": "source/schemas/shopping/types/fulfillment_available_method.json" + }, + { + "local": "protocol/schemas/shopping/types/fulfillment_destination.json", + "upstream": "source/schemas/shopping/types/fulfillment_destination.json" + }, + { + "local": "protocol/schemas/shopping/types/fulfillment_event.json", + "upstream": "source/schemas/shopping/types/fulfillment_event.json" + }, + { + "local": "protocol/schemas/shopping/types/fulfillment_group.json", + "upstream": "source/schemas/shopping/types/fulfillment_group.json" + }, + { + "local": "protocol/schemas/shopping/types/fulfillment_method.json", + "upstream": "source/schemas/shopping/types/fulfillment_method.json" + }, + { + "local": "protocol/schemas/shopping/types/fulfillment_option.json", + "upstream": "source/schemas/shopping/types/fulfillment_option.json" + }, + { + "local": "protocol/schemas/shopping/types/item.json", + "upstream": "source/schemas/shopping/types/item.json" + }, + { + "local": "protocol/schemas/shopping/types/line_item.json", + "upstream": "source/schemas/shopping/types/line_item.json" + }, + { + "local": "protocol/schemas/shopping/types/link.json", + "upstream": "source/schemas/shopping/types/link.json" + }, + { + "local": "protocol/schemas/shopping/types/merchant_fulfillment_config.json", + "upstream": "source/schemas/shopping/types/merchant_fulfillment_config.json" + }, + { + "local": "protocol/schemas/shopping/types/message.json", + "upstream": "source/schemas/shopping/types/message.json" + }, + { + "local": "protocol/schemas/shopping/types/message_error.json", + "upstream": "source/schemas/shopping/types/message_error.json" + }, + { + "local": "protocol/schemas/shopping/types/message_info.json", + "upstream": "source/schemas/shopping/types/message_info.json" + }, + { + "local": "protocol/schemas/shopping/types/message_warning.json", + "upstream": "source/schemas/shopping/types/message_warning.json" + }, + { + "local": "protocol/schemas/shopping/types/order_confirmation.json", + "upstream": "source/schemas/shopping/types/order_confirmation.json" + }, + { + "local": "protocol/schemas/shopping/types/order_line_item.json", + "upstream": "source/schemas/shopping/types/order_line_item.json" + }, + { + "local": "protocol/schemas/shopping/types/payment_credential.json", + "upstream": "source/schemas/shopping/types/payment_credential.json" + }, + { + "local": "protocol/schemas/shopping/types/payment_identity.json", + "upstream": "source/schemas/shopping/types/payment_identity.json" + }, + { + "local": "protocol/schemas/shopping/types/payment_instrument.json", + "upstream": "source/schemas/shopping/types/payment_instrument.json" + }, + { + "local": "protocol/schemas/shopping/types/platform_fulfillment_config.json", + "upstream": "source/schemas/shopping/types/platform_fulfillment_config.json" + }, + { + "local": "protocol/schemas/shopping/types/postal_address.json", + "upstream": "source/schemas/shopping/types/postal_address.json" + }, + { + "local": "protocol/schemas/shopping/types/retail_location.json", + "upstream": "source/schemas/shopping/types/retail_location.json" + }, + { + "local": "protocol/schemas/shopping/types/reverse_domain_name.json", + "upstream": "source/schemas/shopping/types/reverse_domain_name.json" + }, + { + "local": "protocol/schemas/shopping/types/shipping_destination.json", + "upstream": "source/schemas/shopping/types/shipping_destination.json" + }, + { + "local": "protocol/schemas/shopping/types/signals.json", + "upstream": "source/schemas/shopping/types/signals.json" + }, + { + "local": "protocol/schemas/shopping/types/signed_amount.json", + "upstream": "source/schemas/shopping/types/signed_amount.json" + }, + { + "local": "protocol/schemas/shopping/types/token_credential.json", + "upstream": "source/schemas/shopping/types/token_credential.json" + }, + { + "local": "protocol/schemas/shopping/types/total.json", + "upstream": "source/schemas/shopping/types/total.json" + }, + { + "local": "protocol/schemas/shopping/types/totals.json", + "upstream": "source/schemas/shopping/types/totals.json" + }, + { + "local": "protocol/schemas/transports/embedded_config.json", + "upstream": "source/schemas/transports/embedded_config.json" + }, + { + "local": "protocol/schemas/ucp.json", + "upstream": "source/schemas/ucp.json" + }, + { + "local": "protocol/services/shopping/embedded.openrpc.json", + "upstream": "source/services/shopping/embedded.openrpc.json" + } + ] +} From 95b7be791224bb6e3c859f9089e4f0252847c49f Mon Sep 17 00:00:00 2001 From: Westin Wrzesinski Date: Mon, 25 May 2026 11:53:00 -0500 Subject: [PATCH 2/2] chore(protocol): share UCP snapshot check and update flow Move snapshot comparison into a shared script with check and update modes, add an update wrapper and dev command, and document the refresh workflow. The update flow resolves refs to exact upstream commits and supports dry-run. --- dev.yml | 3 + protocol/scripts/check_ucp_snapshot.sh | 145 +------ protocol/scripts/ucp_snapshot.mjs | 492 ++++++++++++++++++++++++ protocol/scripts/ucp_snapshot.sh | 25 ++ protocol/scripts/update_ucp_snapshot.sh | 25 ++ 5 files changed, 547 insertions(+), 143 deletions(-) create mode 100755 protocol/scripts/ucp_snapshot.mjs create mode 100755 protocol/scripts/ucp_snapshot.sh create mode 100755 protocol/scripts/update_ucp_snapshot.sh diff --git a/dev.yml b/dev.yml index 5661096d..0ca0c43d 100644 --- a/dev.yml +++ b/dev.yml @@ -102,6 +102,9 @@ commands: check-upstream: desc: "Compare vendored UCP snapshot files against upstream" run: ./protocol/scripts/check_ucp_snapshot.sh "$@" + update-upstream: + desc: "Update vendored UCP snapshot files from upstream" + run: ./protocol/scripts/update_ucp_snapshot.sh "$@" apollo: subcommands: download_schema: diff --git a/protocol/scripts/check_ucp_snapshot.sh b/protocol/scripts/check_ucp_snapshot.sh index 4b0c4498..bedf0289 100755 --- a/protocol/scripts/check_ucp_snapshot.sh +++ b/protocol/scripts/check_ucp_snapshot.sh @@ -21,146 +21,5 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. set -euo pipefail -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -LOCK_FILE="${REPO_ROOT}/protocol/source-lock.json" - -usage() { - cat <<'EOF' -Usage: check_ucp_snapshot.sh [--ref ] - -Compares the vendored UCP snapshot files in protocol/source-lock.json with -Universal-Commerce-Protocol/ucp. The check does not modify local files. - -Options: - --ref Upstream branch, tag, or commit to compare against. - Defaults to upstreamCommit from the lock file. - -h, --help Show this help message. -EOF -} - -REF="" -while [[ $# -gt 0 ]]; do - case "$1" in - --ref) - if [[ $# -lt 2 || -z "$2" ]]; then - echo "Missing value for --ref" >&2 - usage >&2 - exit 2 - fi - REF="$2" - shift 2 - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "Unknown argument: $1" >&2 - usage >&2 - exit 2 - ;; - esac -done - -missing_tools=() -for tool in gh jq diff mktemp; do - if ! command -v "$tool" >/dev/null 2>&1; then - missing_tools+=("$tool") - fi -done - -if [[ ${#missing_tools[@]} -gt 0 ]]; then - echo "Missing required tools: ${missing_tools[*]}" >&2 - exit 2 -fi - -if [[ ! -f "$LOCK_FILE" ]]; then - echo "Missing lock file: $LOCK_FILE" >&2 - exit 2 -fi - -if ! jq -e ' - (.protocolVersion | type == "string") and - (.upstreamRepository | type == "string") and - (.upstreamCommit | type == "string") and - (.files | type == "array") and - all(.files[]; (.local | type == "string") and (.upstream | type == "string")) -' "$LOCK_FILE" >/dev/null; then - echo "Invalid lock file shape: $LOCK_FILE" >&2 - exit 2 -fi - -UPSTREAM_REPO="$(jq -r '.upstreamRepository' "$LOCK_FILE")" - -if [[ -z "$REF" ]]; then - REF="$(jq -r '.upstreamCommit // "main"' "$LOCK_FILE")" -fi - -TMP_DIR="$(mktemp -d)" -cleanup() { - rm -rf "$TMP_DIR" -} -trap cleanup EXIT - -drift=0 -tool_error=0 -checked=0 - -echo "Comparing vendored UCP snapshot with ${UPSTREAM_REPO}@${REF}" - -while IFS=$'\t' read -r local_path upstream_path; do - checked=$((checked + 1)) - local_file="${REPO_ROOT}/${local_path}" - upstream_file="${TMP_DIR}/upstream-${checked}" - upstream_error="${TMP_DIR}/upstream-${checked}.err" - - if [[ ! -f "$local_file" ]]; then - echo "Missing local file: ${local_path}" >&2 - drift=1 - continue - fi - - endpoint="repos/${UPSTREAM_REPO}/contents/${upstream_path}?ref=${REF}" - if ! gh api -H "Accept: application/vnd.github.raw" "$endpoint" >"$upstream_file" 2>"$upstream_error"; then - echo "Missing or unreadable upstream file: ${upstream_path} at ${REF}" >&2 - if [[ -s "$upstream_error" ]]; then - while IFS= read -r error_line; do - echo " ${error_line}" >&2 - done < "$upstream_error" - fi - drift=1 - continue - fi - - diff_rc=0 - diff -u -L "$local_path" -L "${upstream_path}@${REF}" "$local_file" "$upstream_file" || diff_rc=$? - case "$diff_rc" in - 0) - ;; - 1) - drift=1 - ;; - *) - echo "diff error comparing ${local_path} with ${upstream_path}@${REF}" >&2 - tool_error=1 - ;; - esac -done < <(jq -r '.files[] | [.local, .upstream] | @tsv' "$LOCK_FILE") - -if [[ "$checked" -eq 0 ]]; then - echo "No files listed in $LOCK_FILE" >&2 - exit 2 -fi - -if [[ "$tool_error" -ne 0 ]]; then - echo "UCP snapshot comparison failed due to tooling errors." >&2 - exit 2 -fi - -if [[ "$drift" -eq 0 ]]; then - echo "UCP snapshot matches ${UPSTREAM_REPO}@${REF} (${checked} files)." -else - echo "UCP snapshot drift detected against ${UPSTREAM_REPO}@${REF} (${checked} files checked)." >&2 -fi - -exit "$drift" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "${SCRIPT_DIR}/ucp_snapshot.sh" check "$@" diff --git a/protocol/scripts/ucp_snapshot.mjs b/protocol/scripts/ucp_snapshot.mjs new file mode 100755 index 00000000..d7612160 --- /dev/null +++ b/protocol/scripts/ucp_snapshot.mjs @@ -0,0 +1,492 @@ +#!/usr/bin/env node +/* + * MIT License + * + * Copyright 2023-present, Shopify Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import {spawn} from "node:child_process"; +import {constants as fsConstants} from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import {fileURLToPath} from "node:url"; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(SCRIPT_DIR, "..", ".."); +const LOCK_FILE = path.join(REPO_ROOT, "protocol", "source-lock.json"); + +const USAGE = `Usage: + ucp_snapshot.sh check [--ref ] + ucp_snapshot.sh update --ref [--dry-run] + +Commands: + check Compare vendored UCP files with upstream. Does not modify files. + update Download vendored UCP files and record the resolved upstream commit. + +Options: + --ref Upstream branch, tag, or commit to use. + For check, defaults to the lock-file commit. For update, + this option is required. + --dry-run For update, show what would change without writing. + -h, --help Show this help message. +`; + +class UsageError extends Error {} + +class ExitError extends Error { + constructor(code, message = "") { + super(message); + this.code = code; + } +} + +function usage(stream = process.stdout) { + stream.write(USAGE); +} + +function parseArgs(argv) { + if (argv.length === 0) { + throw new UsageError(""); + } + + if (argv[0] === "-h" || argv[0] === "--help") { + return {help: true}; + } + + const mode = argv[0]; + let ref = ""; + let dryRun = false; + + for (let index = 1; index < argv.length;) { + const arg = argv[index]; + switch (arg) { + case "--ref": + if (index + 1 >= argv.length || argv[index + 1] === "") { + throw new UsageError("Missing value for --ref"); + } + ref = argv[index + 1]; + index += 2; + break; + case "--dry-run": + dryRun = true; + index += 1; + break; + case "-h": + case "--help": + return {help: true}; + default: + throw new UsageError(`Unknown argument: ${arg}`); + } + } + + return {mode, ref, dryRun}; +} + +async function executableExists(name) { + const paths = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean); + for (const searchPath of paths) { + try { + await fs.access(path.join(searchPath, name), fsConstants.X_OK); + return true; + } catch { + // Try the next PATH entry. + } + } + return false; +} + +async function requireTools(...tools) { + const missing = []; + for (const tool of tools) { + if (!(await executableExists(tool))) { + missing.push(tool); + } + } + + if (missing.length > 0) { + throw new ExitError(2, `Missing required tools: ${missing.join(" ")}`); + } +} + +function run(command, args) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: REPO_ROOT, + stdio: ["ignore", "pipe", "pipe"], + }); + + const stdout = []; + const stderr = []; + + child.stdout.on("data", (chunk) => stdout.push(chunk)); + child.stderr.on("data", (chunk) => stderr.push(chunk)); + child.on("error", reject); + child.on("close", (code) => { + resolve({ + code, + stdout: Buffer.concat(stdout), + stderr: Buffer.concat(stderr), + }); + }); + }); +} + +function printIndented(buffer) { + const text = buffer.toString("utf8").replace(/\n$/, ""); + if (text === "") { + return; + } + + for (const line of text.split("\n")) { + console.error(` ${line}`); + } +} + +async function readJson(file) { + return JSON.parse(await fs.readFile(file, "utf8")); +} + +async function writeJson(file, value) { + await fs.writeFile(file, `${JSON.stringify(value, null, 2)}\n`); +} + +function validateLockFile(lock) { + const valid = lock !== null && + typeof lock === "object" && + typeof lock.protocolVersion === "string" && + typeof lock.upstreamRepository === "string" && + typeof lock.upstreamCommit === "string" && + Array.isArray(lock.files) && + lock.files.every((file) => file !== null && + typeof file === "object" && + typeof file.local === "string" && + typeof file.upstream === "string"); + + if (!valid) { + throw new ExitError(2, `Invalid lock file shape: ${LOCK_FILE}`); + } +} + +async function loadLockFile() { + try { + const lock = await readJson(LOCK_FILE); + validateLockFile(lock); + return lock; + } catch (error) { + if (error instanceof ExitError) { + throw error; + } + if (error.code === "ENOENT") { + throw new ExitError(2, `Missing lock file: ${LOCK_FILE}`); + } + throw new ExitError(2, `Unable to read lock file: ${LOCK_FILE}`); + } +} + +function encodeRef(ref) { + return encodeURIComponent(ref); +} + +async function resolveRef(lock, ref) { + const encodedRef = encodeRef(ref); + const result = await run("gh", ["api", `repos/${lock.upstreamRepository}/commits/${encodedRef}`]); + + if (result.code !== 0) { + console.error(`Unable to resolve ${lock.upstreamRepository}@${ref} to a commit.`); + printIndented(result.stderr); + throw new ExitError(2); + } + + try { + const response = JSON.parse(result.stdout.toString("utf8")); + if (typeof response.sha !== "string" || response.sha === "") { + throw new Error("Missing sha"); + } + return response.sha; + } catch { + throw new ExitError(2, `Unable to parse resolved commit for ${lock.upstreamRepository}@${ref}.`); + } +} + +async function fetchUpstreamFiles(lock, ref, tempDir) { + const encodedRef = encodeRef(ref); + const files = []; + let fetchFailed = false; + let checked = 0; + + for (const file of lock.files) { + checked += 1; + const upstreamFile = path.join(tempDir, `upstream-${checked}`); + const endpoint = `repos/${lock.upstreamRepository}/contents/${file.upstream}?ref=${encodedRef}`; + const result = await run("gh", ["api", "-H", "Accept: application/vnd.github.raw", endpoint]); + + if (result.code !== 0) { + console.error(`Missing or unreadable upstream file: ${file.upstream} at ${ref}`); + printIndented(result.stderr); + fetchFailed = true; + continue; + } + + await fs.writeFile(upstreamFile, result.stdout); + files.push({ + localPath: file.local, + upstreamPath: file.upstream, + upstreamFile, + }); + } + + if (checked === 0) { + throw new ExitError(2, `No files listed in ${LOCK_FILE}`); + } + + return {files, fileCount: checked, fetchFailed}; +} + +async function isFile(file) { + try { + return (await fs.stat(file)).isFile(); + } catch (error) { + if (error.code === "ENOENT") { + return false; + } + throw error; + } +} + +async function compareFetchedFiles(files, ref) { + let drift = false; + let toolError = false; + + for (const file of files) { + const localFile = path.join(REPO_ROOT, file.localPath); + + if (!(await isFile(localFile))) { + console.error(`Missing local file: ${file.localPath}`); + drift = true; + continue; + } + + const result = await run("diff", [ + "-u", + "-L", + file.localPath, + "-L", + `${file.upstreamPath}@${ref}`, + localFile, + file.upstreamFile, + ]); + + process.stdout.write(result.stdout); + process.stderr.write(result.stderr); + + if (result.code === 0) { + continue; + } + + if (result.code === 1) { + drift = true; + continue; + } + + console.error(`diff error comparing ${file.localPath} with ${file.upstreamPath}@${ref}`); + toolError = true; + } + + if (toolError) { + console.error("UCP snapshot comparison failed due to tooling errors."); + throw new ExitError(2); + } + + return drift; +} + +function checkLockCommitChange(lock, resolvedCommit) { + const currentCommit = lock.upstreamCommit; + if (currentCommit === resolvedCommit) { + return false; + } + + console.log("Would update protocol/source-lock.json upstreamCommit:"); + console.log(` from: ${currentCommit || "null"}`); + console.log(` to: ${resolvedCommit}`); + return true; +} + +async function updateLockCommit(lock, resolvedCommit) { + lock.upstreamCommit = resolvedCommit; + await writeJson(LOCK_FILE, lock); +} + +async function filesEqual(localFile, upstreamFile) { + try { + const [local, upstream] = await Promise.all([ + fs.readFile(localFile), + fs.readFile(upstreamFile), + ]); + return Buffer.compare(local, upstream) === 0; + } catch (error) { + if (error.code === "ENOENT") { + return false; + } + throw error; + } +} + +async function withTempDir(callback) { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "checkout-kit-ucp-snapshot-")); + try { + return await callback(tempDir); + } finally { + await fs.rm(tempDir, {recursive: true, force: true}); + } +} + +async function runCheck(options) { + await requireTools("gh", "diff"); + const lock = await loadLockFile(); + const ref = options.ref || lock.upstreamCommit; + + return withTempDir(async (tempDir) => { + console.log(`Comparing vendored UCP snapshot with ${lock.upstreamRepository}@${ref}`); + + const fetched = await fetchUpstreamFiles(lock, ref, tempDir); + let drift = fetched.fetchFailed; + if (await compareFetchedFiles(fetched.files, ref)) { + drift = true; + } + + if (drift) { + console.error(`UCP snapshot drift detected against ${lock.upstreamRepository}@${ref} (${fetched.fileCount} files checked).`); + return 1; + } + + console.log(`UCP snapshot matches ${lock.upstreamRepository}@${ref} (${fetched.fileCount} files).`); + return 0; + }); +} + +async function runUpdate(options) { + await requireTools("gh", "diff"); + const lock = await loadLockFile(); + + if (options.ref === "") { + console.error("Update requires --ref ."); + usage(process.stderr); + return 2; + } + + return withTempDir(async (tempDir) => { + const resolvedCommit = await resolveRef(lock, options.ref); + + console.log(`Downloading vendored UCP snapshot from ${lock.upstreamRepository}@${options.ref}`); + console.log(`Resolved upstream commit: ${resolvedCommit}`); + + const fetched = await fetchUpstreamFiles(lock, options.ref, tempDir); + if (fetched.fetchFailed) { + console.error("UCP snapshot update aborted; no local files were changed."); + return 1; + } + + if (options.dryRun) { + let drift = false; + if (await compareFetchedFiles(fetched.files, options.ref)) { + drift = true; + } + if (checkLockCommitChange(lock, resolvedCommit)) { + drift = true; + } + + if (drift) { + console.error(`UCP snapshot would change for ${lock.upstreamRepository}@${resolvedCommit} (${fetched.fileCount} files checked).`); + return 1; + } + + console.log(`UCP snapshot already matches ${lock.upstreamRepository}@${resolvedCommit} (${fetched.fileCount} files).`); + return 0; + } + + let updatedFiles = 0; + for (const file of fetched.files) { + const localFile = path.join(REPO_ROOT, file.localPath); + await fs.mkdir(path.dirname(localFile), {recursive: true}); + + if (!(await filesEqual(localFile, file.upstreamFile))) { + await fs.copyFile(file.upstreamFile, localFile); + console.log(`Updated ${file.localPath}`); + updatedFiles += 1; + } + } + + if (lock.upstreamCommit !== resolvedCommit) { + await updateLockCommit(lock, resolvedCommit); + console.log("Updated protocol/source-lock.json upstreamCommit"); + } + + console.log(`UCP snapshot update complete (${updatedFiles} files changed, ${fetched.fileCount} files checked).`); + return 0; + }); +} + +async function runCli(argv) { + const options = parseArgs(argv); + if (options.help) { + usage(); + return 0; + } + + switch (options.mode) { + case "check": + if (options.dryRun) { + throw new ExitError(2, "--dry-run is only supported for update."); + } + return runCheck(options); + case "update": + return runUpdate(options); + default: + throw new UsageError(`Unknown command: ${options.mode}`); + } +} + +runCli(process.argv.slice(2)) + .then((code) => { + process.exitCode = code; + }) + .catch((error) => { + if (error instanceof UsageError) { + if (error.message !== "") { + console.error(error.message); + } + usage(process.stderr); + process.exitCode = 2; + return; + } + + if (error instanceof ExitError) { + if (error.message !== "") { + console.error(error.message); + } + process.exitCode = error.code; + return; + } + + console.error(error.message); + process.exitCode = 2; + }); diff --git a/protocol/scripts/ucp_snapshot.sh b/protocol/scripts/ucp_snapshot.sh new file mode 100755 index 00000000..4cbc4785 --- /dev/null +++ b/protocol/scripts/ucp_snapshot.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# MIT License +# +# Copyright 2023-present, Shopify Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec node "${SCRIPT_DIR}/ucp_snapshot.mjs" "$@" diff --git a/protocol/scripts/update_ucp_snapshot.sh b/protocol/scripts/update_ucp_snapshot.sh new file mode 100755 index 00000000..c13f7d7a --- /dev/null +++ b/protocol/scripts/update_ucp_snapshot.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# MIT License +# +# Copyright 2023-present, Shopify Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "${SCRIPT_DIR}/ucp_snapshot.sh" update "$@"