@next-friday/create-app 9.6.0 → 9.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +11 -10
  2. package/dist/main.mjs +110 -14
  3. package/package.json +1 -1
  4. package/templates/base/.agents/hooks/agent-production-gate.sh +8 -3
  5. package/templates/base/SETUP.md +9 -1
  6. package/templates/base/scripts/check-agent-gates.ts +40 -35
  7. package/templates/deploy-railway/turborepo/turbo.json +66 -0
  8. package/templates/nestjs/.github/workflows/ci.yml +3 -1
  9. package/templates/nestjs/AGENTS.md +5 -0
  10. package/templates/nestjs/Dockerfile +1 -1
  11. package/templates/nestjs/README.md +26 -0
  12. package/templates/nextjs/.github/workflows/ci.yml +3 -1
  13. package/templates/nextjs/AGENTS.md +5 -0
  14. package/templates/nextjs/Dockerfile +1 -1
  15. package/templates/nextjs/README.md +26 -0
  16. package/templates/nodejs/.github/workflows/ci.yml +3 -1
  17. package/templates/nodejs/AGENTS.md +5 -0
  18. package/templates/nodejs/Dockerfile +1 -1
  19. package/templates/nodejs/README.md +26 -0
  20. package/templates/payload/.github/workflows/ci.yml +3 -1
  21. package/templates/payload/AGENTS.md +5 -0
  22. package/templates/payload/Dockerfile +1 -1
  23. package/templates/payload/README.md +26 -0
  24. package/templates/turborepo/.github/workflows/ci.yml +3 -1
  25. package/templates/turborepo/AGENTS.md +5 -0
  26. package/templates/turborepo/README.md +26 -0
  27. package/templates/turborepo/apps/app/Dockerfile +1 -1
  28. package/templates/turborepo/apps/backoffice/Dockerfile +1 -1
  29. package/templates/turborepo/turbo.json +1 -7
  30. /package/templates/{base → deploy-railway/base}/.github/workflows/deploy.yml +0 -0
  31. /package/templates/{base → deploy-railway/base}/railway.json +0 -0
  32. /package/templates/{base → deploy-railway/base}/runbooks/rollback-deploy.md +0 -0
  33. /package/templates/{turborepo → deploy-railway/turborepo}/.github/workflows/deploy.yml +0 -0
package/README.md CHANGED
@@ -31,7 +31,7 @@ Every stack is built from [`templates/base`](templates/base), which holds the sh
31
31
  - **Code quality:** TypeScript, ESLint and Prettier with the Next Friday configs, Vitest with coverage, fast-check, Knip, dependency-cruiser, editorconfig-checker, and sort-package-json.
32
32
  - **Git hooks:** Husky runs gitleaks, `pnpm rules:check`, and lint-staged before each commit, commitlint on each message, and `pnpm check` before each push. After the first commit, commits to `main` are blocked, and local merges into `main` always are; pushes to `main` are blocked once it exists on the remote.
33
33
  - **CI:** GitHub Actions run the static checks, tests with patch coverage, the build, end-to-end tests for Next.js and Payload, a container build and vulnerability scan, Semgrep, OSV-Scanner, CodeQL, dependency review, OpenSSF Scorecard, gitleaks, TruffleHog, zizmor, and pull request title validation. Dependabot keeps dependencies current.
34
- - **Deployment:** a `Dockerfile` that runs as the `node` user, and a Railway workflow that promotes one commit through `dev`, `uat`, and `prod`.
34
+ - **Deployment:** a `Dockerfile` that runs as the `node` user. With `--deploy railway`, also a Railway workflow that promotes one commit through `dev`, `uat`, and `prod`.
35
35
  - **Agent rules:** `CLAUDE.md` imports `AGENTS.md`. Shared rules live in `.agents/rules`, with hooks that enforce them in Claude Code and Cursor.
36
36
 
37
37
  ## Requirements
@@ -44,17 +44,18 @@ Every stack is built from [`templates/base`](templates/base), which holds the sh
44
44
  ## Options
45
45
 
46
46
  ```text
47
- pnpm create @next-friday/app [name] [--stack nodejs|nestjs|nextjs|payload|turborepo]
47
+ pnpm create @next-friday/app [name] [--stack nodejs|nestjs|nextjs|payload|turborepo] [--deploy none|railway]
48
48
  ```
49
49
 
50
- | Option | Description |
51
- | :--------------- | :---------------------------------------------------------------------------- |
52
- | `name` | Project directory and package name. Asked when left out. |
53
- | `--stack` | `nodejs`, `nestjs`, `nextjs`, `payload`, or `turborepo`. Asked when left out. |
54
- | `--skip-git` | Do not run `git init`. |
55
- | `--skip-install` | Do not run `pnpm install`. |
56
- | `-v, --version` | Print the version. |
57
- | `-h, --help` | Print the usage. |
50
+ | Option | Description |
51
+ | :--------------- | :---------------------------------------------------------------------------------------- |
52
+ | `name` | Project directory and package name. Asked when left out. |
53
+ | `--stack` | `nodejs`, `nestjs`, `nextjs`, `payload`, or `turborepo`. Asked when left out. |
54
+ | `--deploy` | Deploy target: `none` or `railway`. The default is `none`, which adds no deploy workflow. |
55
+ | `--skip-git` | Do not run `git init`. |
56
+ | `--skip-install` | Do not run `pnpm install`. |
57
+ | `-v, --version` | Print the version. |
58
+ | `-h, --help` | Print the usage. |
58
59
 
59
60
  The name must:
60
61
 
package/dist/main.mjs CHANGED
@@ -117,6 +117,16 @@ function validateProjectName(name, cwd) {
117
117
  function ownEntries(directory) {
118
118
  return readdirSync(directory).filter((entry) => entry !== ".git");
119
119
  }
120
+ // src/deploy-targets.ts
121
+ const deployTargets = ["none", "railway"];
122
+ /**
123
+ * Check whether a string names a supported deploy target.
124
+ * @param value - Deploy target from the command line.
125
+ * @returns Whether `value` is a supported deploy target.
126
+ */
127
+ function isDeployTarget(value) {
128
+ return deployTargets.some((target) => target === value);
129
+ }
120
130
  // src/stacks.ts
121
131
  const stacks = [
122
132
  {
@@ -173,10 +183,11 @@ function templateName(stack) {
173
183
  return `next-friday-template-${stack}`;
174
184
  }
175
185
  // src/arguments.ts
176
- const usage = `Usage: pnpm create @next-friday/app [name] [--stack ${stacks.map((stack) => stack.id).join("|")}]
186
+ const usage = `Usage: pnpm create @next-friday/app [name] [--stack ${stacks.map((stack) => stack.id).join("|")}] [--deploy ${deployTargets.join("|")}]
177
187
 
178
188
  name Project directory and package name, or . for the current directory. Asked when left out.
179
189
  --stack Template stack. Asked when left out.
190
+ --deploy Deploy target: ${deployTargets.join(" or ")}. The default is none.
180
191
  --skip-git Do not run git init.
181
192
  --skip-install Do not run pnpm install.
182
193
  -v, --version Show the version.
@@ -192,6 +203,10 @@ function parseCliArguments(cliArguments) {
192
203
  allowPositionals: true,
193
204
  options: {
194
205
  stack: { type: "string" },
206
+ deploy: {
207
+ type: "string",
208
+ default: "none"
209
+ },
195
210
  "skip-git": {
196
211
  type: "boolean",
197
212
  default: false
@@ -212,23 +227,33 @@ function parseCliArguments(cliArguments) {
212
227
  }
213
228
  }
214
229
  });
215
- const { help, stack, version } = values;
230
+ const { deploy, help, stack, version } = values;
216
231
  const isInformational = help || version;
217
232
  if (!isInformational && positionals.length > 1) throw new Error(`Pass one project name, not ${positionals.length}: ${positionals.join(" ")}.`);
218
233
  const [name] = positionals;
219
- if (!isInformational && stack !== void 0 && !isStackId(stack)) {
220
- const ids = stacks.map((supported) => supported.id).join(", ");
221
- throw new Error(`Unknown stack "${stack}". Use one of: ${ids}.`);
222
- }
234
+ if (!isInformational) assertKnownChoices(stack, deploy);
223
235
  return {
224
236
  help,
225
237
  name,
226
238
  version,
239
+ deploy: isDeployTarget(deploy) ? deploy : "none",
227
240
  stack: stack !== void 0 && isStackId(stack) ? stack : void 0,
228
241
  skipGit: values["skip-git"],
229
242
  skipInstall: values["skip-install"]
230
243
  };
231
244
  }
245
+ /**
246
+ * Throw when the stack or the deploy target is not one the CLI supports.
247
+ * @param stack - Stack from the command line, or `undefined` when left out.
248
+ * @param deploy - Deploy target from the command line.
249
+ */
250
+ function assertKnownChoices(stack, deploy) {
251
+ if (stack !== void 0 && !isStackId(stack)) {
252
+ const ids = stacks.map((supported) => supported.id).join(", ");
253
+ throw new Error(`Unknown stack "${stack}". Use one of: ${ids}.`);
254
+ }
255
+ if (!isDeployTarget(deploy)) throw new Error(`Unknown deploy target "${deploy}". Use one of: ${deployTargets.join(", ")}.`);
256
+ }
232
257
  // src/rename.ts
233
258
  /**
234
259
  * Replace the template name with the project name in every text file.
@@ -245,6 +270,46 @@ async function renameProject(directory, from, to) {
245
270
  await writeFile(file, text.replaceAll(from, () => to));
246
271
  }));
247
272
  }
273
+ // src/deploy-sections.ts
274
+ const marker = /^\s*(?:#|\/\/|<!--)\s*deploy:(?<name>[a-z]+)\s*(?:-->\s*)?$/u;
275
+ const endName = "end";
276
+ /**
277
+ * Keep the sections of a template file marked for the chosen deploy target and remove the others.
278
+ * A section starts at a comment line that holds `deploy:<target>` and ends at one that holds `deploy:end`.
279
+ * Every marker line is removed, with a blank line that directly follows it.
280
+ * @param content - Text of a copied template file.
281
+ * @param target - Chosen deploy target.
282
+ * @returns The text for the chosen deploy target.
283
+ */
284
+ function resolveDeploySections(content, target) {
285
+ const kept = [];
286
+ let section;
287
+ let isAfterMarker = false;
288
+ const lines = content.split("\n");
289
+ for (const [index, line] of lines.entries()) {
290
+ const name = marker.exec(line)?.groups?.name;
291
+ if (name === void 0) {
292
+ if (!(isAfterMarker && line.trim() === "" && index < lines.length - 1) && (section ?? target) === target) kept.push(line);
293
+ } else section = sectionAfterMarker(name, section);
294
+ isAfterMarker = name !== void 0;
295
+ }
296
+ if (section !== void 0) throw new Error(`The deploy:${section} section has no deploy:end marker.`);
297
+ return kept.join("\n");
298
+ }
299
+ /**
300
+ * Give the section a marker line leaves open, or throw when the marker is out of place.
301
+ * @param name - Deploy target the marker names, or `end`.
302
+ * @param section - Deploy target of the section the marker sits in, or `undefined` outside a section.
303
+ * @returns The deploy target of the section now open, or `undefined` after an end marker.
304
+ */
305
+ function sectionAfterMarker(name, section) {
306
+ if (name === endName) {
307
+ if (section === void 0) throw new Error("A deploy:end marker has no deploy target section to close.");
308
+ return;
309
+ }
310
+ if (section !== void 0) throw new Error(`A deploy:${name} marker starts inside the deploy:${section} section.`);
311
+ return name;
312
+ }
248
313
  // src/create.ts
249
314
  const templatesDirectory = fileURLToPath(new URL("../templates", import.meta.url));
250
315
  const interruptSignals = [
@@ -276,7 +341,7 @@ async function create(cliArguments) {
276
341
  const target = path.resolve(cwd, name);
277
342
  const directory = existsSync(target) ? realpathSync(target) : target;
278
343
  log.step(`Copying the ${stack} template.`);
279
- await writeProject(stack, directory, manifestName);
344
+ await writeProject(stack, options.deploy, directory, manifestName);
280
345
  log.success(`Created ${manifestName} from the ${stack} template.`);
281
346
  const { git, isInstalled } = setUpProject(directory, options);
282
347
  outro(nextSteps(name, stack, git, isInstalled));
@@ -294,10 +359,11 @@ async function create(cliArguments) {
294
359
  * Copy, rename, and write `.env`, removing what was written when a step fails or Ctrl+C, SIGTERM, or SIGHUP arrives.
295
360
  * An interrupt waits for the running step, so the removal does not race the copy.
296
361
  * @param stack - Supported stack id.
362
+ * @param deploy - Supported deploy target.
297
363
  * @param directory - Project directory.
298
364
  * @param manifestName - Package name for `package.json`.
299
365
  */
300
- async function writeProject(stack, directory, manifestName) {
366
+ async function writeProject(stack, deploy, directory, manifestName) {
301
367
  const kept = existsSync(directory) ? readdirSync(directory) : void 0;
302
368
  let isInterrupted = false;
303
369
  const interrupt = () => {
@@ -305,7 +371,7 @@ async function writeProject(stack, directory, manifestName) {
305
371
  };
306
372
  for (const signal of interruptSignals) process.on(signal, interrupt);
307
373
  try {
308
- await copyTemplate(stack, directory);
374
+ await copyTemplate(stack, deploy, directory);
309
375
  await renameProject(directory, templateName(stack), manifestName);
310
376
  await writeEnvironmentFile(directory);
311
377
  if (isInterrupted) throw new CancelledError();
@@ -382,19 +448,49 @@ function existingGit(directory) {
382
448
  return existsSync(path.join(directory, ".git")) ? "existing" : void 0;
383
449
  }
384
450
  /**
385
- * Copy the shared template files, then the stack's files, into the project directory.
451
+ * Copy the shared template files, the stack's files, and the deploy target's files into the project directory.
386
452
  * npm leaves `.gitignore` out of published packages, so the template keeps it as `_gitignore`.
387
453
  * @param stack - Supported stack id.
454
+ * @param deploy - Supported deploy target.
388
455
  * @param directory - Project directory.
389
456
  */
390
- async function copyTemplate(stack, directory) {
391
- for (const template of ["base", stack]) if (!existsSync(path.join(templatesDirectory, template))) throw new Error(`The ${template} template is missing from ${templatesDirectory}. Reinstall @next-friday/create-app.`);
392
- await cp(path.join(templatesDirectory, "base"), directory, { recursive: true });
393
- await cp(path.join(templatesDirectory, stack), directory, { recursive: true });
457
+ async function copyTemplate(stack, deploy, directory) {
458
+ const deployLayer = deploy === "none" ? void 0 : `deploy-${deploy}`;
459
+ const required = [
460
+ "base",
461
+ stack,
462
+ ...deployLayer === void 0 ? [] : [`${deployLayer}/base`]
463
+ ];
464
+ for (const template of required) if (!existsSync(path.join(templatesDirectory, template))) throw new Error(`The ${template} template is missing from ${templatesDirectory}. Reinstall @next-friday/create-app.`);
465
+ const copyLayer = async (layer) => {
466
+ await cp(path.join(templatesDirectory, layer), directory, { recursive: true });
467
+ };
468
+ await copyLayer("base");
469
+ await copyLayer(stack);
470
+ if (deployLayer !== void 0) {
471
+ await copyLayer(`${deployLayer}/base`);
472
+ if (existsSync(path.join(templatesDirectory, deployLayer, stack))) await copyLayer(`${deployLayer}/${stack}`);
473
+ }
394
474
  await rename(path.join(directory, "_gitignore"), path.join(directory, ".gitignore"));
475
+ await resolveDeployTarget(directory, deploy);
395
476
  await markScriptsExecutable(directory);
396
477
  }
397
478
  /**
479
+ * Resolve the deploy target sections of every copied file that holds a marker.
480
+ * @param directory - Project directory.
481
+ * @param deploy - Supported deploy target.
482
+ */
483
+ async function resolveDeployTarget(directory, deploy) {
484
+ const files = await listProjectFiles(directory);
485
+ await Promise.all(files.map(async (file) => {
486
+ const content = await readFile(file);
487
+ if (!content.includes("deploy:")) return;
488
+ const template = content.toString();
489
+ const resolved = resolveDeploySections(template, deploy);
490
+ if (resolved !== template) await writeFile(file, resolved);
491
+ }));
492
+ }
493
+ /**
398
494
  * Make every file that starts with a shebang executable. `pnpm pack` drops the executable bit,
399
495
  * and the Git and agent hooks run these scripts directly.
400
496
  * @param directory - Project directory.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@next-friday/create-app",
3
- "version": "9.6.0",
3
+ "version": "9.7.1",
4
4
  "description": "Create a Next Friday project from a template.",
5
5
  "keywords": [
6
6
  "create",
@@ -45,9 +45,9 @@ release_gate="A release manager runs prod deploys, commands against prod, and re
45
45
 
46
46
  # The gate reads the command line as text, joining continued lines and dropping quotes,
47
47
  # backslashes, and fd redirections, so wrappers such as bash -c, eval, or npx and split words such
48
- # as pr''od do not hide the common forms. A dispatch input or Railway environment the gate cannot
49
- # read (a variable, a command substitution, a file, or stdin) counts as prod. It is a convenience,
50
- # not a security boundary: the prod environment's required reviewers and Railway credentials are.
48
+ # as pr''od do not hide the common forms. A dispatch input the gate cannot read (a variable, a
49
+ # command substitution, a file, or stdin) counts as prod. It is a convenience, not a security
50
+ # boundary: the prod environment's required reviewers and the deploy credentials are.
51
51
  text=${command//'\\'/}
52
52
  text=${text//$'\\\n'/}
53
53
  text=$(sed -E 's/([0-9]*[<>]&[0-9-]*|&>>?)//g' <<< "${text//[\"\'\\]/}")
@@ -56,16 +56,21 @@ prod="(prod([^[:alnum:]_-]|$)|[\$\`@])"
56
56
  dispatch="(workflow[[:space:]]+run|/dispatches)"
57
57
  environment_field="environment[]]*[=:][[:space:]]*$prod"
58
58
  unreadable_inputs="--(json|input)([[:space:]=]|$)"
59
+ # deploy:railway
60
+ # A Railway environment the gate cannot read counts as prod too.
59
61
  railway="(^|[^[:alnum:]_.-])(railway|@railway/cli(@[^[:space:]]*)?)([[:space:]]$simple)?"
62
+ # deploy:end
60
63
  git_push="(^|[^[:alnum:]_.-])git([[:space:]]+(-[Cc][[:space:]]+[^[:space:]]+|--(git-dir|work-tree|namespace|config-env)[[:space:]]+[^[:space:]]+|--?[^[:space:]]+))*[[:space:]]+push([[:space:]]$simple)?"
61
64
  tag_ref="([[:space:]:+]((refs/)?tags/|v[0-9])|[[:space:]]--((follow-)?tags|mirror)([[:space:]=]|$))"
62
65
 
63
66
  if [[ $text =~ $dispatch$simple($environment_field|$unreadable_inputs) || $text =~ $environment_field$simple$dispatch ]]; then
64
67
  block "$release_gate"
65
68
  fi
69
+ # deploy:railway
66
70
  if [[ $text =~ $railway[[:space:]](-[[:alpha:]]*e([[:space:]]+|=)?|--environment([[:space:]]+|=))$prod || $text =~ $railway[[:space:]](env|environment)([[:space:]]+[[:alpha:]]+)?[[:space:]]+$prod ]]; then
67
71
  block "$release_gate"
68
72
  fi
73
+ # deploy:end
69
74
  if [[ $text =~ $git_push$tag_ref || $(tr '[:upper:]' '[:lower:]' <<< "$text") == *push.followtags* ]]; then
70
75
  block "$release_gate"
71
76
  fi
@@ -64,7 +64,10 @@ Replace `security@example.com` in `SECURITY.md` with the contact the security po
64
64
 
65
65
  Rulesets in a private repository need GitHub Pro, Team, or Enterprise; on GitHub Free, make the repository public or upgrade.
66
66
 
67
- - **Secrets and variables → Actions → Variables:** in a private repository, set `CODE_SCANNING` to `true` to run CodeQL, Scorecard, and Dependency Review, which need GitHub Code Security there; public repositories run them without it. Set `DEPLOY_ENABLED` to `true` when each push to `main` that passes CI should deploy to `dev`; the README's deploy section sets up the environments.
67
+ - **Secrets and variables → Actions → Variables:** in a private repository, set `CODE_SCANNING` to `true` to run CodeQL, Scorecard, and Dependency Review, which need GitHub Code Security there; public repositories run them without it. Set `CONTAINER_IMAGE` to `false` to skip the job that builds and scans the container image, for a project that ships no image.
68
+ <!-- deploy:railway -->
69
+ - **Secrets and variables → Actions → Variables:** set `DEPLOY_ENABLED` to `true` when each push to `main` that passes CI should deploy to `dev`; the README's deploy section sets up the environments.
70
+ <!-- deploy:end -->
68
71
  - Create the `needs triage` label, which the issue forms apply: `gh label create "needs triage"`.
69
72
 
70
73
  **Check:** a test pull request shows the two required checks and asks for a code owner's review.
@@ -94,7 +97,12 @@ Replace `security@example.com` in `SECURITY.md` with the contact the security po
94
97
  - **Sources of truth (engineering lead):** for each artifact, name one system as the source of truth: this repository, an existing tool such as Jira, or, as the minimum, a link both ways (the record id in the file and the commit SHA in the record).
95
98
  - **Higher risk (engineering lead):** decide which changes count as higher risk. A tech lead or architect approves their specs and plans.
96
99
  - **Policies (each policy owner, with an engineer):** the plugin ships a default for each area, `plugins/sdlc/claude-code/skills/<area>-policy/SKILL.md` (security: OWASP ASVS 5.0.0 Level 1; UX: WCAG 2.2 Level AA; compliance: the OECD privacy principles; brand: one declared source for every brand value). Review it. To replace a default, add the organization's own rules in `.agents/skills/<area>-policy/SKILL.md` in the project: that file wins over the plugin's default, and the `CODEOWNERS` line of step 2 for that area makes the policy owner its code owner. Do not edit the plugin's copy. Then run `pnpm rules:sync`, which renders the project's own policy skill for Claude Code and Cursor. Test that the skill triggers: ask for the relevant task in different ways and confirm the skill loads each time.
100
+ <!-- deploy:railway -->
97
101
  - **Production gate (engineering leadership, with change management and compliance):** name the release managers and add them as required reviewers on the `prod` environment, as the README's deploy section describes. `.agents/hooks/agent-production-gate.sh` blocks an agent's shell commands that dispatch a `prod` deploy, run a Railway command with `prod` as its environment, or push a release tag. It does not read MCP tools. A release manager whose own agent session must do one of these starts the agent with `RELEASE_APPROVAL` set to the approval record, for example `RELEASE_APPROVAL=CHG-123 claude`. The value lifts the gate only when it matches the extended regular expression on the first line of `.agents/release-approval-form`, shipped as `^[A-Z]+-[0-9]+$`; set that line to the form of your organization's approval records. Each gate hook appends its decisions to `.agents/logs/gate-decisions.jsonl`, which git ignores: the time, the hook, the decision, the session, and the reason for a block, and, when the production gate is lifted, the approval value and the Git user name. A record holds no command text and no path.
102
+ <!-- deploy:end -->
103
+ <!-- deploy:none -->
104
+ - **Production gate (engineering leadership, with change management and compliance):** name the release managers and add them as required reviewers on the `prod` environment, as the README's deploy section describes. `.agents/hooks/agent-production-gate.sh` blocks an agent's shell commands that dispatch a `prod` deploy or push a release tag. It does not read MCP tools. A release manager whose own agent session must do one of these starts the agent with `RELEASE_APPROVAL` set to the approval record, for example `RELEASE_APPROVAL=CHG-123 claude`. The value lifts the gate only when it matches the extended regular expression on the first line of `.agents/release-approval-form`, shipped as `^[A-Z]+-[0-9]+$`; set that line to the form of your organization's approval records. Each gate hook appends its decisions to `.agents/logs/gate-decisions.jsonl`, which git ignores: the time, the hook, the decision, the session, and the reason for a block, and, when the production gate is lifted, the approval value and the Git user name. A record holds no command text and no path.
105
+ <!-- deploy:end -->
98
106
  - **Review (tech lead):** `.agents/review.md` names this stack's generated files to skip; add a path when the project gains a generated file, then run `pnpm rules:sync`, which renders it to `REVIEW.md` for Claude Code Review. Once a month, rate the review findings and tune `.agents/review.md`.
99
107
  - **People without Git experience:** connect GitHub to Claude (claude.ai or Cowork), so Claude commits `intent.md` for them.
100
108
 
@@ -137,7 +137,7 @@ const allowedCommands = [
137
137
  'gh api repos/next-friday/example/issues/1/comments -f body="APPROVED by QA"',
138
138
  ];
139
139
 
140
- const railwayProductionCommand = "railway up -e prod";
140
+ const sampleProductionCommand = "gh workflow run deploy.yml -f environment=prod";
141
141
 
142
142
  const productionCommands = [
143
143
  "gh workflow run deploy.yml -f environment=prod -f commit_sha=abc",
@@ -158,8 +158,30 @@ const productionCommands = [
158
158
  "gh api repos/o/r/actions/workflows/1/dispatches --input body.json",
159
159
  "gh api repos/o/r/actions/workflows/1/dispatches -f inputs[environment]=$E",
160
160
  "bash -c 'gh workflow run deploy.yml -f environment=prod'",
161
+ sampleProductionCommand,
162
+ "git push origin v1.0.0",
163
+ "git push origin HEAD:refs/tags/v1.0.0",
164
+ "git push --tags",
165
+ "git push --follow-tags origin main",
166
+ "git -C . push origin v2.3.4",
167
+ "gh workflow run deploy.yml -f environment=pr''od",
168
+ "gh workflow run deploy.yml -f environment=p\\rod",
169
+ 'gh workflow run deploy.yml -f "envi"ronment=prod',
170
+ "git push origin 'refs/tags/*'",
171
+ 'git push origin "refs/tags/v*"',
172
+ "git push --mirror",
173
+ "git -c push.followTags=true push origin main",
174
+ "git config push.followTags true && git push",
175
+ "gh workflow run deploy.yml 2>&1 -f environment=prod",
176
+ "git push origin tags/v1.0.0",
177
+ "git --git-dir .git push origin v1.0.0",
178
+ "gh workflow run deploy.yml \\\n -f environment=prod \\\n -f commit_sha=abc",
179
+ "git push \\\n origin v1.0.0",
180
+ "git push &>/dev/null origin v1.0.0",
181
+ "cd a\\\\\ngit push origin v1.0.0",
182
+ // deploy:railway
161
183
  "railway up --ci --environment prod",
162
- railwayProductionCommand,
184
+ "railway up -e prod",
163
185
  "railway up -e=prod",
164
186
  "railway up -eprod",
165
187
  "railway up -ce prod",
@@ -181,32 +203,13 @@ const productionCommands = [
181
203
  "/usr/local/bin/railway up -e prod",
182
204
  "timeout 60 railway up -e prod",
183
205
  "eval railway up -e prod",
184
- "git push origin v1.0.0",
185
- "git push origin HEAD:refs/tags/v1.0.0",
186
- "git push --tags",
187
- "git push --follow-tags origin main",
188
- "git -C . push origin v2.3.4",
189
206
  "npx @railway/cli@5.71.0 up -e prod",
190
207
  "npx @railway/cli@latest up --environment prod",
191
208
  "railway up -e pr''od",
192
- "gh workflow run deploy.yml -f environment=pr''od",
193
- "gh workflow run deploy.yml -f environment=p\\rod",
194
- 'gh workflow run deploy.yml -f "envi"ronment=prod',
195
- "git push origin 'refs/tags/*'",
196
- 'git push origin "refs/tags/v*"',
197
- "git push --mirror",
198
- "git -c push.followTags=true push origin main",
199
- "git config push.followTags true && git push",
200
- "gh workflow run deploy.yml 2>&1 -f environment=prod",
201
209
  "railway up 2>&1 -e prod",
202
- "git push origin tags/v1.0.0",
203
- "git --git-dir .git push origin v1.0.0",
204
- "gh workflow run deploy.yml \\\n -f environment=prod \\\n -f commit_sha=abc",
205
210
  "railway up \\\n --environment prod",
206
- "git push \\\n origin v1.0.0",
207
211
  "railway up &>/dev/null -e prod",
208
- "git push &>/dev/null origin v1.0.0",
209
- "cd a\\\\\ngit push origin v1.0.0",
212
+ // deploy:end
210
213
  ];
211
214
 
212
215
  const approvalLiftsGate = ["CHG-123", "CHG-1"];
@@ -229,10 +232,6 @@ const approvalKeepsGate = [
229
232
  const nonProductionCommands = [
230
233
  "gh workflow run deploy.yml -f environment=uat -f commit_sha=abc",
231
234
  "gh api repos/o/r/actions/workflows/deploy.yml/dispatches -f 'inputs[environment]=uat'",
232
- "railway up -e uat",
233
- "railway up -e prod-eu",
234
- "railway environment uat",
235
- "railway status",
236
235
  "gh workflow list",
237
236
  "gh run list --workflow deploy.yml",
238
237
  "gh api repos/o/r/environments/prod",
@@ -241,14 +240,20 @@ const nonProductionCommands = [
241
240
  'git commit -m "release v1.0" && git push',
242
241
  'git commit -m "docs(x): deploy to production"',
243
242
  'gh pr create --title "ci(x): promote to prod" --body "run gh workflow run later"',
244
- "echo railway",
245
243
  "git tag v1.0.0",
246
244
  'git stash push -m "wip v2"',
247
245
  'git commit -m "fix(ci): push v2 images"',
248
- 'git commit -m "fix: railway env uat and prod split"',
249
246
  "git push -u origin HEAD",
250
247
  "git push origin feature/push-v2",
251
248
  "pnpm verify 2>&1 | tail && git push",
249
+ // deploy:railway
250
+ "railway up -e uat",
251
+ "railway up -e prod-eu",
252
+ "railway environment uat",
253
+ "railway status",
254
+ "echo railway",
255
+ 'git commit -m "fix: railway env uat and prod split"',
256
+ // deploy:end
252
257
  ];
253
258
 
254
259
  const mcpTools = {
@@ -461,11 +466,11 @@ const cases: GateCase[] = [
461
466
  expectedApproval: releaseApproval,
462
467
  input: {
463
468
  tool_input: {
464
- command: railwayProductionCommand,
469
+ command: sampleProductionCommand,
465
470
  },
466
471
  },
467
472
  expectedPermission: "allow" as const,
468
- name: `production with RELEASE_APPROVAL="${releaseApproval}": ${railwayProductionCommand}`,
473
+ name: `production with RELEASE_APPROVAL="${releaseApproval}": ${sampleProductionCommand}`,
469
474
  expectedExit: 0 as const,
470
475
  })),
471
476
  ...approvalKeepsGate.map(releaseApproval => ({
@@ -473,10 +478,10 @@ const cases: GateCase[] = [
473
478
  releaseApproval,
474
479
  input: {
475
480
  tool_input: {
476
- command: railwayProductionCommand,
481
+ command: sampleProductionCommand,
477
482
  },
478
483
  },
479
- name: `production with RELEASE_APPROVAL="${releaseApproval}": ${railwayProductionCommand}`,
484
+ name: `production with RELEASE_APPROVAL="${releaseApproval}": ${sampleProductionCommand}`,
480
485
  expectedExit: 2 as const,
481
486
  })),
482
487
  ...nonProductionCommands.map(command => ({
@@ -590,7 +595,7 @@ const cases: GateCase[] = [
590
595
  name: "record: the conversation of a Cursor hook input",
591
596
  expectedExit: 2,
592
597
  input: {
593
- command: railwayProductionCommand,
598
+ command: sampleProductionCommand,
594
599
  conversation_id: "cursor-conversation",
595
600
  },
596
601
  },
@@ -617,7 +622,7 @@ const cases: GateCase[] = [
617
622
  releaseApproval: "CHG-123",
618
623
  expectedExit: 2,
619
624
  input: {
620
- command: railwayProductionCommand,
625
+ command: sampleProductionCommand,
621
626
  },
622
627
  },
623
628
  {
@@ -636,7 +641,7 @@ const cases: GateCase[] = [
636
641
  expectedExit: 2,
637
642
  isRecordUnwritable: true,
638
643
  input: {
639
- command: railwayProductionCommand,
644
+ command: sampleProductionCommand,
640
645
  },
641
646
  },
642
647
  ];
@@ -0,0 +1,66 @@
1
+ {
2
+ "$schema": "https://turborepo.dev/schema.json",
3
+ "futureFlags": {
4
+ "affectedUsingTaskInputs": true
5
+ },
6
+ "globalDependencies": [
7
+ ".dockerignore",
8
+ ".env",
9
+ "package.json",
10
+ "pnpm-workspace.yaml",
11
+ "railway.json"
12
+ ],
13
+ "globalPassThroughEnv": [
14
+ "APP_URL",
15
+ "CI",
16
+ "DATABASE_URL",
17
+ "NEXT_TELEMETRY_DISABLED",
18
+ "PAYLOAD_SECRET"
19
+ ],
20
+ "tasks": {
21
+ "build": {
22
+ "dependsOn": ["^build"],
23
+ "inputs": ["$TURBO_DEFAULT$", "!README.md"],
24
+ "outputs": [".next/**", "!.next/cache/**", "!.next/dev/**"]
25
+ },
26
+ "depcruise": {
27
+ "inputs": ["$TURBO_DEFAULT$", "!README.md"]
28
+ },
29
+ "dev": {
30
+ "cache": false,
31
+ "persistent": true,
32
+ "inputs": ["$TURBO_DEFAULT$", "!README.md"]
33
+ },
34
+ "start": {
35
+ "cache": false,
36
+ "persistent": true,
37
+ "inputs": ["$TURBO_DEFAULT$", "!README.md"]
38
+ },
39
+ "test": {
40
+ "inputs": ["$TURBO_DEFAULT$", "!README.md"]
41
+ },
42
+ "test:changed": {
43
+ "cache": false,
44
+ "inputs": ["$TURBO_DEFAULT$", "!README.md"]
45
+ },
46
+ "test:coverage": {
47
+ "inputs": ["$TURBO_DEFAULT$", "!README.md"],
48
+ "outputs": ["coverage/**"]
49
+ },
50
+ "test:e2e": {
51
+ "cache": false,
52
+ "dependsOn": ["build"],
53
+ "inputs": ["$TURBO_DEFAULT$", "!README.md"]
54
+ },
55
+ "test:watch": {
56
+ "cache": false,
57
+ "persistent": true,
58
+ "inputs": ["$TURBO_DEFAULT$", "!README.md"]
59
+ },
60
+ "typecheck": {
61
+ "dependsOn": ["^typecheck"],
62
+ "inputs": ["$TURBO_DEFAULT$", "!README.md"],
63
+ "outputs": [".next/types/**", "next-env.d.ts"]
64
+ }
65
+ }
66
+ }
@@ -118,7 +118,7 @@ jobs:
118
118
  image:
119
119
  name: Container Image
120
120
  needs: changes
121
- if: needs.changes.outputs.full_verification == 'true' || github.event_name == 'schedule'
121
+ if: (needs.changes.outputs.full_verification == 'true' || github.event_name == 'schedule') && vars.CONTAINER_IMAGE != 'false'
122
122
  runs-on: ubuntu-latest
123
123
  timeout-minutes: 15
124
124
  env:
@@ -157,6 +157,7 @@ jobs:
157
157
  tar --extract --gzip --file "$archive" trivy
158
158
  ./trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed --no-progress app:ci
159
159
 
160
+ # deploy:railway
160
161
  deploy-dev:
161
162
  name: Deploy to dev
162
163
  needs:
@@ -171,6 +172,7 @@ jobs:
171
172
  environment: dev
172
173
  commit_sha: ${{ github.sha }}
173
174
 
175
+ # deploy:end
174
176
  triage:
175
177
  name: Triage failed build
176
178
  needs:
@@ -45,7 +45,12 @@ The `verifier` subagent starts the app with `pnpm start:dev`. It exercises the c
45
45
 
46
46
  When Claude makes a mistake twice, add the correction here.
47
47
 
48
+ <!-- deploy:railway -->
48
49
  - **Production gate**: `.agents/hooks/agent-production-gate.sh` blocks a shell command that deploys to `prod`, runs a Railway CLI command that names `prod`, or pushes a release tag, unless a release manager started the session with `RELEASE_APPROVAL` set. Any value lifts the gate except an empty value, `0`, `false`, and `no`, in any letter case. Report what is ready to promote instead. When a gated command appears only inside a message, write the text to a file with your file-editing tool, not a shell heredoc, and pass it with `git commit -F <file>` or `gh pr create --body-file <file>`. The gate reads only shell commands, so an MCP tool or a Railway command without `-e` or `--environment` passes it.
50
+ <!-- deploy:end -->
51
+ <!-- deploy:none -->
52
+ - **Production gate**: `.agents/hooks/agent-production-gate.sh` blocks a shell command that deploys to `prod` or pushes a release tag, unless a release manager started the session with `RELEASE_APPROVAL` set. Any value lifts the gate except an empty value, `0`, `false`, and `no`, in any letter case. Report what is ready to promote instead. When a gated command appears only inside a message, write the text to a file with your file-editing tool, not a shell heredoc, and pass it with `git commit -F <file>` or `gh pr create --body-file <file>`. The gate reads only shell commands, so an MCP tool passes it.
53
+ <!-- deploy:end -->
49
54
  - **Issues**: open an issue only to hand work to another person, never to give a pull request a number to close. A pull request closes an issue that already exists with `Closes #<issue>`.
50
55
  - **What belongs in the repository**: files, commit messages, and pull requests hold the change, its reason, and the checks. Write no date, log, history, mistake count, token figure, or session note into a file.
51
56
  - **Sourced requirements**: add a requirement to an intent, spec, or plan only when a person decided it or a named source says it. This includes a requirement a review bot suggests. When no person decided it and no source says it, leave it out and answer the thread with that.
@@ -1,4 +1,4 @@
1
- # Runs on any OCI platform (Railway, Fly.io, Cloud Run). The platform sets `PORT`.
1
+ # Runs on any OCI container platform. The platform sets `PORT`.
2
2
  FROM \
3
3
  node:24.22.1-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53 \
4
4
  AS base
@@ -34,13 +34,25 @@ CI also runs the build, the end-to-end tests, the container image scan, and the
34
34
 
35
35
  ## Container
36
36
 
37
+ <!-- deploy:railway -->
38
+
37
39
  The `Dockerfile` builds a production image that runs as a non-root user (UID 1000). The platform must set `PORT`; Railway sets it automatically. The build needs `pnpm-lock.yaml`, so run `pnpm install` once before the first `docker build`.
38
40
 
41
+ <!-- deploy:end -->
42
+
43
+ <!-- deploy:none -->
44
+
45
+ The `Dockerfile` builds a production image that runs as a non-root user (UID 1000). The platform must set `PORT`. The build needs `pnpm-lock.yaml`, so run `pnpm install` once before the first `docker build`.
46
+
47
+ <!-- deploy:end -->
48
+
39
49
  ```sh
40
50
  docker build -t app .
41
51
  docker run --env PORT=3000 --publish 3000:3000 app
42
52
  ```
43
53
 
54
+ <!-- deploy:railway -->
55
+
44
56
  ## Deploy
45
57
 
46
58
  `.github/workflows/deploy.yml` deploys the `Dockerfile` to Railway and promotes one commit through `dev`, `uat`, and `prod`. `railway.json` sets the Railway health check to `/health`. The workflow stays off until you configure it:
@@ -58,4 +70,18 @@ docker run --env PORT=3000 --publish 3000:3000 app
58
70
 
59
71
  Remove the workflow, `railway.json`, and the `deploy-dev` job in `ci.yml` if the project deploys elsewhere.
60
72
 
73
+ <!-- deploy:end -->
74
+
75
+ <!-- deploy:none -->
76
+
77
+ ## Deploy
78
+
79
+ The project has no deploy step yet. A deploy step needs:
80
+
81
+ - a workflow under `.github/workflows/` that deploys;
82
+ - the `prod` environment under **Settings → Environments**, with the release managers as its required reviewers;
83
+ - the image the `Dockerfile` builds.
84
+
85
+ <!-- deploy:end -->
86
+
61
87
  See [CONTRIBUTING.md](CONTRIBUTING.md) for the contribution workflow.
@@ -124,7 +124,7 @@ jobs:
124
124
  image:
125
125
  name: Container Image
126
126
  needs: changes
127
- if: needs.changes.outputs.full_verification == 'true' || github.event_name == 'schedule'
127
+ if: (needs.changes.outputs.full_verification == 'true' || github.event_name == 'schedule') && vars.CONTAINER_IMAGE != 'false'
128
128
  runs-on: ubuntu-latest
129
129
  timeout-minutes: 15
130
130
  env:
@@ -163,6 +163,7 @@ jobs:
163
163
  tar --extract --gzip --file "$archive" trivy
164
164
  ./trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed --no-progress app:ci
165
165
 
166
+ # deploy:railway
166
167
  deploy-dev:
167
168
  name: Deploy to dev
168
169
  needs:
@@ -177,6 +178,7 @@ jobs:
177
178
  environment: dev
178
179
  commit_sha: ${{ github.sha }}
179
180
 
181
+ # deploy:end
180
182
  triage:
181
183
  name: Triage failed build
182
184
  needs:
@@ -46,7 +46,12 @@ The `verifier` subagent starts the app with `pnpm dev`. It exercises the changed
46
46
  When Claude makes a mistake twice, add the correction here.
47
47
 
48
48
  - **First end-to-end run**: run `pnpm exec playwright install chromium` once. `pnpm test:e2e` starts the development server unless one is already running.
49
+ <!-- deploy:railway -->
49
50
  - **Production gate**: `.agents/hooks/agent-production-gate.sh` blocks a shell command that deploys to `prod`, runs a Railway CLI command that names `prod`, or pushes a release tag, unless a release manager started the session with `RELEASE_APPROVAL` set. Any value lifts the gate except an empty value, `0`, `false`, and `no`, in any letter case. Report what is ready to promote instead. When a gated command appears only inside a message, write the text to a file with your file-editing tool, not a shell heredoc, and pass it with `git commit -F <file>` or `gh pr create --body-file <file>`. The gate reads only shell commands, so an MCP tool or a Railway command without `-e` or `--environment` passes it.
51
+ <!-- deploy:end -->
52
+ <!-- deploy:none -->
53
+ - **Production gate**: `.agents/hooks/agent-production-gate.sh` blocks a shell command that deploys to `prod` or pushes a release tag, unless a release manager started the session with `RELEASE_APPROVAL` set. Any value lifts the gate except an empty value, `0`, `false`, and `no`, in any letter case. Report what is ready to promote instead. When a gated command appears only inside a message, write the text to a file with your file-editing tool, not a shell heredoc, and pass it with `git commit -F <file>` or `gh pr create --body-file <file>`. The gate reads only shell commands, so an MCP tool passes it.
54
+ <!-- deploy:end -->
50
55
  - **Issues**: open an issue only to hand work to another person, never to give a pull request a number to close. A pull request closes an issue that already exists with `Closes #<issue>`.
51
56
  - **What belongs in the repository**: files, commit messages, and pull requests hold the change, its reason, and the checks. Write no date, log, history, mistake count, token figure, or session note into a file.
52
57
  - **Sourced requirements**: add a requirement to an intent, spec, or plan only when a person decided it or a named source says it. This includes a requirement a review bot suggests. When no person decided it and no source says it, leave it out and answer the thread with that.
@@ -1,4 +1,4 @@
1
- # Runs on any OCI platform (Railway, Fly.io, Cloud Run). On Vercel, deploy without it. The platform sets `PORT`.
1
+ # Runs on any OCI container platform. A platform that builds Next.js itself deploys without it. The platform sets `PORT`.
2
2
  FROM \
3
3
  node:24.22.1-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53 \
4
4
  AS build
@@ -32,8 +32,18 @@ CI also runs the build, the end-to-end tests, the container image scan, and the
32
32
 
33
33
  ## Container
34
34
 
35
+ <!-- deploy:railway -->
36
+
35
37
  The `Dockerfile` builds a standalone production image that runs as the `node` user, for platforms such as Railway. The server listens on `PORT`, or 3000 when it is unset.
36
38
 
39
+ <!-- deploy:end -->
40
+
41
+ <!-- deploy:none -->
42
+
43
+ The `Dockerfile` builds a standalone production image that runs as the `node` user for a container platform. The server listens on `PORT`, or 3000 when it is unset.
44
+
45
+ <!-- deploy:end -->
46
+
37
47
  ```sh
38
48
  docker build -t app .
39
49
  docker run --publish 3000:3000 app
@@ -41,6 +51,8 @@ docker run --publish 3000:3000 app
41
51
 
42
52
  On Vercel, deploy without the image; the Next.js preset takes precedence over the `Dockerfile`.
43
53
 
54
+ <!-- deploy:railway -->
55
+
44
56
  ## Deploy
45
57
 
46
58
  `.github/workflows/deploy.yml` deploys the `Dockerfile` to Railway and promotes one commit through `dev`, `uat`, and `prod`. `railway.json` sets the Railway health check to `/health`. The workflow stays off until you configure it:
@@ -58,4 +70,18 @@ On Vercel, deploy without the image; the Next.js preset takes precedence over th
58
70
 
59
71
  Remove the workflow, `railway.json`, and the `deploy-dev` job in `ci.yml` if the project deploys elsewhere.
60
72
 
73
+ <!-- deploy:end -->
74
+
75
+ <!-- deploy:none -->
76
+
77
+ ## Deploy
78
+
79
+ The project has no deploy step yet. A deploy step needs:
80
+
81
+ - a workflow under `.github/workflows/` that deploys;
82
+ - the `prod` environment under **Settings → Environments**, with the release managers as its required reviewers;
83
+ - the image the `Dockerfile` builds.
84
+
85
+ <!-- deploy:end -->
86
+
61
87
  See [CONTRIBUTING.md](CONTRIBUTING.md) for the contribution workflow.
@@ -116,7 +116,7 @@ jobs:
116
116
  image:
117
117
  name: Container Image
118
118
  needs: changes
119
- if: needs.changes.outputs.full_verification == 'true' || github.event_name == 'schedule'
119
+ if: (needs.changes.outputs.full_verification == 'true' || github.event_name == 'schedule') && vars.CONTAINER_IMAGE != 'false'
120
120
  runs-on: ubuntu-latest
121
121
  timeout-minutes: 15
122
122
  env:
@@ -155,6 +155,7 @@ jobs:
155
155
  tar --extract --gzip --file "$archive" trivy
156
156
  ./trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed --no-progress app:ci
157
157
 
158
+ # deploy:railway
158
159
  deploy-dev:
159
160
  name: Deploy to dev
160
161
  needs:
@@ -169,6 +170,7 @@ jobs:
169
170
  environment: dev
170
171
  commit_sha: ${{ github.sha }}
171
172
 
173
+ # deploy:end
172
174
  triage:
173
175
  name: Triage failed build
174
176
  needs:
@@ -44,7 +44,12 @@ The `verifier` subagent starts the app with `pnpm start:dev`. It exercises the c
44
44
 
45
45
  When Claude makes a mistake twice, add the correction here.
46
46
 
47
+ <!-- deploy:railway -->
47
48
  - **Production gate**: `.agents/hooks/agent-production-gate.sh` blocks a shell command that deploys to `prod`, runs a Railway CLI command that names `prod`, or pushes a release tag, unless a release manager started the session with `RELEASE_APPROVAL` set. Any value lifts the gate except an empty value, `0`, `false`, and `no`, in any letter case. Report what is ready to promote instead. When a gated command appears only inside a message, write the text to a file with your file-editing tool, not a shell heredoc, and pass it with `git commit -F <file>` or `gh pr create --body-file <file>`. The gate reads only shell commands, so an MCP tool or a Railway command without `-e` or `--environment` passes it.
49
+ <!-- deploy:end -->
50
+ <!-- deploy:none -->
51
+ - **Production gate**: `.agents/hooks/agent-production-gate.sh` blocks a shell command that deploys to `prod` or pushes a release tag, unless a release manager started the session with `RELEASE_APPROVAL` set. Any value lifts the gate except an empty value, `0`, `false`, and `no`, in any letter case. Report what is ready to promote instead. When a gated command appears only inside a message, write the text to a file with your file-editing tool, not a shell heredoc, and pass it with `git commit -F <file>` or `gh pr create --body-file <file>`. The gate reads only shell commands, so an MCP tool passes it.
52
+ <!-- deploy:end -->
48
53
  - **Issues**: open an issue only to hand work to another person, never to give a pull request a number to close. A pull request closes an issue that already exists with `Closes #<issue>`.
49
54
  - **What belongs in the repository**: files, commit messages, and pull requests hold the change, its reason, and the checks. Write no date, log, history, mistake count, token figure, or session note into a file.
50
55
  - **Sourced requirements**: add a requirement to an intent, spec, or plan only when a person decided it or a named source says it. This includes a requirement a review bot suggests. When no person decided it and no source says it, leave it out and answer the thread with that.
@@ -1,4 +1,4 @@
1
- # Runs on any OCI platform (Railway, Fly.io, Cloud Run). The platform sets `PORT`.
1
+ # Runs on any OCI container platform. The platform sets `PORT`.
2
2
  FROM \
3
3
  node:24.22.1-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53 \
4
4
  AS base
@@ -32,13 +32,25 @@ CI also runs the build, the container image scan, and the security scans, includ
32
32
 
33
33
  ## Container
34
34
 
35
+ <!-- deploy:railway -->
36
+
35
37
  The `Dockerfile` builds a production image that runs as a non-root user (UID 1000). The platform must set `PORT`; Railway sets it automatically. The build needs `pnpm-lock.yaml`, so run `pnpm install` once before the first `docker build`.
36
38
 
39
+ <!-- deploy:end -->
40
+
41
+ <!-- deploy:none -->
42
+
43
+ The `Dockerfile` builds a production image that runs as a non-root user (UID 1000). The platform must set `PORT`. The build needs `pnpm-lock.yaml`, so run `pnpm install` once before the first `docker build`.
44
+
45
+ <!-- deploy:end -->
46
+
37
47
  ```sh
38
48
  docker build -t app .
39
49
  docker run --env PORT=3000 --publish 3000:3000 app
40
50
  ```
41
51
 
52
+ <!-- deploy:railway -->
53
+
42
54
  ## Deploy
43
55
 
44
56
  `.github/workflows/deploy.yml` deploys the `Dockerfile` to Railway and promotes one commit through `dev`, `uat`, and `prod`. `railway.json` sets the Railway health check to `/health`. The workflow stays off until you configure it:
@@ -56,4 +68,18 @@ docker run --env PORT=3000 --publish 3000:3000 app
56
68
 
57
69
  Remove the workflow, `railway.json`, and the `deploy-dev` job in `ci.yml` if the project deploys elsewhere.
58
70
 
71
+ <!-- deploy:end -->
72
+
73
+ <!-- deploy:none -->
74
+
75
+ ## Deploy
76
+
77
+ The project has no deploy step yet. A deploy step needs:
78
+
79
+ - a workflow under `.github/workflows/` that deploys;
80
+ - the `prod` environment under **Settings → Environments**, with the release managers as its required reviewers;
81
+ - the image the `Dockerfile` builds.
82
+
83
+ <!-- deploy:end -->
84
+
59
85
  See [CONTRIBUTING.md](CONTRIBUTING.md) for the contribution workflow.
@@ -141,7 +141,7 @@ jobs:
141
141
  image:
142
142
  name: Container Image
143
143
  needs: changes
144
- if: needs.changes.outputs.full_verification == 'true' || github.event_name == 'schedule'
144
+ if: (needs.changes.outputs.full_verification == 'true' || github.event_name == 'schedule') && vars.CONTAINER_IMAGE != 'false'
145
145
  runs-on: ubuntu-latest
146
146
  timeout-minutes: 15
147
147
  services:
@@ -196,6 +196,7 @@ jobs:
196
196
  tar --extract --gzip --file "$archive" trivy
197
197
  ./trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed --no-progress app:ci
198
198
 
199
+ # deploy:railway
199
200
  deploy-dev:
200
201
  name: Deploy to dev
201
202
  needs:
@@ -210,6 +211,7 @@ jobs:
210
211
  environment: dev
211
212
  commit_sha: ${{ github.sha }}
212
213
 
214
+ # deploy:end
213
215
  triage:
214
216
  name: Triage failed build
215
217
  needs:
@@ -49,7 +49,12 @@ The `verifier` subagent starts the app with `pnpm dev`, which needs a PostgreSQL
49
49
  When Claude makes a mistake twice, add the correction here.
50
50
 
51
51
  - **Database for `pnpm dev` and end-to-end tests**: both need a PostgreSQL database in `DATABASE_URL` and `PAYLOAD_SECRET` in `.env`; the README lists every variable. Run `pnpm exec playwright install chromium` once before the first `pnpm test:e2e`.
52
+ <!-- deploy:railway -->
52
53
  - **Production gate**: `.agents/hooks/agent-production-gate.sh` blocks a shell command that deploys to `prod`, runs a Railway CLI command that names `prod`, or pushes a release tag, unless a release manager started the session with `RELEASE_APPROVAL` set. Any value lifts the gate except an empty value, `0`, `false`, and `no`, in any letter case. Report what is ready to promote instead. When a gated command appears only inside a message, write the text to a file with your file-editing tool, not a shell heredoc, and pass it with `git commit -F <file>` or `gh pr create --body-file <file>`. The gate reads only shell commands, so an MCP tool or a Railway command without `-e` or `--environment` passes it.
54
+ <!-- deploy:end -->
55
+ <!-- deploy:none -->
56
+ - **Production gate**: `.agents/hooks/agent-production-gate.sh` blocks a shell command that deploys to `prod` or pushes a release tag, unless a release manager started the session with `RELEASE_APPROVAL` set. Any value lifts the gate except an empty value, `0`, `false`, and `no`, in any letter case. Report what is ready to promote instead. When a gated command appears only inside a message, write the text to a file with your file-editing tool, not a shell heredoc, and pass it with `git commit -F <file>` or `gh pr create --body-file <file>`. The gate reads only shell commands, so an MCP tool passes it.
57
+ <!-- deploy:end -->
53
58
  - **Issues**: open an issue only to hand work to another person, never to give a pull request a number to close. A pull request closes an issue that already exists with `Closes #<issue>`.
54
59
  - **What belongs in the repository**: files, commit messages, and pull requests hold the change, its reason, and the checks. Write no date, log, history, mistake count, token figure, or session note into a file.
55
60
  - **Sourced requirements**: add a requirement to an intent, spec, or plan only when a person decided it or a named source says it. This includes a requirement a review bot suggests. When no person decided it and no source says it, leave it out and answer the thread with that.
@@ -1,4 +1,4 @@
1
- # Runs on any OCI platform (Railway, Fly.io, Cloud Run). On Vercel, deploy without it. The platform sets `PORT`.
1
+ # Runs on any OCI container platform. A platform that builds Next.js itself deploys without it. The platform sets `PORT`.
2
2
  FROM \
3
3
  node:24.22.1-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53 \
4
4
  AS build
@@ -48,8 +48,18 @@ CI also runs the build, the end-to-end tests, the container image scan, and the
48
48
 
49
49
  ## Container
50
50
 
51
+ <!-- deploy:railway -->
52
+
51
53
  The `Dockerfile` builds a standalone production image that runs as the `node` user, for platforms such as Railway. The server listens on `PORT`, or 3000 when it is unset, and needs `DATABASE_URL`, `PAYLOAD_SECRET`, and `APP_URL`, the public URL that Payload accepts cookie-authenticated and cross-origin requests from.
52
54
 
55
+ <!-- deploy:end -->
56
+
57
+ <!-- deploy:none -->
58
+
59
+ The `Dockerfile` builds a standalone production image that runs as the `node` user for a container platform. The server listens on `PORT`, or 3000 when it is unset, and needs `DATABASE_URL`, `PAYLOAD_SECRET`, and `APP_URL`, the public URL that Payload accepts cookie-authenticated and cross-origin requests from.
60
+
61
+ <!-- deploy:end -->
62
+
53
63
  ```sh
54
64
  docker build -t app .
55
65
  docker run --publish 3000:3000 --env DATABASE_URL --env PAYLOAD_SECRET --env APP_URL app
@@ -61,6 +71,8 @@ The config sets no `email` adapter, so Payload writes every email, including pas
61
71
 
62
72
  On Vercel, deploy without the image; the Next.js preset takes precedence over the `Dockerfile`.
63
73
 
74
+ <!-- deploy:railway -->
75
+
64
76
  ## Deploy
65
77
 
66
78
  `.github/workflows/deploy.yml` deploys the `Dockerfile` to Railway and promotes one commit through `dev`, `uat`, and `prod`. `railway.json` sets the Railway health check to `/health`. The workflow stays off until you configure it:
@@ -84,4 +96,18 @@ On Vercel, deploy without the image; the Next.js preset takes precedence over th
84
96
 
85
97
  Remove the workflow, `railway.json`, and the `deploy-dev` job in `ci.yml` if the project deploys elsewhere.
86
98
 
99
+ <!-- deploy:end -->
100
+
101
+ <!-- deploy:none -->
102
+
103
+ ## Deploy
104
+
105
+ The project has no deploy step yet. A deploy step needs:
106
+
107
+ - a workflow under `.github/workflows/` that deploys;
108
+ - the `prod` environment under **Settings → Environments**, with the release managers as its required reviewers;
109
+ - the image the `Dockerfile` builds.
110
+
111
+ <!-- deploy:end -->
112
+
87
113
  See [CONTRIBUTING.md](CONTRIBUTING.md) for the contribution workflow.
@@ -212,7 +212,7 @@ jobs:
212
212
  needs:
213
213
  - changes
214
214
  - affected-apps
215
- if: (needs.changes.outputs.full_verification == 'true' || github.event_name == 'schedule') && needs.affected-apps.outputs.apps != '[]'
215
+ if: (needs.changes.outputs.full_verification == 'true' || github.event_name == 'schedule') && needs.affected-apps.outputs.apps != '[]' && vars.CONTAINER_IMAGE != 'false'
216
216
  runs-on: ubuntu-latest
217
217
  timeout-minutes: 20
218
218
  strategy:
@@ -272,6 +272,7 @@ jobs:
272
272
  tar --extract --gzip --file "$archive" trivy
273
273
  ./trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed --no-progress "$APP:ci"
274
274
 
275
+ # deploy:railway
275
276
  deploy-dev:
276
277
  name: Deploy to dev
277
278
  needs:
@@ -286,6 +287,7 @@ jobs:
286
287
  environment: dev
287
288
  commit_sha: ${{ github.sha }}
288
289
 
290
+ # deploy:end
289
291
  triage:
290
292
  name: Triage failed build
291
293
  needs:
@@ -52,7 +52,12 @@ When Claude makes a mistake twice, add the correction here.
52
52
 
53
53
  - **Next.js docs live per app**: this Next.js version differs from your training data. Read the guide in `apps/app/node_modules/next/dist/docs/` or `apps/backoffice/node_modules/next/dist/docs/` before writing Next.js code; the workspace root has no `next` package.
54
54
  - **Database for `pnpm dev` and end-to-end tests**: the backoffice needs a PostgreSQL database in `DATABASE_URL` and `PAYLOAD_SECRET` in `.env`; the README lists every variable. Run `pnpm --filter ./apps/app exec playwright install chromium` once before the first `pnpm test:e2e`.
55
+ <!-- deploy:railway -->
55
56
  - **Production gate**: `.agents/hooks/agent-production-gate.sh` blocks a shell command that deploys to `prod`, runs a Railway CLI command that names `prod`, or pushes a release tag, unless a release manager started the session with `RELEASE_APPROVAL` set. Any value lifts the gate except an empty value, `0`, `false`, and `no`, in any letter case. Report what is ready to promote instead. When a gated command appears only inside a message, write the text to a file with your file-editing tool, not a shell heredoc, and pass it with `git commit -F <file>` or `gh pr create --body-file <file>`. The gate reads only shell commands, so an MCP tool or a Railway command without `-e` or `--environment` passes it.
57
+ <!-- deploy:end -->
58
+ <!-- deploy:none -->
59
+ - **Production gate**: `.agents/hooks/agent-production-gate.sh` blocks a shell command that deploys to `prod` or pushes a release tag, unless a release manager started the session with `RELEASE_APPROVAL` set. Any value lifts the gate except an empty value, `0`, `false`, and `no`, in any letter case. Report what is ready to promote instead. When a gated command appears only inside a message, write the text to a file with your file-editing tool, not a shell heredoc, and pass it with `git commit -F <file>` or `gh pr create --body-file <file>`. The gate reads only shell commands, so an MCP tool passes it.
60
+ <!-- deploy:end -->
56
61
  - **Issues**: open an issue only to hand work to another person, never to give a pull request a number to close. A pull request closes an issue that already exists with `Closes #<issue>`.
57
62
  - **What belongs in the repository**: files, commit messages, and pull requests hold the change, its reason, and the checks. Write no date, log, history, mistake count, token figure, or session note into a file.
58
63
  - **Sourced requirements**: add a requirement to an intent, spec, or plan only when a person decided it or a named source says it. This includes a requirement a review bot suggests. When no person decided it and no source says it, leave it out and answer the thread with that.
@@ -61,6 +61,8 @@ Uploaded images, up to 10 MB each, are written to the backoffice container files
61
61
 
62
62
  On Vercel, deploy an app without its image. The Next.js preset takes precedence over the Dockerfile.
63
63
 
64
+ <!-- deploy:railway -->
65
+
64
66
  ## Deploy
65
67
 
66
68
  `.github/workflows/deploy.yml` deploys both apps and promotes one commit through `dev`, `uat`, and `prod`. `railway.json` sets the Railway health check to `/health`. Create one Railway service per app, with that service's Dockerfile path set to `apps/app/Dockerfile` or `apps/backoffice/Dockerfile`. The workflow stays off until you configure it:
@@ -86,6 +88,20 @@ On Vercel, deploy an app without its image. The Next.js preset takes precedence
86
88
 
87
89
  Remove the workflow, `railway.json`, and the `deploy-dev` job in `ci.yml` if the project deploys elsewhere.
88
90
 
91
+ <!-- deploy:end -->
92
+
93
+ <!-- deploy:none -->
94
+
95
+ ## Deploy
96
+
97
+ The project has no deploy step yet. A deploy step needs:
98
+
99
+ - a workflow under `.github/workflows/` that deploys;
100
+ - the `prod` environment under **Settings → Environments**, with the release managers as its required reviewers;
101
+ - the image the `Dockerfile` builds.
102
+
103
+ <!-- deploy:end -->
104
+
89
105
  ## Mobile app
90
106
 
91
107
  To add an Expo app, create it under `apps/mobile`:
@@ -94,6 +110,16 @@ To add an Expo app, create it under `apps/mobile`:
94
110
  2. Use `@expo/fingerprint` with `expo-github-action/fingerprint` so a pull request requests a native build only when the native layer changed ([Expo fingerprint](https://docs.expo.dev/versions/latest/sdk/fingerprint/)).
95
111
  3. Keep a single `react-native` version in the workspace. Duplicate React Native versions in one monorepo are not supported ([Expo: Monorepos](https://docs.expo.dev/guides/monorepos/)).
96
112
 
113
+ <!-- deploy:railway -->
114
+
97
115
  EAS Build and Submit replace Railway for that app. CI `--affected` already includes `apps/*` and `packages/*`, so an Expo app is skipped when it did not change.
98
116
 
117
+ <!-- deploy:end -->
118
+
119
+ <!-- deploy:none -->
120
+
121
+ EAS Build and Submit deploy that app, in place of the project's deploy step. CI `--affected` already includes `apps/*` and `packages/*`, so an Expo app is skipped when it did not change.
122
+
123
+ <!-- deploy:end -->
124
+
99
125
  See [CONTRIBUTING.md](CONTRIBUTING.md) for the contribution workflow.
@@ -1,4 +1,4 @@
1
- # Runs on any OCI platform (Railway, Fly.io, Cloud Run). On Vercel, deploy without it. The platform sets `PORT`.
1
+ # Runs on any OCI container platform. A platform that builds Next.js itself deploys without it. The platform sets `PORT`.
2
2
  FROM \
3
3
  node:24.22.1-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53 \
4
4
  AS build
@@ -1,4 +1,4 @@
1
- # Runs on any OCI platform (Railway, Fly.io, Cloud Run). On Vercel, deploy without it. The platform sets `PORT`.
1
+ # Runs on any OCI container platform. A platform that builds Next.js itself deploys without it. The platform sets `PORT`.
2
2
  FROM \
3
3
  node:24.22.1-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53 \
4
4
  AS build
@@ -3,13 +3,7 @@
3
3
  "futureFlags": {
4
4
  "affectedUsingTaskInputs": true
5
5
  },
6
- "globalDependencies": [
7
- ".dockerignore",
8
- ".env",
9
- "package.json",
10
- "pnpm-workspace.yaml",
11
- "railway.json"
12
- ],
6
+ "globalDependencies": [".dockerignore", ".env", "package.json", "pnpm-workspace.yaml"],
13
7
  "globalPassThroughEnv": [
14
8
  "APP_URL",
15
9
  "CI",