@theholocron/cli 3.31.1 → 3.33.0

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.
@@ -366,6 +366,12 @@ interface Deployment extends ProviderIdentity {
366
366
  target?: DeploymentTrigger;
367
367
  }): Promise<DeploymentRecord>;
368
368
  getDeployment(deploymentId: string): Promise<DeploymentRecord>;
369
+ /**
370
+ * Add a custom domain (or wildcard) to the project. Idempotent — no-op
371
+ * when the domain is already present. Optional: providers without a custom
372
+ * domain API omit this (e.g. Vercel manages domains separately).
373
+ */
374
+ ensureCustomDomain?(projectId: string, hostname: string): Promise<void>;
369
375
  }
370
376
  interface StorageBranch {
371
377
  id: string;
package/dist/cli.mjs CHANGED
@@ -2830,21 +2830,120 @@ function generateThinCallerContent(name, withOverrides, additionalPaths) {
2830
2830
  return injected;
2831
2831
  }
2832
2832
  /**
2833
+ * Extract the Cloudflare Pages preview config from a deploy workflow's `with:` object.
2834
+ *
2835
+ * Accepts three forms:
2836
+ * - `preview: true` — derive both project and domain from org context
2837
+ * - `preview: { project: "..." }` — explicit project; domain derived from context if omitted
2838
+ * - `preview: { project: "...", domain: "..." }` — fully explicit
2839
+ *
2840
+ * Returns null when `preview:` is absent, false, or can't be resolved.
2841
+ */
2842
+ function extractPreviewConfig(raw, ctx = {}) {
2843
+ const preview = raw["preview"];
2844
+ if (!preview) return null;
2845
+ if (preview === true) {
2846
+ const project = ctx.org ? `${ctx.org}-preview` : null;
2847
+ const domain = ctx.domain ? `preview.${ctx.domain}` : void 0;
2848
+ if (!project) return null;
2849
+ return {
2850
+ project,
2851
+ ...domain ? { domain } : {}
2852
+ };
2853
+ }
2854
+ if (typeof preview !== "object") return null;
2855
+ const p = preview;
2856
+ const project = typeof p["project"] === "string" && p["project"] ? p["project"] : ctx.org ? `${ctx.org}-preview` : null;
2857
+ if (!project) return null;
2858
+ const domain = typeof p["domain"] === "string" && p["domain"] ? p["domain"] : ctx.domain ? `preview.${ctx.domain}` : void 0;
2859
+ return {
2860
+ project,
2861
+ ...domain ? { domain } : {}
2862
+ };
2863
+ }
2864
+ /**
2865
+ * Generate the full thin-caller YAML for a `deploy.yml` that handles both
2866
+ * production (push to main → GitHub Pages) and preview (pull_request →
2867
+ * Cloudflare Pages) in a single file.
2868
+ *
2869
+ * Both jobs receive the same docs/storybook `with:` inputs. If the per-repo
2870
+ * config supplies `cloudflare-project` it is forwarded; otherwise the reusable
2871
+ * falls back to the `CLOUDFLARE_PAGES_PROJECT` org variable — set that once and
2872
+ * all repos with a `deploy` workflow get previews without per-repo config.
2873
+ */
2874
+ function generateCombinedDeployContent(deployWith, paths, preview) {
2875
+ const yamlScalar = (v) => {
2876
+ if (v === true) return "true";
2877
+ if (v === false) return "false";
2878
+ const s = String(v);
2879
+ return s.startsWith("[") || s.startsWith("{") ? `'${s}'` : s;
2880
+ };
2881
+ const withLines = (entries) => Object.entries(entries).map(([k, v]) => ` ${k}: ${yamlScalar(v)}`).join("\n");
2882
+ const pathsBlock = paths.length > 0 ? ` paths:\n${paths.map((p) => ` - ${p}\n`).join("")}` : "";
2883
+ const previewWith = {
2884
+ ...deployWith,
2885
+ "cloudflare-project": preview.project
2886
+ };
2887
+ const deployWithBlock = Object.keys(deployWith).length > 0 ? ` with:\n${withLines(deployWith)}\n` : "";
2888
+ const previewWithBlock = ` with:\n${withLines(previewWith)}\n`;
2889
+ return [
2890
+ `name: Deploy`,
2891
+ ``,
2892
+ `on: # yamllint disable-line rule:truthy`,
2893
+ ` push:`,
2894
+ ` branches: [main]`,
2895
+ ...pathsBlock ? [`${pathsBlock}`] : [],
2896
+ ` pull_request:`,
2897
+ ` branches: [main]`,
2898
+ ...pathsBlock ? [`${pathsBlock}`] : [],
2899
+ ` workflow_dispatch:`,
2900
+ ``,
2901
+ `concurrency:`,
2902
+ ` group: $\{{ github.event_name == 'pull_request' && format('deploy-preview-{0}', github.event.pull_request.number) || 'pages' }}`,
2903
+ ` cancel-in-progress: $\{{ github.event_name == 'pull_request' }}`,
2904
+ ``,
2905
+ `permissions:`,
2906
+ ` contents: read`,
2907
+ ` pages: write`,
2908
+ ` id-token: write`,
2909
+ ` pull-requests: write`,
2910
+ ``,
2911
+ `jobs:`,
2912
+ ` deploy:`,
2913
+ ` name: Deploy`,
2914
+ ` if: \${{ github.event_name != 'pull_request' }}`,
2915
+ ` uses: theholocron/.github/.github/workflows/deploy.yml@main`,
2916
+ ...deployWithBlock ? [deployWithBlock.trimEnd()] : [],
2917
+ ` secrets: inherit`,
2918
+ ``,
2919
+ ` preview:`,
2920
+ ` name: Deploy Preview`,
2921
+ ` if: \${{ github.event_name == 'pull_request' }}`,
2922
+ ` uses: theholocron/.github/.github/workflows/deploy-preview.yml@main`,
2923
+ previewWithBlock.trimEnd(),
2924
+ ` secrets: inherit`,
2925
+ ``
2926
+ ].join("\n");
2927
+ }
2928
+ /**
2833
2929
  * Expand structured with-values to flat GitHub Actions inputs before
2834
2930
  * generating the thin caller. Handles:
2835
2931
  * - deploy shorthand: docs/storybook → type + storybook-projects
2932
+ * - preview: stripped (handled separately via extractPreviewConfig)
2836
2933
  * - run-chromatic object → run-chromatic: true + chromatic-projects
2837
2934
  * - plain arrays → JSON-stringified for YAML scalar quoting
2838
2935
  *
2839
2936
  * Used by both `holocron setup` and `sync-workflow-templates`.
2840
2937
  */
2841
- function normalizeWorkflowWith(raw) {
2938
+ function normalizeWorkflowWith(raw, repoName) {
2842
2939
  const result = { ...raw };
2940
+ delete result["preview"];
2843
2941
  const hasDocs = raw["docs"] === true || raw["docs"] !== null && typeof raw["docs"] === "object";
2844
2942
  const storybookProjects = raw["storybook"];
2845
2943
  if (hasDocs) {
2846
2944
  result["type"] = "docs";
2847
2945
  delete result["docs"];
2946
+ if (!result["name"] && repoName) result["name"] = repoName;
2848
2947
  }
2849
2948
  if (Array.isArray(storybookProjects)) {
2850
2949
  if (!hasDocs) result["type"] = "storybook";
@@ -3390,7 +3489,7 @@ async function runSetup(input) {
3390
3489
  for (const entry of workflows) {
3391
3490
  const name = typeof entry === "string" ? entry : entry.name;
3392
3491
  const rawWith = typeof entry === "object" ? entry.with : void 0;
3393
- const withOverrides = rawWith ? normalizeWorkflowWith(rawWith) : void 0;
3492
+ const withOverrides = rawWith ? normalizeWorkflowWith(rawWith, config.name) : void 0;
3394
3493
  const additionalPaths = (typeof entry === "object" ? entry.paths : void 0) ?? (name === "deploy" && rawWith ? deriveDeployPaths(rawWith) : void 0);
3395
3494
  if (name === "test" && withOverrides) {
3396
3495
  const runUnit = withOverrides["run-unit"];
@@ -3407,6 +3506,20 @@ async function runSetup(input) {
3407
3506
  print(formatStep(steps[steps.length - 1]));
3408
3507
  continue;
3409
3508
  }
3509
+ if (name === "deploy" && rawWith) {
3510
+ const previewCfg = extractPreviewConfig(rawWith, {
3511
+ org: config.org,
3512
+ domain: config.domain
3513
+ });
3514
+ if (previewCfg) {
3515
+ const paths = additionalPaths;
3516
+ steps.push(await runStep("source", "write workflow deploy (with preview)", dryRun, async () => {
3517
+ await source.writeWorkflowFile("deploy.yml", workflowHeader() + generateCombinedDeployContent(withOverrides, paths, previewCfg));
3518
+ }));
3519
+ print(formatStep(steps[steps.length - 1]));
3520
+ continue;
3521
+ }
3522
+ }
3410
3523
  steps.push(await runStep("source", `write workflow ${name}`, dryRun, async () => {
3411
3524
  await source.writeWorkflowFile(`${name}.yml`, workflowHeader() + generateThinCallerContent(name, withOverrides, additionalPaths));
3412
3525
  }));
@@ -3578,6 +3691,28 @@ async function runSetup(input) {
3578
3691
  await deploy.ensureProject({ name: config.name });
3579
3692
  }));
3580
3693
  print(formatStep(steps[steps.length - 1]));
3694
+ const deployEntry = (config.workflows ?? []).map((e) => typeof e === "string" ? { name: e } : e).find((e) => e.name === "deploy");
3695
+ const previewCfg = deployEntry?.with ? extractPreviewConfig(deployEntry.with) : null;
3696
+ if (previewCfg?.domain && deploy.ensureCustomDomain) {
3697
+ const wildcardDomain = `*.${previewCfg.domain}`;
3698
+ steps.push(await runStep("deployment", `ensureCustomDomain ${wildcardDomain}`, dryRun, async () => {
3699
+ await deploy.ensureCustomDomain(previewCfg.project, wildcardDomain);
3700
+ }));
3701
+ print(formatStep(steps[steps.length - 1]));
3702
+ }
3703
+ if (previewCfg?.domain && loader.has("dns")) {
3704
+ const dns = loader.get("dns");
3705
+ const wildcardDomain = `*.${previewCfg.domain}`;
3706
+ steps.push(await runStep("dns", `upsertRecord ${wildcardDomain}`, dryRun, async () => {
3707
+ await dns.upsertRecord(previewCfg.domain, {
3708
+ type: "CNAME",
3709
+ name: wildcardDomain,
3710
+ content: `${previewCfg.project}.pages.dev`,
3711
+ ttl: 1
3712
+ });
3713
+ }));
3714
+ print(formatStep(steps[steps.length - 1]));
3715
+ }
3581
3716
  }
3582
3717
  if (loader.has("auth")) {
3583
3718
  const auth = loader.get("auth");
@@ -4480,6 +4615,9 @@ var dependencies_default = "name: Dependencies\n\non: # yamllint disable-line ru
4480
4615
  //#region src/templates/workflows/deploy.yml
4481
4616
  var deploy_default = "name: Deploy\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n inputs:\n type:\n description: \"Type of deployment: docs or storybook\"\n required: true\n type: string\n name:\n description: Repo name prefix used to filter the docs site package (<name>-site); omit to run pnpm -C docs build\n required: false\n type: string\n default: \"\"\n storybook-projects:\n description: >\n JSON array of { \"name\"?, \"workingDir\", \"outputDir\"? } objects for storybook deploys.\n Each is built via `pnpm -C <workingDir> build:storybook`. If \"name\" is provided the\n output is placed under `sandbox/<name>/`; omit \"name\" for single-repo deploys and the\n output lands directly in `sandbox/`.\n type: string\n required: false\n default: \"[]\"\n build-script:\n description: pnpm script that builds the Storybook static output (single storybook, type:storybook only)\n type: string\n required: false\n default: build:storybook\n output-dir:\n description: Directory where Storybook writes its static output (single storybook, type:storybook only)\n type: string\n required: false\n default: storybook-static\n\njobs:\n deploy:\n name: Deploy\n runs-on: ubuntu-latest\n permissions:\n contents: read\n pages: write\n id-token: write\n environment:\n name: github-pages\n url: ${{ steps.deployment.outputs.page_url }}\n steps:\n - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0\n name: Checkout repository\n\n - uses: theholocron/.github/.github/actions/setup@main\n name: Setup\n\n - name: Build docs site\n if: ${{ inputs.type == 'docs' && inputs.name != '' }}\n env:\n SITE_NAME: ${{ inputs.name }}\n run: pnpm --filter @theholocron/\"$SITE_NAME\"-site build\n\n - name: Build docs site\n if: ${{ inputs.type == 'docs' && inputs.name == '' }}\n run: pnpm -C docs build\n\n - name: Build Storybook projects\n if: ${{ inputs.storybook-projects != '[]' }}\n env:\n PROJECTS: ${{ inputs.storybook-projects }}\n run: |\n echo \"$PROJECTS\" | jq -c '.[]' | while IFS= read -r project; do\n workingDir=$(echo \"$project\" | jq -r '.workingDir')\n pnpm -C \"$workingDir\" build:storybook\n done\n\n - name: Build Storybook\n if: ${{ inputs.type == 'storybook' && inputs.storybook-projects == '[]' }}\n env:\n BUILD_SCRIPT: ${{ inputs.build-script }}\n run: pnpm run \"$BUILD_SCRIPT\"\n\n - name: Assemble site\n env:\n DEPLOY_TYPE: ${{ inputs.type }}\n PROJECTS: ${{ inputs.storybook-projects }}\n STORYBOOK_OUTPUT_DIR: ${{ inputs.output-dir }}\n run: |\n mkdir -p _site\n if [ \"$DEPLOY_TYPE\" = \"docs\" ]; then\n cp -r docs/dist/. _site/\n fi\n if [ \"$PROJECTS\" != \"[]\" ]; then\n echo \"$PROJECTS\" | jq -c '.[]' | while IFS= read -r project; do\n name=$(echo \"$project\" | jq -r '.name // \"\"')\n workingDir=$(echo \"$project\" | jq -r '.workingDir')\n outputDir=$(echo \"$project\" | jq -r '.outputDir // \"storybook-static\"')\n if [ -n \"$name\" ]; then\n target=\"_site/sandbox/${name}\"\n else\n target=\"_site/sandbox\"\n fi\n mkdir -p \"$target\"\n cp -r \"${workingDir}/${outputDir}/.\" \"$target/\"\n done\n elif [ \"$DEPLOY_TYPE\" = \"storybook\" ]; then\n mkdir -p _site/sandbox\n cp -r \"${STORYBOOK_OUTPUT_DIR}/.\" _site/sandbox/\n fi\n\n - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1\n name: Upload pages artifact\n with:\n path: _site\n\n - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5\n id: deployment\n name: Deploy to GitHub Pages\n";
4482
4617
  //#endregion
4618
+ //#region src/templates/workflows/deploy-preview.yml
4619
+ var deploy_preview_default = "name: Deploy Preview\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n inputs:\n type:\n description: \"Type of deployment: docs or storybook\"\n required: true\n type: string\n name:\n description: Repo name prefix used to filter the docs site package (<name>-site); omit to run pnpm -C docs build\n required: false\n type: string\n default: \"\"\n storybook-projects:\n description: >\n JSON array of { \"name\"?, \"workingDir\", \"outputDir\"? } objects for storybook deploys.\n Each is built via `pnpm -C <workingDir> build:storybook`. If \"name\" is provided the\n output is placed under `sandbox/<name>/`; omit \"name\" for single-repo deploys and the\n output lands directly in `sandbox/`.\n type: string\n required: false\n default: \"[]\"\n build-script:\n description: pnpm script that builds the Storybook static output (single storybook, type:storybook only)\n type: string\n required: false\n default: build:storybook\n output-dir:\n description: Directory where Storybook writes its static output (single storybook, type:storybook only)\n type: string\n required: false\n default: storybook-static\n cloudflare-project:\n description: >\n Cloudflare Pages project name. Falls back to the CLOUDFLARE_PAGES_PROJECT\n org variable when omitted — set that variable once and all repos get previews\n without per-repo config.\n required: false\n type: string\n default: \"\"\n\njobs:\n deploy-preview:\n name: Deploy Preview\n runs-on: ubuntu-latest\n permissions:\n contents: read\n pull-requests: write\n steps:\n - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0\n name: Checkout repository\n\n - uses: theholocron/.github/.github/actions/setup@main\n name: Setup\n\n - name: Build docs site\n if: ${{ inputs.type == 'docs' && inputs.name != '' }}\n env:\n SITE_NAME: ${{ inputs.name }}\n run: pnpm --filter @theholocron/\"$SITE_NAME\"-site build\n\n - name: Build docs site\n if: ${{ inputs.type == 'docs' && inputs.name == '' }}\n run: pnpm -C docs build\n\n - name: Build Storybook projects\n if: ${{ inputs.storybook-projects != '[]' }}\n env:\n PROJECTS: ${{ inputs.storybook-projects }}\n run: |\n echo \"$PROJECTS\" | jq -c '.[]' | while IFS= read -r project; do\n workingDir=$(echo \"$project\" | jq -r '.workingDir')\n pnpm -C \"$workingDir\" build:storybook\n done\n\n - name: Build Storybook\n if: ${{ inputs.type == 'storybook' && inputs.storybook-projects == '[]' }}\n env:\n BUILD_SCRIPT: ${{ inputs.build-script }}\n run: pnpm run \"$BUILD_SCRIPT\"\n\n - name: Assemble site\n env:\n DEPLOY_TYPE: ${{ inputs.type }}\n PROJECTS: ${{ inputs.storybook-projects }}\n STORYBOOK_OUTPUT_DIR: ${{ inputs.output-dir }}\n run: |\n mkdir -p _site\n if [ \"$DEPLOY_TYPE\" = \"docs\" ]; then\n cp -r docs/dist/. _site/\n fi\n if [ \"$PROJECTS\" != \"[]\" ]; then\n echo \"$PROJECTS\" | jq -c '.[]' | while IFS= read -r project; do\n name=$(echo \"$project\" | jq -r '.name // \"\"')\n workingDir=$(echo \"$project\" | jq -r '.workingDir')\n outputDir=$(echo \"$project\" | jq -r '.outputDir // \"storybook-static\"')\n if [ -n \"$name\" ]; then\n target=\"_site/sandbox/${name}\"\n else\n target=\"_site/sandbox\"\n fi\n mkdir -p \"$target\"\n cp -r \"${workingDir}/${outputDir}/.\" \"$target/\"\n done\n elif [ \"$DEPLOY_TYPE\" = \"storybook\" ]; then\n mkdir -p _site/sandbox\n cp -r \"${STORYBOOK_OUTPUT_DIR}/.\" _site/sandbox/\n fi\n\n - uses: cloudflare/pages-action@f0a1cd58cd66095dee69bfa18fa5efd1dde93bca # v1.5.0\n name: Deploy to Cloudflare Pages\n # Skip when neither the per-repo input nor the org variable is set.\n if: ${{ inputs.cloudflare-project != '' || vars.CLOUDFLARE_PAGES_PROJECT != '' }}\n with:\n apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}\n accountId: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}\n projectName: ${{ inputs.cloudflare-project || vars.CLOUDFLARE_PAGES_PROJECT }}\n directory: _site\n gitHubToken: ${{ secrets.GITHUB_TOKEN }}\n # Branch name scoped to repo+PR so the wildcard custom domain\n # *.preview.theholocron.dev resolves to <repo>-pr-<n>.preview.theholocron.dev\n branch: ${{ github.event.repository.name }}-pr-${{ github.event.pull_request.number }}\n";
4620
+ //#endregion
4483
4621
  //#region src/templates/workflows/greetings.yml
4484
4622
  var greetings_default = "name: Greetings\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n\njobs:\n greeting:\n name: Greet first-time contributors\n permissions:\n issues: write\n pull-requests: write\n runs-on: ubuntu-latest\n timeout-minutes: 5\n # Group by the issue/PR number so duplicate events don't race each other.\n steps:\n - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0\n name: Greet on first contribution\n with:\n script: |\n // Only greet on the initial open — ignore synchronize, reopened, etc.\n if (context.payload.action !== 'opened') return;\n\n const actor = context.actor;\n const { owner, repo } = context.repo;\n\n // Payload inspection is more reliable than context.eventName for detecting\n // whether this is an issue vs. PR event — works regardless of how GitHub\n // propagates event names through workflow_call chains.\n const isIssue = !!context.payload.issue && !context.payload.pull_request;\n // listForRepo returns both issues and PRs (GitHub treats PRs as issues),\n // sorted newest-first. Filter by type to track first-issue and first-PR\n // independently, and avoid search-index eventual-consistency lag.\n const { data: recent } = await github.rest.issues.listForRepo({\n owner, repo,\n creator: actor,\n state: 'all',\n per_page: 100\n });\n\n const sameType = recent.filter(item =>\n isIssue ? !item.pull_request : !!item.pull_request\n );\n\n if (sameType.length !== 1) return;\n const body = isIssue\n ? `Hey @${actor}!\\n\\nWe really appreciate you taking the time to report an issue. The collaborators on this project attempt to help as many people as possible, but we are a limited number of volunteers, so it is possible that this will not be addressed as swiftly.\\n\\nYour patience is much appreciated and we will get back to you as quickly as possible.`\n : `Hey @${actor}!\\n\\nWe really appreciate you taking the time to help out with this PR. The collaborators on this project attempt to help as many people as possible, but we are a limited number of volunteers, so it is possible that this will not be addressed as swiftly.\\n\\nYour patience is much appreciated and we will get back to you as quickly as possible.`;\n\n await github.rest.issues.createComment({\n owner,\n repo,\n issue_number: context.issue.number,\n body\n });\n";
4485
4623
  //#endregion
@@ -4537,6 +4675,7 @@ const REUSABLE_WORKFLOWS = {
4537
4675
  codeql: codeql_default,
4538
4676
  dependencies: dependencies_default,
4539
4677
  deploy: deploy_default,
4678
+ "deploy-preview": deploy_preview_default,
4540
4679
  greetings: greetings_default,
4541
4680
  lint: lint_default,
4542
4681
  "post-release": post_release_default,
@@ -4635,6 +4774,20 @@ function parseWorkflowsFromTs(source) {
4635
4774
  for (const entry of explicitEntries) merged.set(entry.name, entry);
4636
4775
  return [...merged.values()];
4637
4776
  }
4777
+ /**
4778
+ * Extract org name and docs domain from a `holocron.config.ts` source string.
4779
+ * Used to resolve `preview: true` to `{ project: "<org>-preview", domain: "preview.<docsDomain>" }`.
4780
+ */
4781
+ function parseOrgContextFromTs(source) {
4782
+ const orgMatch = source.match(/\borg\s*:\s*["']([^"']+)["']/);
4783
+ const domainMatch = source.match(/\bdomain\s*:\s*["']([^"']+)["']/);
4784
+ const nameMatch = source.split(/\bworkflows\s*:/)[0].match(/\bname\s*:\s*["']([^"']+)["']/);
4785
+ return {
4786
+ org: orgMatch?.[1],
4787
+ domain: domainMatch?.[1],
4788
+ repoName: nameMatch?.[1]
4789
+ };
4790
+ }
4638
4791
  function reusableHeader(source) {
4639
4792
  return [
4640
4793
  `# AUTO-GENERATED — do not edit in theholocron/.github directly.`,
@@ -4645,7 +4798,7 @@ function reusableHeader(source) {
4645
4798
  ``
4646
4799
  ].join("\n");
4647
4800
  }
4648
- function buildBatch(repo, allowedWorkflows, withOverrides) {
4801
+ function buildBatch(repo, allowedWorkflows, withOverrides, orgContext) {
4649
4802
  const files = [];
4650
4803
  const isPrimaryGithubRepo = repo === DEFAULT_REPO;
4651
4804
  if (isPrimaryGithubRepo) for (const [name, content] of Object.entries(ACTIONS)) files.push({
@@ -4669,9 +4822,22 @@ function buildBatch(repo, allowedWorkflows, withOverrides) {
4669
4822
  });
4670
4823
  }
4671
4824
  } else for (const name of Object.keys(REUSABLE_WORKFLOWS)) {
4825
+ if (name === "deploy-preview") continue;
4672
4826
  if (allowedWorkflows && !allowedWorkflows.has(name)) continue;
4673
4827
  const rawWith = withOverrides?.get(name);
4674
- const content = generateThinCallerContent(name, rawWith ? normalizeWorkflowWith(rawWith) : void 0, name === "deploy" && rawWith ? deriveDeployPaths(rawWith) : void 0);
4828
+ const normalizedWith = rawWith ? normalizeWorkflowWith(rawWith, orgContext?.repoName) : void 0;
4829
+ const additionalPaths = name === "deploy" && rawWith ? deriveDeployPaths(rawWith) : void 0;
4830
+ if (name === "deploy" && rawWith) {
4831
+ const previewCfg = extractPreviewConfig(rawWith, orgContext);
4832
+ if (previewCfg) {
4833
+ files.push({
4834
+ path: `.github/workflows/deploy.yml`,
4835
+ content: workflowHeader() + generateCombinedDeployContent(normalizedWith, additionalPaths, previewCfg)
4836
+ });
4837
+ continue;
4838
+ }
4839
+ }
4840
+ const content = generateThinCallerContent(name, normalizedWith, additionalPaths);
4675
4841
  if (!content) continue;
4676
4842
  files.push({
4677
4843
  path: `.github/workflows/${name}.yml`,
@@ -4751,6 +4917,7 @@ async function runSyncGithub(input) {
4751
4917
  }
4752
4918
  let allowedWorkflows;
4753
4919
  let withOverrides;
4920
+ let orgContext;
4754
4921
  if (repo !== DEFAULT_REPO) try {
4755
4922
  let entries = [];
4756
4923
  try {
@@ -4760,7 +4927,9 @@ async function runSyncGithub(input) {
4760
4927
  if (!(err instanceof ProviderApiError) || err.status !== 404) throw err;
4761
4928
  try {
4762
4929
  const data = await client.git.getContents(repo, "holocron.config.ts");
4763
- entries = parseWorkflowsFromTs(Buffer.from(data.content.replace(/\n/g, ""), "base64").toString("utf8"));
4930
+ const source = Buffer.from(data.content.replace(/\n/g, ""), "base64").toString("utf8");
4931
+ entries = parseWorkflowsFromTs(source);
4932
+ orgContext = parseOrgContextFromTs(source);
4764
4933
  } catch {}
4765
4934
  }
4766
4935
  if (entries.length > 0) {
@@ -4769,7 +4938,7 @@ async function runSyncGithub(input) {
4769
4938
  if (overrideEntries.length > 0) withOverrides = new Map(overrideEntries);
4770
4939
  }
4771
4940
  } catch {}
4772
- const batch = buildBatch(repo, allowedWorkflows, withOverrides);
4941
+ const batch = buildBatch(repo, allowedWorkflows, withOverrides, orgContext);
4773
4942
  let created = 0;
4774
4943
  let updated = 0;
4775
4944
  let unchanged = 0;