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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 49 additions & 6 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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"
36 changes: 36 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
66 changes: 59 additions & 7 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -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`.

---

Expand Down
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -63,5 +65,8 @@
},
"dependencies": {
"prompts": "^2.4.2"
},
"devDependencies": {
"graphql": "^17.0.2"
}
}
68 changes: 68 additions & 0 deletions test/graphql.test.js
Original file line number Diff line number Diff line change
@@ -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\//);
}
});
Loading
Loading