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`. --- 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); +}