From 8a1201475a8b0718774aaa7f80a98bb8468c1dc3 Mon Sep 17 00:00:00 2001 From: Mayank Date: Sat, 10 Oct 2026 10:46:23 +0530 Subject: [PATCH 1/2] Add checks for what has fallen behind npm run check:updates compares what the repository pins with what is current: npm dependency ranges in the CLI and both templates, the Shopify API version, the Functions Node runtime, the GitHub Actions the workflows use, and new majors of the Firebase and Shopify CLIs. It names the lines to change and marks each finding auto, optional, manual or unknown. It reads only npm's registry and raw.githubusercontent.com, which a restricted network can still reach. The Shopify version comes from the quarterly calendar, confirmed against Shopify's own library, because Shopify's version table lists scheduled versions next to shipped ones. npm run check:graphql validates every query the templates send against Shopify's real schema for the version they pin, or for one passed with --version. The templates' tests stub Shopify, so nothing else would notice a field that a new API version removed. It also lists deprecated fields. The tests work out their expectations from whatever the repository pins, so they keep passing after a version is raised. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 13 +++ package.json | 7 +- test/graphql.test.js | 68 ++++++++++++ test/updates.test.js | 207 +++++++++++++++++++++++++++++++++++ tools/check-graphql.js | 89 +++++++++++++++ tools/check-updates.js | 242 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 625 insertions(+), 1 deletion(-) create mode 100644 test/graphql.test.js create mode 100644 test/updates.test.js create mode 100644 tools/check-graphql.js create mode 100644 tools/check-updates.js diff --git a/package-lock.json b/package-lock.json index bc08d2b..b487da3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,10 +14,23 @@ "bin": { "nitrogen": "bin/create.js" }, + "devDependencies": { + "graphql": "^17.0.2" + }, "engines": { "node": ">=18.0.0" } }, + "node_modules/graphql": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-17.0.2.tgz", + "integrity": "sha512-FRWbddMxfkjiB7z+aQDWIR+E34xo9I8c9mtK2RPv8PmMzKRvrdsreHL/Ui/TmwHJfhHChEtsFPyMHKI+xuarQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.0.0 || ^24.0.0 || ^25.0.0 || >=26.0.0" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", diff --git a/package.json b/package.json index c6b6152..f37154d 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,9 @@ "scripts": { "test": "node --test \"test/*.test.js\"", "test:e2e": "node -e \"process.env.NITROGEN_E2E='1';require('child_process').execSync('npm test',{stdio:'inherit'})\"", - "build:reference": "node tools/build-reference.js" + "build:reference": "node tools/build-reference.js", + "check:updates": "node tools/check-updates.js", + "check:graphql": "node tools/check-graphql.js" }, "publishConfig": { "access": "public" @@ -63,5 +65,8 @@ }, "dependencies": { "prompts": "^2.4.2" + }, + "devDependencies": { + "graphql": "^17.0.2" } } diff --git a/test/graphql.test.js b/test/graphql.test.js new file mode 100644 index 0000000..c0ca0f4 --- /dev/null +++ b/test/graphql.test.js @@ -0,0 +1,68 @@ +// The schema check: every query the templates send must exist in the Shopify +// API version they pin. These tests use a small local schema; the real check +// (npm run check:graphql) fetches Shopify's. + +import assert from "node:assert/strict"; +import path from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { buildSchema, introspectionFromSchema } from "graphql"; +import { documents, problems, schemaFrom } from "../tools/check-graphql.js"; + +const TEMPLATES = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "templates"); + +const schema = buildSchema(` + type Query { shop: Shop product(id: ID!): Product } + type Shop { name: String tier: String plan: String @deprecated(reason: "Use tier instead.") } + type Product { id: ID! title: String } +`); + +test("a query that fits the schema has no problems", () => { + assert.deepEqual(problems(schema, "query ($id: ID!) { shop { name tier } product(id: $id) { title } }"), { errors: [], deprecated: [] }); +}); + +test("a field the schema does not have is an error", () => { + const { errors } = problems(schema, "{ shop { name owner } }"); + assert.equal(errors.length, 1); + assert.match(errors[0], /owner/); +}); + +test("a deprecated field still works, and is reported with its replacement", () => { + const { errors, deprecated } = problems(schema, "{ shop { plan } }"); + assert.deepEqual(errors, []); + assert.equal(deprecated.length, 1); + assert.match(deprecated[0], /plan/); + assert.match(deprecated[0], /Use tier instead/); +}); + +test("a query that does not parse is an error, not a crash", () => { + const { errors } = problems(schema, "{ shop { name "); + assert.equal(errors.length, 1); +}); + +test("a real service's schema is used as it is, even where a strict reading rejects it", () => { + // Shopify's schema deprecates fields on a type whose interface does not, + // which graphql refuses to validate against unless told the schema is real + const quirky = buildSchema( + `interface Node { id: ID! } + type Thing implements Node { id: ID! @deprecated(reason: "Old.") name: String } + type Query { thing: Thing }`, + { assumeValid: true }, + ); + const fromService = schemaFrom(introspectionFromSchema(quirky)); + assert.deepEqual(problems(fromService, "{ thing { name } }"), { errors: [], deprecated: [] }); + assert.equal(problems(fromService, "{ thing { missing } }").errors.length, 1); +}); + +test("the same queries are found in both templates, and none in comments", () => { + const js = documents(path.join(TEMPLATES, "js", "functions", "src")); + const ts = documents(path.join(TEMPLATES, "ts", "functions", "src")); + assert.ok(js.length >= 3, `found only ${js.length} queries`); + const text = (list) => list.map((document) => document.source.replace(/\s+/g, " ").trim()).sort(); + assert.deepEqual(text(ts), text(js)); + // shopify.js shows `{ ... }` in a comment as an example; that is not a query + for (const document of [...js, ...ts]) { + assert.ok(!document.source.includes("..."), `${document.file}:${document.line} is a comment example`); + assert.match(document.file, /^api\//); + } +}); diff --git a/test/updates.test.js b/test/updates.test.js new file mode 100644 index 0000000..8aed568 --- /dev/null +++ b/test/updates.test.js @@ -0,0 +1,207 @@ +// The weekly update check: what counts as behind, and which findings may be +// applied without a person. +// +// The routine that acts on these findings may not edit tests, so nothing here +// names a version the repository pins today. Expectations are worked out +// from whatever it pins when the test runs. + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { check, rangeStatus, report, runtimes, shopifyVersions, SOURCES, TESTED_TOOLS, workflowActions } from "../tools/check-updates.js"; + +const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); +const read = (file) => fs.readFileSync(path.join(ROOT, file), "utf8"); +const json = (file) => JSON.parse(read(file)); + +test("a caret range is current, covered, or left behind by a new major", () => { + assert.equal(rangeStatus("^7.4.0", "7.4.0"), "current"); + assert.equal(rangeStatus("^7.4.0", "7.5.2"), "covered"); + assert.equal(rangeStatus("^7.4.0", "8.0.0"), "major"); + // Below 1.0 a caret pins the minor + assert.equal(rangeStatus("^0.3.1", "0.3.9"), "covered"); + assert.equal(rangeStatus("^0.3.1", "0.4.0"), "major"); + for (const other of ["~1.2.0", "1.2.3", ">=1", "*", "latest"]) { + assert.equal(rangeStatus(other, "9.9.9"), "unknown", other); + } +}); + +test("the Shopify version is the newest one the calendar says has shipped", () => { + const listed = "July26 = '2026-07',\n October26 = '2026-10',\n January27 = '2027-01',"; + // Shopify lists scheduled versions next to shipped ones: the date decides + assert.deepEqual(shopifyVersions(new Date("2026-09-30T23:00:00Z"), listed), { due: "2026-07", latest: "2026-07" }); + assert.deepEqual(shopifyVersions(new Date("2026-10-01T00:00:00Z"), listed), { due: "2026-10", latest: "2026-10" }); + assert.deepEqual(shopifyVersions(new Date("2027-01-15T00:00:00Z"), listed), { due: "2027-01", latest: "2027-01" }); + // Due by the calendar, but Shopify's own library does not have it yet + assert.deepEqual(shopifyVersions(new Date("2027-04-02T00:00:00Z"), listed), { due: "2027-04", latest: "2027-01" }); +}); + +test("Firebase's runtime table is read for status and deprecation date", () => { + const table = ` + nodejs22: { + friendly: "Node.js 22", + status: "GA", + deprecationDate: "2027-04-30", + decommissionDate: "2028-10-31", + }, + nodejs24: { friendly: "Node.js 24", status: "GA", deprecationDate: "2028-04-30" }, + nodejs26: { friendly: "Node.js 26", status: "beta", deprecationDate: "2030-04-30" }, + python313: { friendly: "Python 3.13", status: "GA", deprecationDate: "2029-10-10" },`; + assert.deepEqual(runtimes(table), [ + { node: 22, status: "GA", deprecationDate: "2027-04-30" }, + { node: 24, status: "GA", deprecationDate: "2028-04-30" }, + { node: 26, status: "beta", deprecationDate: "2030-04-30" }, + ]); +}); + +test("the actions a workflow uses are read with their major", () => { + const workflow = "steps:\n - uses: actions/checkout@v7\n - uses: actions/setup-node@v7.1.0\n with:\n node-version: 24\n - run: npm ci\n"; + assert.deepEqual(workflowActions(workflow), [ + { action: "actions/checkout", major: 7, line: 2 }, + { action: "actions/setup-node", major: 7, line: 3 }, + ]); +}); + +// ─── The whole check, against this repository with every source faked ──── +const pinnedApi = read("templates/ts/functions/src/shopify.ts").match(/API_VERSION = "(\d{4}-\d{2})"/)[1]; +const pinnedNode = Number(json("templates/shared/firebase.json").functions[0].runtime.replace("nodejs", "")); + +function nextQuarter(version) { + const [year, month] = version.split("-").map(Number); + return month === 10 ? `${year + 1}-01` : `${year}-${String(month + 3).padStart(2, "0")}`; +} + +const floors = {}; +for (const file of ["package.json", "templates/js/functions/package.json", "templates/ts/functions/package.json"]) { + const pkg = json(file); + for (const [name, range] of Object.entries({ ...pkg.dependencies, ...pkg.devDependencies })) { + floors[name] = range.replace(/^\^/, ""); + } +} + +// A world in which nothing is behind. `changes` overrides one fact at a time. +function world(changes = {}) { + const facts = { + today: new Date(`${pinnedApi}-20T00:00:00Z`), + npm: withNpm({}), + shopify: `'${pinnedApi}', '${nextQuarter(pinnedApi)}'`, + runtimes: `nodejs${pinnedNode}: { status: "GA", deprecationDate: "2099-01-01" },`, + actionMajor: null, // null: whatever each workflow already uses + ...changes, + }; + const used = Object.fromEntries( + fs.readdirSync(path.join(ROOT, ".github", "workflows")).flatMap((file) => + workflowActions(read(`.github/workflows/${file}`)).map(({ action, major }) => [action, major]), + ), + ); + const fetchText = async (url) => { + if (url === SOURCES.shopifyVersions) return facts.shopify; + if (url === SOURCES.firebaseRuntimes) return facts.runtimes; + const npm = url.match(/^https:\/\/registry\.npmjs\.org\/(.+)\/latest$/); + if (npm) { + const name = decodeURIComponent(npm[1]); + if (!(name in facts.npm)) throw new Error(`unexpected package ${name}`); + return JSON.stringify({ version: facts.npm[name] }); + } + const action = url.match(/^https:\/\/raw\.githubusercontent\.com\/([^/]+\/[^/]+)\/HEAD\/package\.json$/); + if (action) return JSON.stringify({ version: `${facts.actionMajor ?? used[action[1]]}.0.0` }); + throw new Error(`unexpected request ${url}`); + }; + return { root: ROOT, today: facts.today, fetchText }; +} + +const kinds = (findings) => findings.map((finding) => finding.kind); + +test("with every source level with the repository, there is nothing to update", async () => { + const findings = await check(world()); + assert.deepEqual(findings, []); + assert.match(report(findings), /Nothing to update/); +}); + +test("a newer Shopify API version is an auto finding that lists every line to change", async () => { + const next = nextQuarter(pinnedApi); + const findings = await check(world({ today: new Date(`${next}-09T00:00:00Z`) })); + assert.deepEqual(kinds(findings), ["auto"]); + const [finding] = findings; + assert.match(finding.what, new RegExp(`${pinnedApi}.*${next}`)); + const files = new Set(finding.where.map((place) => place.file)); + for (const file of [ + "templates/js/functions/src/shopify.js", + "templates/ts/functions/src/shopify.ts", + "templates/shopify.app.toml", + "templates/shared/extensions/theme-block/shopify.extension.toml", + "README.md", + "lib/index.js", + ]) { + assert.ok(files.has(file), `${file} is not listed`); + } + // The README badge spells the version with a doubled dash + const badge = finding.where.find((place) => place.file === "README.md" && read("README.md").split("\n")[place.line - 1].includes("img.shields.io")); + assert.ok(badge, "the README badge line is not listed"); + assert.ok(!files.has("CHANGELOG.md"), "history is not rewritten"); + assert.match(report(findings), /^auto\b/m); +}); + +test("a version due by the calendar that Shopify has not listed is unknown, not an update", async () => { + const next = nextQuarter(pinnedApi); + const findings = await check(world({ today: new Date(`${next}-09T00:00:00Z`), shopify: `'${pinnedApi}'` })); + assert.deepEqual(kinds(findings), ["unknown"]); +}); + +test("a new major of a dependency is manual; a newer version in range is optional", async () => { + const [major, minor] = floors["firebase-functions"].split(".").map(Number); + const breaking = await check(world({ npm: withNpm({ "firebase-functions": `${major + 1}.0.0` }) })); + assert.deepEqual(kinds(breaking), ["manual"]); + assert.deepEqual( + breaking[0].where.map((place) => place.file).sort(), + ["templates/js/functions/package.json", "templates/ts/functions/package.json"], + ); + + const inRange = await check(world({ npm: withNpm({ "firebase-functions": `${major}.${minor + 1}.0` }) })); + assert.deepEqual(kinds(inRange), ["optional"]); + // On its own an optional finding is not a reason to release + assert.match(report(inRange), /Nothing to update/); +}); + +function withNpm(overrides) { + return { ...floors, "firebase-tools": `${TESTED_TOOLS["firebase-tools"]}.1.0`, "@shopify/cli": `${TESTED_TOOLS["@shopify/cli"]}.2.0`, ...overrides }; +} + +test("a newer runtime, a retiring runtime, a newer action and a newer tool major are all manual", async () => { + const newer = await check(world({ runtimes: `nodejs${pinnedNode}: { status: "GA", deprecationDate: "2099-01-01" },\n nodejs${pinnedNode + 2}: { status: "GA", deprecationDate: "2099-01-01" },` })); + assert.deepEqual(kinds(newer), ["manual"]); + assert.ok(newer[0].where.some((place) => place.file === "templates/shared/firebase.json")); + + // A runtime that is not GA yet is not an update + const beta = await check(world({ runtimes: `nodejs${pinnedNode}: { status: "GA", deprecationDate: "2099-01-01" },\n nodejs${pinnedNode + 2}: { status: "beta", deprecationDate: "2099-01-01" },` })); + assert.deepEqual(beta, []); + + const facts = world(); + const soon = new Date(facts.today.getTime() + 60 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + const retiring = await check(world({ runtimes: `nodejs${pinnedNode}: { status: "GA", deprecationDate: "${soon}" },` })); + assert.deepEqual(kinds(retiring), ["manual"]); + + const action = await check(world({ actionMajor: 99 })); + assert.ok(action.length >= 1); + assert.ok(action.every((finding) => finding.kind === "manual" && finding.where.every((place) => place.file.startsWith(".github/workflows/")))); + + const tool = await check(world({ npm: withNpm({ "firebase-tools": `${TESTED_TOOLS["firebase-tools"] + 1}.0.0` }) })); + assert.deepEqual(kinds(tool), ["manual"]); + assert.deepEqual(tool[0].where, []); +}); + +test("a source that cannot be reached is unknown, never up to date", async () => { + const facts = world(); + const findings = await check({ + ...facts, + fetchText: async (url) => { + if (url === SOURCES.firebaseRuntimes) throw new Error("HTTP 403"); + return facts.fetchText(url); + }, + }); + assert.deepEqual(kinds(findings), ["unknown"]); + assert.match(findings[0].detail, /HTTP 403/); + assert.doesNotMatch(report(findings), /Nothing to update/); +}); diff --git a/tools/check-graphql.js b/tools/check-graphql.js new file mode 100644 index 0000000..ee01511 --- /dev/null +++ b/tools/check-graphql.js @@ -0,0 +1,89 @@ +// Checks every GraphQL query the templates send against the schema of the +// Shopify API version they pin: +// +// npm run check:graphql the pinned version +// npm run check:graphql -- --version=2026-10 a version you are moving to +// +// The templates' own tests stub Shopify, so they cannot notice a field that a +// new API version removed. This can: it reads the real schema from the public +// endpoint Shopify's code generator uses. A query that no longer fits fails +// the check; a deprecated field is listed so it can be replaced in time. +// +// Needs shopify.dev, which a restricted network may not reach. The Test +// workflow runs it on every pull request, where the network is open. + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { buildClientSchema, getIntrospectionQuery, NoDeprecatedCustomRule, parse, validate } from "graphql"; + +const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); +const SCHEMA_URL = "https://shopify.dev/admin-graphql-direct-proxy"; + +/** The queries passed to graphql(ctx, `...`) under `dir`, with where each is. */ +export function documents(dir, base = dir) { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return documents(full, base); + if (!/\.(js|ts)$/.test(entry.name)) return []; + const text = fs.readFileSync(full, "utf8"); + return [...text.matchAll(/graphql\(\s*ctx,\s*`([^`]*)`/g)].flatMap((match) => { + const before = text.slice(0, match.index); + // An example in a comment is not a query the app sends + if (/^\s*(\*|\/\/)/.test(before.slice(before.lastIndexOf("\n") + 1))) return []; + return [{ file: path.relative(base, full).replaceAll("\\", "/"), line: before.split("\n").length, source: match[1] }]; + }); + }); +} + +/** What is wrong with one query: `errors` break it, `deprecated` will later. */ +export function problems(schema, source) { + let document; + try { + document = parse(source); + } catch (error) { + return { errors: [error.message], deprecated: [] }; + } + const messages = (rules) => validate(schema, document, rules).map((error) => error.message); + return { errors: messages(), deprecated: messages([NoDeprecatedCustomRule]) }; +} + +/** A schema to check queries against, from a service's introspection result. */ +export function schemaFrom(introspection) { + // Shopify's schema deprecates some fields that an interface it implements + // does not, which graphql rejects as an invalid schema. It is the schema the + // API really serves, so it is taken as it is. + return buildClientSchema(introspection, { assumeValid: true }); +} + +async function shopifySchema(version) { + const response = await fetch(`${SCHEMA_URL}/${version}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ query: getIntrospectionQuery() }), + signal: AbortSignal.timeout(60000), + }); + if (!response.ok) throw new Error(`${SCHEMA_URL}/${version} answered HTTP ${response.status}`); + return schemaFrom((await response.json()).data); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const pinned = fs.readFileSync(path.join(ROOT, "templates/ts/functions/src/shopify.ts"), "utf8").match(/API_VERSION = "(\d{4}-\d{2})"/)[1]; + const version = process.argv.find((arg) => arg.startsWith("--version="))?.slice(10) || pinned; + const schema = await shopifySchema(version); + + let broken = 0; + let checked = 0; + for (const language of ["js", "ts"]) { + for (const { file, line, source } of documents(path.join(ROOT, "templates", language, "functions", "src"))) { + const { errors, deprecated } = problems(schema, source); + const where = `templates/${language}/functions/src/${file}:${line}`; + for (const message of errors) console.log(`error ${where} ${message}`); + for (const message of deprecated) console.log(`deprecated ${where} ${message}`); + broken += errors.length; + checked += 1; + } + } + console.log(`${checked} queries checked against Shopify API ${version}: ${broken ? `${broken} broken` : "all valid"}`); + process.exit(broken ? 1 : 0); +} diff --git a/tools/check-updates.js b/tools/check-updates.js new file mode 100644 index 0000000..8129d15 --- /dev/null +++ b/tools/check-updates.js @@ -0,0 +1,242 @@ +// Compares what this repository pins with what is current: +// +// npm run check:updates +// +// - npm dependency ranges, in the CLI and both templates +// - the Shopify API version +// - the Cloud Functions Node runtime +// - the GitHub Actions the workflows use +// - new majors of the Firebase and Shopify CLIs +// +// Each finding names the lines to change and is one of: +// +// auto safe to apply once the tests pass +// optional a range that could be raised; not a reason to release on its own +// manual needs a person: tests cannot prove it works on a real project +// unknown a source could not be read, so nothing is known either way +// +// The weekly routine runs this first (see RELEASING.md). Exit code: 0 when +// there is nothing to do, 1 when there is, 2 when something is unknown. + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); + +// The majors of the tools the CLI drives that it has been run against. A newer +// major may have renamed a command or a flag: try the CLI with it on a real +// project, then raise the number here. +export const TESTED_TOOLS = { "firebase-tools": 15, "@shopify/cli": 4 }; + +// Everything is read from npm's registry or raw.githubusercontent.com, the +// two hosts a restricted network (a cloud agent's, say) can still reach. +export const SOURCES = { + shopifyVersions: "https://raw.githubusercontent.com/Shopify/shopify-app-js/HEAD/packages/apps/shopify-api/lib/types.ts", + firebaseRuntimes: "https://raw.githubusercontent.com/firebase/firebase-tools/HEAD/src/deploy/functions/runtimes/supported/types.ts", +}; + +const PACKAGE_FILES = ["package.json", "templates/js/functions/package.json", "templates/ts/functions/package.json"]; +const API_VERSION_FILE = "templates/ts/functions/src/shopify.ts"; +const FIREBASE_JSON = "templates/shared/firebase.json"; +// Where the API version is written down. CHANGELOG.md is history, not a pin. +const VERSION_MENTIONS = ["README.md", "lib", "templates", "docs", "skills"]; +const TEXT_FILE = /\.(md|js|ts|json|toml|html|txt|liquid|yml)$/; +const RETIRING_WITHIN_DAYS = 180; + +// ─── What counts as behind ─────────────────────────────────────────────── +const numbers = (version) => version.split(".").map(Number); + +function newer(a, b) { + const [x, y] = [numbers(a), numbers(b)]; + for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i]; + return false; +} + +/** Where `latest` stands against a caret range: current, covered, major or unknown. */ +export function rangeStatus(range, latest) { + const floor = range.match(/^\^(\d+\.\d+\.\d+)$/)?.[1]; + if (!floor) return "unknown"; + if (floor === latest) return "current"; + // A caret holds the leftmost part that is not zero + const [have, want] = [numbers(floor), numbers(latest)]; + const held = have.findIndex((part) => part !== 0); + const sameLine = have.slice(0, held + 1).every((part, i) => part === want[i]); + return sameLine && newer(latest, floor) ? "covered" : "major"; +} + +/** + * Shopify ships an API version on the first day of each quarter. Its own + * version table also lists versions that are only scheduled, so the calendar + * decides what is due, and `listed` (the versions Shopify's library knows) + * confirms it exists. + */ +export function shopifyVersions(today, listed) { + const month = [10, 7, 4, 1].find((start) => start <= today.getUTCMonth() + 1); + const due = `${today.getUTCFullYear()}-${String(month).padStart(2, "0")}`; + const shipped = [...listed.matchAll(/['"](\d{4}-\d{2})['"]/g)].map((m) => m[1]).filter((version) => version <= due); + return { due, latest: shipped.sort().at(-1) ?? null }; +} + +/** The Node runtimes in firebase-tools' table of supported runtimes. */ +export function runtimes(source) { + return [...source.matchAll(/nodejs(\d+):\s*\{([^}]*)\}/g)].map(([, node, body]) => ({ + node: Number(node), + status: body.match(/status:\s*"([^"]+)"/)?.[1], + deprecationDate: body.match(/deprecationDate:\s*"([^"]+)"/)?.[1], + })); +} + +/** The actions a workflow file uses, with the major each is held at. */ +export function workflowActions(text) { + return text.split("\n").flatMap((content, i) => { + const used = content.match(/uses:\s*([\w.-]+\/[\w.-]+)@v(\d+)/); + return used ? [{ action: used[1], major: Number(used[2]), line: i + 1 }] : []; + }); +} + +// ─── Reading the repository ────────────────────────────────────────────── +function textFiles(root, entry) { + const full = path.join(root, entry); + if (!fs.existsSync(full)) return []; + if (!fs.statSync(full).isDirectory()) return TEXT_FILE.test(entry) ? [entry] : []; + return fs.readdirSync(full).flatMap((name) => (name === "node_modules" ? [] : textFiles(root, `${entry}/${name}`))); +} + +/** Every line, in the files that record it, that contains one of `needles`. */ +function mentions(root, needles) { + return VERSION_MENTIONS.flatMap((entry) => textFiles(root, entry)).flatMap((file) => + fs.readFileSync(path.join(root, file), "utf8").split("\n").flatMap((content, i) => + needles.some((needle) => content.includes(needle)) ? [{ file, line: i + 1 }] : [], + ), + ); +} + +function lineOf(root, file, needle) { + const index = fs.readFileSync(path.join(root, file), "utf8").split("\n").findIndex((content) => content.includes(needle)); + return { file, line: index + 1 }; +} + +// ─── The check ─────────────────────────────────────────────────────────── +async function fetchOverNetwork(url) { + const response = await fetch(url, { signal: AbortSignal.timeout(20000) }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.text(); +} + +export async function check({ root = ROOT, today = new Date(), fetchText = fetchOverNetwork } = {}) { + const findings = []; + const read = (file) => fs.readFileSync(path.join(root, file), "utf8"); + const latestOnNpm = async (name) => JSON.parse(await fetchText(`https://registry.npmjs.org/${name.replace("/", "%2f")}/latest`)).version; + + // A source that cannot be read leaves that subject unknown, never "fine" + const look = async (subject, body) => { + try { + await body(); + } catch (error) { + findings.push({ kind: "unknown", what: `${subject}: could not be checked`, detail: error.message, where: [] }); + } + }; + + await look("npm dependencies", async () => { + const declared = {}; + for (const file of PACKAGE_FILES) { + const pkg = JSON.parse(read(file)); + for (const [name, range] of Object.entries({ ...pkg.dependencies, ...pkg.devDependencies })) { + (declared[name] ??= { range, where: [] }).where.push(lineOf(root, file, `"${name}"`)); + } + } + for (const [name, { range, where }] of Object.entries(declared)) { + const latest = await latestOnNpm(name); + const status = rangeStatus(range, latest); + if (status === "major") { + findings.push({ kind: "manual", what: `${name}: ${range} does not cover ${latest}, a new major`, detail: `Change to ^${latest}.`, where }); + } else if (status === "covered") { + findings.push({ kind: "optional", what: `${name}: ${range} can be raised to ^${latest}`, detail: "Installs already get it. Raise it only alongside another update.", where }); + } else if (status === "unknown") { + findings.push({ kind: "unknown", what: `${name}: the range ${range} is not a caret range`, detail: `Latest is ${latest}.`, where }); + } + } + }); + + await look("Shopify API version", async () => { + const pinned = read(API_VERSION_FILE).match(/API_VERSION = "(\d{4}-\d{2})"/)[1]; + const { due, latest } = shopifyVersions(today, await fetchText(SOURCES.shopifyVersions)); + if (latest && latest > pinned) { + findings.push({ + kind: "auto", + what: `Shopify API version: ${pinned} -> ${latest}`, + detail: `Replace ${pinned} with ${latest} on each line below. The README badge writes it with a doubled dash.`, + where: mentions(root, [pinned, pinned.replace("-", "--")]), + }); + } + if (due > (latest ?? "") && due > pinned) { + findings.push({ kind: "unknown", what: `Shopify API version: ${due} is due by the calendar`, detail: "Shopify's library does not list it yet, so it stays as it is for now.", where: [] }); + } + }); + + await look("Functions runtime", async () => { + const pinned = Number(JSON.parse(read(FIREBASE_JSON)).functions[0].runtime.replace("nodejs", "")); + const table = runtimes(await fetchText(SOURCES.firebaseRuntimes)); + const mine = table.find((runtime) => runtime.node === pinned); + if (!mine) throw new Error(`nodejs${pinned} is not in Firebase's runtime table`); + const where = [ + lineOf(root, FIREBASE_JSON, `"runtime"`), + ...PACKAGE_FILES.slice(1).map((file) => lineOf(root, file, `"node"`)), + ]; + const newest = Math.max(...table.filter((runtime) => runtime.status === "GA").map((runtime) => runtime.node)); + if (newest > pinned) { + findings.push({ kind: "manual", what: `Functions runtime: nodejs${pinned} -> nodejs${newest}`, detail: "Deploy to a real project before releasing a runtime change.", where }); + } + const daysLeft = (new Date(mine.deprecationDate) - today) / 86400000; + if (daysLeft < RETIRING_WITHIN_DAYS) { + findings.push({ kind: "manual", what: `Functions runtime: nodejs${pinned} is deprecated on ${mine.deprecationDate}`, detail: "Move to a newer runtime before then.", where }); + } + }); + + await look("GitHub Actions", async () => { + const workflows = path.join(root, ".github", "workflows"); + for (const name of fs.existsSync(workflows) ? fs.readdirSync(workflows) : []) { + const file = `.github/workflows/${name}`; + for (const { action, major, line } of workflowActions(read(file))) { + const latest = JSON.parse(await fetchText(`https://raw.githubusercontent.com/${action}/HEAD/package.json`)).version; + if (numbers(latest)[0] > major) { + findings.push({ kind: "manual", what: `${action}: v${major} -> v${numbers(latest)[0]}`, detail: "Workflow files are changed by a person.", where: [{ file, line }] }); + } + } + } + }); + + await look("Firebase and Shopify CLIs", async () => { + for (const [name, tested] of Object.entries(TESTED_TOOLS)) { + const latest = await latestOnNpm(name); + if (numbers(latest)[0] > tested) { + findings.push({ kind: "manual", what: `${name} ${latest} is out; Nitrogen was last run against ${tested}.x`, detail: "Run the CLI with it on a real project, then raise TESTED_TOOLS in tools/check-updates.js.", where: [] }); + } + } + }); + + return findings; +} + +// ─── Report ────────────────────────────────────────────────────────────── +export function report(findings) { + const count = (kind) => findings.filter((finding) => finding.kind === kind).length; + const lines = findings.flatMap(({ kind, what, detail, where }) => [ + `${kind.padEnd(9)} ${what}`, + ` ${detail}`, + ...where.map((place) => ` ${place.file}:${place.line}`), + ]); + const todo = count("auto") + count("manual"); + const summary = todo === 0 && count("unknown") === 0 + ? "Nothing to update." + : `${count("auto")} auto, ${count("manual")} manual, ${count("optional")} optional, ${count("unknown")} unknown`; + return [...lines, lines.length ? "" : null, summary].filter((line) => line !== null).join("\n"); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const findings = await check(); + console.log(process.argv.includes("--json") ? JSON.stringify(findings, null, 2) : report(findings)); + const has = (kind) => findings.some((finding) => finding.kind === kind); + process.exit(has("auto") || has("manual") ? 1 : has("unknown") ? 2 : 0); +} From 78ad66b47dbc86709e990ddc0b4cafcfc4eed4ec Mon Sep 17 00:00:00 2001 From: Mayank Date: Sat, 10 Oct 2026 10:46:24 +0530 Subject: [PATCH 2/2] Test every pull request, and release when a version bump reaches master A new Test workflow runs the full suite and the schema check on every pull request and on master, on Node 24. The publish workflow now also starts when package.json changes on master, and tags what it published. A cloud routine can merge its own pull request but cannot push a tag, so merging a version bump has to be a release. Tag pushes and manual runs still work, a version already on npm is still skipped, and runs are serialised so a merge and a tag for the same version cannot both publish. The tag is pushed by a separate job, so the token that can write to the repository is never present while dependencies run. A manual run on a branch other than master publishes nothing. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/publish.yml | 55 +++++++++++++++++++++++++---- .github/workflows/test.yml | 36 +++++++++++++++++++ RELEASING.md | 66 +++++++++++++++++++++++++++++++---- 3 files changed, 144 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9c02543..622a2bb 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,9 +1,14 @@ name: Publish to npm -# Releases are explicit. Pushing to master does not publish — tag it, or run -# this workflow by hand from the Actions tab. +# Three things start a release: # -# git tag v2.1.0 && git push origin v2.1.0 +# - a new version in package.json reaching master, by merging a pull request +# - pushing a tag: git tag v2.1.0 && git push origin v2.1.0 +# - running this workflow by hand from the Actions tab, on master +# +# All three are safe to repeat: a version that is already on npm is skipped. +# The first exists for the weekly update routine, which can merge its own pull +# request but cannot push a tag (see RELEASING.md). # # Authentication uses npm Trusted Publishing (OIDC). There is no NPM_TOKEN: # GitHub mints a short-lived identity token per run and npm verifies it came @@ -15,19 +20,31 @@ name: Publish to npm # Repository : nitrogen <- the GitHub repo # Package on npm : @mksd0398/nitrogen # Workflow filename : publish.yml <- must match THIS file's name -# Environment : (leave blank) +# Environment : Production <- must match `environment:` below # Allowed actions : npm publish # on: push: tags: ["v*"] + # Path filters are not applied to tags, so a tag always starts a run + branches: [master] + paths: [package.json] workflow_dispatch: +# One at a time: a merge and a tag for the same version would otherwise both +# find it unpublished and both try to publish it +concurrency: + group: publish + cancel-in-progress: false + jobs: publish: - # Only run in the original repo — forks cannot publish - if: github.repository == 'mksd0398/nitrogen' + # Only in the original repo (forks cannot publish), and only from master + # or a tag: a run started by hand on another branch publishes nothing + if: github.repository == 'mksd0398/nitrogen' && (github.ref_type == 'tag' || github.ref == 'refs/heads/master') runs-on: ubuntu-latest + outputs: + published: ${{ steps.publish.outcome == 'success' }} # Must equal the Environment of the Trusted Publisher on npmjs.com, letter # for letter. npm compares it with the identity token GitHub mints, and # answers a mismatch with a bare 404. @@ -75,6 +92,11 @@ jobs: - name: Test the templates run: npm run test:e2e + # The tests stub Shopify. This checks the templates' queries against the + # real schema of the API version they pin. + - name: Check the templates' queries against Shopify's schema + run: npm run check:graphql + - name: Refuse to ship template build artifacts # copyDirSync copies templates verbatim, so a stray node_modules/ or # lib/ here would be shipped into every scaffolded project @@ -102,6 +124,7 @@ jobs: # No NODE_AUTH_TOKEN, and no --provenance flag: trusted publishing # authenticates via OIDC and attaches provenance automatically. - name: Publish to npm + id: publish if: steps.check.outputs.skip != 'true' run: npm publish --access public @@ -115,3 +138,23 @@ jobs: echo "workflow: $GITHUB_WORKFLOW_REF" echo "ref: $GITHUB_REF" grep -hiE "oidc|trusted|exchange|http fetch (PUT|POST)" ~/.npm/_logs/*-debug-*.log || echo "npm logged nothing about OIDC" + + # A release that did not start from a tag has none yet, and every version on + # npm should have one. This is its own job so that the token which can write + # to the repository is never present while dependencies are installed and run. + # A tag pushed with this token does not start the workflow again. + tag: + needs: publish + if: needs.publish.outputs.published == 'true' && github.ref_type != 'tag' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + + - name: Tag the published version + run: | + VERSION=$(node -p "require('./package.json').version") + git tag "v$VERSION" + # Someone may have pushed the same tag by hand in the meantime + git push origin "v$VERSION" || git ls-remote --exit-code --tags origin "v$VERSION" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..ab0639b --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,36 @@ +name: Test + +# Runs on every pull request and on master. The weekly update routine merges +# its own pull request only when this passes (see RELEASING.md). +# +# The templates' tests stub Shopify, so they cannot notice a query that a new +# Shopify API version no longer accepts. check:graphql can: it validates every +# query against the real schema of the version the templates pin. +on: + pull_request: + push: + branches: [master] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: "24" + package-manager-cache: false + + - name: Install dependencies + run: npm ci + + # Scaffolds both templates, installs them, builds and runs their tests + - name: Test the CLI and the templates + run: npm run test:e2e + + - name: Check the templates' queries against Shopify's schema + run: npm run check:graphql diff --git a/RELEASING.md b/RELEASING.md index a3143e1..42a5d4d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,16 +1,68 @@ # Releasing -Releases are automatic: push a `v*` tag and `.github/workflows/publish.yml` -tests both templates, then publishes to npm via trusted publishing (OIDC — there -is no `NPM_TOKEN`). +A release starts when a new `version` in `package.json` reaches `master`. +`.github/workflows/publish.yml` then: + +1. tests both templates; +2. checks their queries against Shopify's schema; +3. publishes to npm via trusted publishing (OIDC, so there is no `NPM_TOKEN`); +4. tags the commit. + +```bash +npm version patch --no-git-tag-version # or minor / major +# commit it, open a pull request, merge it +``` + +Pushing a `v*` tag, or running the workflow by hand on `master`, starts the same +release. All three are safe to repeat: the workflow skips a version that is +already on npm. + +Every pull request runs `.github/workflows/test.yml`: the full test suite and +the schema check, on Node 24. + +--- + +## Staying current + +Two checks say whether anything Nitrogen pins has fallen behind: ```bash -npm version patch # or minor / major — commits and tags -git push && git push --tags +npm run check:updates # dependencies, Shopify API version, runtime, actions, CLIs +npm run check:graphql # the templates' queries against Shopify's real schema ``` -The workflow skips itself if the version is already on npm, so re-running a tag -is safe. +`check:updates` prints the lines to change for each finding, and marks it: + +| Kind | Meaning | +|---|---| +| `auto` | Safe to apply once the tests pass. Today that is a new Shopify API version. | +| `optional` | A dependency range that could be raised. Installs already get the newer version, so it is not a reason to release on its own. | +| `manual` | Needs a person: a new major of a dependency, a new Functions runtime, a GitHub Actions change, or a new major of the Firebase or Shopify CLI. Tests cannot prove these work on a real Firebase project. | +| `unknown` | A source could not be read, so nothing is known either way. | + +`check:graphql` fails when a query no longer fits the schema, and lists +deprecated fields so they can be replaced before Shopify removes them. The +templates' own tests stub Shopify and cannot see either. Check a version before +moving to it with `npm run check:graphql -- --version=2026-10`. + +### The weekly routine + +A Claude Code routine runs `check:updates` every Sunday at 2:00 PM IST, in +Anthropic's cloud. It is managed at https://claude.ai/code/routines. + +- **Nothing behind:** it stops, having changed nothing. +- **`auto` findings:** it makes the change on a branch, bumps the version, adds a + changelog entry, runs both test suites and opens a pull request. A second agent + reviews the diff against fixed rules. When that review and the Test workflow + both pass, it merges the pull request, which releases it. +- **`manual` findings, a failed test or a rejected review:** it opens a pull + request or an issue titled "Needs you: ..." and never merges it. + +It acts as the repository owner on GitHub, so GitHub sends no notification for +what it opens. npm sends an email for each version it publishes. + +After checking a new major of the Firebase or Shopify CLI on a real project, +raise `TESTED_TOOLS` in `tools/check-updates.js`. ---