Skip to main content

Workflows

Generate GitHub Actions workflow files under .github/workflows/ from the "workflows" array in nova.config.json.

The generator validates each entry against its template, builds the workflow from your config, resolves variable names, and writes the YAML to disk.

Why Use This Command?

  • Bootstraps a full set of CI/CD workflows without writing YAML by hand.
  • Publishes one project to multiple destinations (npm, GitHub Packages, Docker, hosting) from a single build, instead of each destination running its own pipeline from scratch.
  • Serializes dependent publishes (e.g., a preset waits for its core dependency to reach the registry) via after on deploy destinations.
  • Keeps workflow definitions consistent across Nova-managed repositories.

Requirements

  • Node.js runtime — Use any Node.js LTS release.
  • Project root — Run the command from the directory containing the top-level package.json.
  • Configured workflows — The "workflows" array must exist in nova.config.json. Use nova utility initialize to configure it interactively.

Usage

Options

FlagDescription
-d, --dry-runRun without writing any files.
-r, --replace-fileOverwrite the existing file instead of creating a .nova-backup copy.

Renamed fields are a breaking change

Configs written for the previous generator must migrate to the new field names (name, build, deploy, with, and the per-destination to, path, after). An old-shape field is rejected with a diagnostic naming its exact replacement, and the workflow is skipped until you update it.

Config-Driven Workflow Entries

Each entry in the "workflows" array maps to one output file. An entry contains:

FieldRequiredDescription
templateYesThe name of a bundled workflow template (see Available Templates).
nameYesA label appended to the output filename and workflow name. Must be unique within the same template.
triggersYesAn array of triggers (see Available Triggers). Each trigger must be valid for the template.
buildNoAn array of workspace paths (keys from workspaces) to include in the check / build step filter.
deployNoAn array of publish destinations for the publish template. See Deploy Fields.
withNoKey-value pairs that remap secret / variable names or supply values for literal template variables.

Deploy Fields

Each entry in the deploy array pins one publish destination to a workspace.

FieldRequiredDescription
toYesOne of the nine target types (see Available Target Types).
pathYesPath to the workspace that owns the destination (e.g., "./packages/nova").
afterNoPaths of other same-type destinations that must finish publishing before this one starts.
withNoKey-value pairs for this destination that remap names or supply literal values, overriding the workflow-level with.

Config Examples

Publish a single package to npm:

json
{
  "template": "publish",
  "name": "project",
  "triggers": ["release"],
  "build": ["./packages/core"],
  "deploy": [
    {
      "to": "npm",
      "path": "./packages/core"
    }
  ]
}

Serialize dependent publishes with after:

json
{
  "template": "publish",
  "name": "project",
  "triggers": ["release"],
  "build": [
    "./packages/core",
    "./packages/preset"
  ],
  "deploy": [
    {
      "to": "npm",
      "path": "./packages/core"
    },
    {
      "to": "npm",
      "path": "./packages/preset",
      "after": ["./packages/core"]
    }
  ]
}

The preset destination waits for core to finish publishing before it starts, so consumers that pull [email protected] can always resolve its declared dependency on [email protected].

Publish to multiple destinations from one build:

json
{
  "template": "publish",
  "name": "project",
  "triggers": ["release"],
  "build": [
    "./packages/sdk",
    "./apps/docs"
  ],
  "deploy": [
    {
      "to": "npm",
      "path": "./packages/sdk"
    },
    {
      "to": "github-packages",
      "path": "./packages/sdk"
    },
    {
      "to": "cloudflare-pages-docusaurus",
      "path": "./apps/docs"
    }
  ]
}

Each destination becomes its own publish job. Jobs run in parallel off a shared build job, except where after declares an explicit ordering.

Available Templates

The generator bundles three templates, each with its own trigger set.

TemplateDescriptionNotes
publishBuild once and publish to one or more destinationsUses deploy. Each destination declares its own secret / variable setup.
lock-inactive-issuesLock stale issuesNo manual setup needed (uses automatic GITHUB_TOKEN). Pick a cadence via triggers.
check-sponsor-gated-issuesGate issues by GitHub sponsor statusRequires a PERSONAL_ACCESS_TOKEN secret plus several var-format with entries (see check-sponsor-gated-issues Variables).

check-sponsor-gated-issues Variables

This template declares the following variables. GITHUB_TOKEN is supplied automatically, so the only secret you create manually is PERSONAL_ACCESS_TOKEN. The remaining values are var-format, not literal - they resolve to ${{ vars.NAME }} references and are configured as GitHub repository variables.

VariableFormatDescription
GITHUB_TOKENsecretAutomatic token supplied by GitHub Actions (no manual setup).
PERSONAL_ACCESS_TOKENsecretPersonal access token with read:org and read:user scopes for sponsor lookups.
ISSUE_LABELSvarComma-separated labels to apply when gating issues.
ISSUE_LIMIT_COMMENTERvarWhether to limit comments to the issue opener and repo owners.
ISSUE_LOCK_ON_CLOSEvarWhether to lock issues when closed.
ISSUE_MESSAGE_NOT_SPONSORvarMessage shown to non-sponsors when they open an issue.
ISSUE_MESSAGE_WELCOMEvarWelcome message shown to sponsors when they open an issue.
IS_ORGANIZATIONvarWhether the repository owner is an organization.
SPONSOR_ACTIVE_ONLYvarWhether to require active sponsorship status.
SPONSOR_EXEMPT_FILE_LOCATIONvarPath to a file listing users exempt from sponsor requirements.
SPONSOR_MINIMUMvarMinimum monthly sponsorship amount in dollars.

Available Target Types

The publish template supports the following to values for each entry in deploy. Each type declares its own secrets and variables.

to valueDescriptionSecrets / Variables
npmPublish a package to npmNPM_TOKEN (secret) - npm access token for publishing. Skip creating this secret in GitHub if using OIDC trusted publishing.
github-actionPublish a JavaScript GitHub Action via orphan tagsNone to configure on GitHub. Its ACTION_ENTRY_POINT, ACTION_OUTPUT_PATH, ACTION_YML_PATH, and RELEASE_BRANCH_NAME are literal values supplied via with (see Publishing GitHub Actions).
github-packagesPublish a package to GitHub PackagesGITHUB_TOKEN (secret, supplied automatically - no manual setup).
docker-hubPublish a Docker image to Docker HubDOCKERHUB_TOKEN (secret) - Docker Hub access token for publishing images. DOCKERHUB_USERNAME (var) - Docker Hub username or organization name.
ghcrPublish a Docker image to GitHub Container RegistryGITHUB_TOKEN (secret, supplied automatically - no manual setup).
cloudflare-pages-docusaurusDeploy a Docusaurus site to Cloudflare PagesCLOUDFLARE_API_TOKEN (secret) - Cloudflare API token with Pages deployment permissions. CLOUDFLARE_ACCOUNT_ID (var) - Cloudflare account ID. CLOUDFLARE_PROJECT_NAME (var) - Cloudflare Pages project name.
cloudflare-workersDeploy a Worker with static assets to Cloudflare WorkersCLOUDFLARE_API_TOKEN (secret) - Cloudflare API token with Workers deployment permissions. CLOUDFLARE_ACCOUNT_ID (var) - Cloudflare account ID.
github-pages-docusaurusDeploy a Docusaurus site to GitHub PagesGITHUB_TOKEN (secret, supplied automatically - no manual setup).
vercel-nextjsDeploy a Next.js app to VercelVERCEL_TOKEN (secret) - Vercel access token with deploy permissions. VERCEL_ORG_ID (var) - Vercel organization ID. VERCEL_PROJECT_ID (var) - Vercel project ID.

Available Triggers

Each template accepts a specific set of triggers, and a trigger outside that set is rejected.

TemplateTriggers
publishrelease, tag-push, push, workflow-run-success, workflow-run-failure, workflow-run-any
lock-inactive-issuesschedule-daily, schedule-weekly, schedule-monthly
check-sponsor-gated-issuesissues, issue-comment
TriggerCarries workflows
releaseNo
tag-pushNo
pushNo
schedule-dailyNo
schedule-weeklyNo
schedule-monthlyNo
issuesNo
issue-commentNo
workflow-run-successYes
workflow-run-failureYes
workflow-run-anyYes

The publish and lock-inactive-issues templates also include workflow_dispatch (manual trigger with dry-run option) automatically.

Configuring Publish Triggers

The publish template accepts three release-style triggers, each with its own config form.

  • release — Publishes when a GitHub Release is published. Configure it with the bare string "release".
  • tag-push — Publishes when a version tag is pushed, emitting on: push: tags: ["v*"]. Configure it with the bare string "tag-push" (tags default to v*), or an object { "name": "tag-push", "tags": ["v*"] } to override the tag globs.
  • push — Branch continuous deployment that publishes for real on a push to a listed branch. Configure it only in object form with a required branches filter, for example { "name": "push", "branches": ["main"], "paths": ["packages/**"] }.

Publishing on a version tag keeps GITHUB_REF pointing at the tag, so npm --provenance signing stays valid. A release-triggered run can instead bind to the default branch and fail provenance, which is why tag-push is preferred for signed npm publishes.

The bare string "push" is a validation error, since a branchless push trigger would fire on every branch. The branches, paths, and tags filters must each be a non-empty array of strings when present.

Configuring Workflow-Run Triggers

The three workflow-run-* triggers chain a publish workflow to one or more upstream workflows, and each carries its upstream references on the trigger object.

  • Declare them in object form with a workflows array of <template>-<name> keys, for example { "name": "workflow-run-success", "workflows": ["publish-core"] }.
  • Each referenced key must resolve to a sibling workflow in the same config, and circular references abort the run.
json
{
  "template": "publish",
  "name": "preset",
  "triggers": [
    {
      "name": "workflow-run-success",
      "workflows": ["publish-core"]
    }
  ],
  "build": ["./packages/preset"],
  "deploy": [
    {
      "to": "npm",
      "path": "./packages/preset"
    }
  ]
}

Environment Variables and Secrets

A publish workflow delivers every app environment value declared under environment.apps. Each value carries two independent, required flags — secret (storage) and buildOnly (delivery) — and Nova routes delivery entirely off buildOnly, never off secret.

The difference between the two buildOnly destinations is when there is a live environment to read the value. A running server has one, so a buildOnly: false value sits in its env and the code reads it by reference (env.X) each run — never in a file. A build has one only while it runs on the CI runner, so a buildOnly: true value is placed in the build's env there; a Variable the code inlines (import.meta.env.X) gets baked into the output, a Secret used for auth is consumed and not emitted. "Build" means "available to the build process," not "baked into the shipped output" — that is why secret + buildOnly is valid, and why the only leak risk (a secret inlined into client output) is a code choice, not the delivery's fault.

secret never changes where a value goes; buildOnly never changes how it is stored and moved. All four combinations are real:

StoragebuildOnly: true (build env)buildOnly: false (running server)
secret: false (Variable)baked into the build (a PUBLIC_ client key)synced to the server as plaintext (AMAZON_REGION)
secret: true (Secret)available to the CI build, used transiently, not shipped (a private build token)synced to the server, encrypted (STRIPE_SECRET_KEY)

Every GitHub name is the app's prefix joined to the key (e.g. CBN_ joined to PUBLIC_SITE_KEY becomes CBN_PUBLIC_SITE_KEY).

Build Delivery

Every buildOnly: true app value is written into the build .env before check / build. Nova reads it from its GitHub Variable (${{ vars.<name> }}) when secret: false, or its GitHub Secret (${{ secrets.<name> }}) when secret: true. A secret: false Variable falls back to its defaultValue when the Variable is unset. Deploy credentials and workflow-config keys never reach the build.

The Setup report lists every GitHub Variable and Secret to create.

Runtime Secret Sync

On each deploy, Nova reconciles every buildOnly: false app value into the runtime store of every server-bearing target in the app's deploy[]. secret picks the channel: a secret: true value is stored encrypted, a secret: false value as a plaintext runtime variable.

  • Upsert — Every declared buildOnly: false value is pushed to the destination under its prefixed name, and the running app reads it as env.NAME on Workers or process.env.NAME on Vercel.
  • Delete — Any value already in the destination store but no longer declared in the config is removed, since Nova is the single source of truth for that target's runtime env.
  • Log by name — Each add and removal is written to the deploy log by name only, never by value, so the reconcile stays auditable.

Sync makes Nova authoritative

Nova has full authority over each server's runtime env. Removing a buildOnly: false value from the config deletes it from every server on the next deploy, and any value set out-of-band is reconciled away. The sync runs unattended in CI, so there is no interactive confirm — the deploy log records every change.

Per-Target Mechanism

The config is identical across both targets; only the deploy step differs.

TargetReconcile mechanismRuntime scope
cloudflare-workerswrangler secret for encrypted values and wrangler deploy --var for plaintext, run around wrangler deploy.Live worker.
vercel-nextjsvercel env add and remove for both channels, run before the prebuilt deploy.Production only.

Dev is unchanged — the values live in the local .env from nova generate must-haves dotenv, which you fill in with real values.

Variable Formats

Each template declares variables with one of three formats. The format determines how the value is substituted into the generated YAML:

FormatSubstitution behavior
secretProduces ${{ secrets.NAME }} in the output. If a with entry is provided, the secret reference uses that value as the name instead.
varProduces ${{ vars.NAME }} in the output. If a with entry is provided, the variable reference uses that value as the name instead.
literalReplaces the placeholder with the exact string from with. A literal variable without a default must be provided or the entry is skipped.

Output Filename Convention

Generated files are written to .github/workflows/ with the naming pattern:

text
nova-{template}-{name}.yml

Examples:

  • nova-lock-inactive-issues-project.yml
  • nova-publish-project.yml
  • nova-check-sponsor-gated-issues-project.yml

How It Works

Reading Config

The generator loads nova.config.json and reads the "workflows" array. If the array is missing or empty, the generator exits with no changes.

Validation

Before generating each file, the generator checks:

  1. No entry carries an old-shape field name — a renamed field is rejected with a hint naming its replacement (e.g., Workflow field "suffix" was renamed to "name". Skipping.).
  2. The template name matches a known bundled template.
  3. name is present and a non-empty string, and no two entries share the same template and name.
  4. Each trigger is valid for the entry's template, and object-form filter lists are non-empty string arrays.
  5. A push trigger uses the object form with a branches filter, and a workflow-run trigger carries workflows references that each resolve to a sibling workflow.
  6. Each build scope is a registered workspace, and no workspace still carries the retired dotenv block — that model moved to the top-level environment block classified by secret, so an old-shape config is rejected with a migration hint.
  7. Each deploy to is a supported target type, and each deploy path is a registered workspace.
  8. All literal variables without a default have a corresponding value in with.
  9. Destination uniqueness holds across every workflow (see Cross-Workflow Uniqueness), and no two entries produce the same output filename.

Each failed check emits a diagnostic and skips the affected workflow, while any valid workflows still build.

File Generation

Each valid entry produces one YAML file under .github/workflows/. The generator builds the workflow blueprint from your config — merging the resolved triggers, jobs, and deploy destinations — resolves variable placeholders from the template metadata and the entry's with, then serializes the blueprint to YAML.

Deploy jobs that declare after dependencies emit needs: ["build", ...]. If the target file already exists, a .nova-backup copy is created unless --replace-file is set.

Orphan Cleanup

Orphan cleanup runs only when the config validated with zero diagnostics. A config with any error never deletes your existing workflow files, so a rejected workflow's file is left untouched.

When the config is clean, the generator scans .github/workflows/ for existing nova-*.yml files that are not in the current config. These orphaned files are backed up (renamed with a .nova-backup timestamp) or deleted when --replace-file is set.

Setup Instructions

After all files are generated, the generator prints a Setup report of the secrets and variables to configure on GitHub. This covers every non-automatic secret and var, plus every buildOnly: true app value each build scope reads — listed under its prefixed GitHub name as a Variable (secret: false) or a Secret (secret: true).

Entries are deduplicated and listed once per output file, so a value used by two destinations in the same workflow appears a single time.

Publishing GitHub Actions

The github-action destination distributes a JavaScript GitHub Action by maintaining an orphan releases branch whose commits contain only the runtime files needed by consumers. Each release is tagged with its semver (e.g., v2.1.0), and the floating major tag (v2) is force-moved to the latest release commit. Older frozen major tags (e.g., v1) are never touched.

Expected Repo Shape

  • action.yml at the repo root (top-level, not inside a workspace).
  • packages/<action-name>/ — action source (role: app, policy: trackable).
  • apps/docs/ — Docusaurus docs (role: docs).
  • Build output in the workspace's build/ directory, following the standard Nova convention.

Release Tree Contents

What lands on the releases branch for each release:

Required (publish fails if missing):

  • action.yml
  • action/ — the built output (renamed from the workspace's build/ directory during publish).

Optional (copied if present at repo root, skipped silently if not):

  • README.md
  • LICENSE
  • SECURITY.md
  • NOTICE
  • CHANGELOG.md

Example nova.config.json Entry

json
{
  "workflows": [{
    "template": "publish",
    "name": "release",
    "triggers": ["release"],
    "deploy": [
      {
        "to": "github-action",
        "path": "./packages/my-action",
        "with": {
          "ACTION_ENTRY_POINT": "index.js",
          "ACTION_OUTPUT_PATH": "./packages/my-action/build",
          "ACTION_YML_PATH": "./action.yml",
          "RELEASE_BRANCH_NAME": "releases"
        }
      }
    ]
  }]
}

All four with values are required. Defaults aren't auto-applied — nova utility initialize prompts for each one, showing the suggested value as an example.

You can set these on the deploy's with as shown, or on the workflow-level with to share them across the entry. A deploy-level with value takes precedence over the workflow-level one for that destination.

ValueDescription
ACTION_ENTRY_POINTFilename of the bundled action entry script (relative to ACTION_OUTPUT_PATH). Example: index.js.
ACTION_OUTPUT_PATHPath to the built action output directory.
ACTION_YML_PATHPath to the action.yml file from repo root.
RELEASE_BRANCH_NAMEBranch name for the orphan release history.

Release Mechanics

When a GitHub Release is published:

  1. The base build job runs the workspace's build command (npx turbo run build if a turbo.json is present at repo root, otherwise npm run build), producing output in the workspace's build/ directory.
  2. The publish-github-action-* job spins up a separate git workspace at $RUNNER_TEMP/release, initialized with the release branch name.
  3. If the release branch already exists remotely, the workspace fetches and resets to it. Otherwise it stays as a fresh orphan branch.
  4. The release tree is staged: action.yml, the optional files above, and the built output copied into action/ (renaming build/action/).
  5. The staged tree is committed as Release <tag> and force-pushed to the release branch.
  6. The semver tag (e.g., v2.1.0) is force-retagged onto the new orphan commit. GitHub creates this tag on main's HEAD when you publish the release; this step moves it to point at the orphan commit so uses: owner/[email protected] resolves correctly.
  7. The floating major tag (e.g., v2) is force-moved to the same commit.
  8. A SLSA build-provenance attestation is emitted on the resolved entry script (ACTION_OUTPUT_PATH/ACTION_ENTRY_POINT) via actions/attest-build-provenance.

The main branch is never rewritten — the release branch is a parallel, orphan history that mirrors only what consumers receive via uses: owner/repo@<ref>.

Implications of the orphan-branch design

A few non-obvious consequences worth knowing:

  • main and the release branch share no history. Running git log main will never show release commits. Running git log <release-branch> shows only release commits, never source commits. To find which main commit produced a given release, use git log with the release's authoring date or look at the release's GitHub UI metadata.
  • Checking out a tag yields the runtime tree, not the source. git checkout v2.1.0 puts you in a working tree containing only action.yml, action/index.js, and the optional community files. The original source that produced this release is at the parent main commit at the time of release publication.
  • Force-retagging breaks reproducibility from a tag. Once v2.1.0 is moved from main's HEAD to the orphan commit, the original main commit that triggered the release is no longer reachable via that tag. Keep release notes that reference the original commit SHA if you need that traceability.
  • Older frozen major tags stay put. A v1 tag from a prior major line is never touched by this workflow. Only the floating major matching the released v<N>.x.y (e.g., v2 for a v2.1.0 release) gets force-moved.

Multiple actions in one repo

If a repo ships more than one GitHub Action (rare), each github-action destination must declare a distinct RELEASE_BRANCH_NAME across the entire nova.config.json. Two github-action destinations sharing the same release branch will race on push — both jobs run in parallel and force-push to the same branch, so the second writer overwrites the first.

Nova validates destination uniqueness across the entire workflows[] array, not just within a single workflow: declaring two github-action destinations anywhere in your config — same workflow or separate workflows — with overlapping RELEASE_BRANCH_NAME values fails the generator with a clear error message and rejects both colliding workflows. To ship two actions from one repo, use distinct RELEASE_BRANCH_NAME values (e.g., releases-primary, releases-secondary) — typically each in its own workflow with its own name. Example layout:

json
{
  "workflows": [
    {
      "template": "publish",
      "name": "primary",
      "triggers": ["release"],
      "deploy": [
        {
          "to": "github-action",
          "path": "./packages/primary-action",
          "with": {
            "ACTION_ENTRY_POINT": "index.js",
            "ACTION_OUTPUT_PATH": "./packages/primary-action/build",
            "ACTION_YML_PATH": "./action.yml",
            "RELEASE_BRANCH_NAME": "releases-primary"
          }
        }
      ]
    },
    {
      "template": "publish",
      "name": "secondary",
      "triggers": ["release"],
      "deploy": [
        {
          "to": "github-action",
          "path": "./packages/secondary-action",
          "with": {
            "ACTION_ENTRY_POINT": "index.js",
            "ACTION_OUTPUT_PATH": "./packages/secondary-action/build",
            "ACTION_YML_PATH": "./packages/secondary-action/action.yml",
            "RELEASE_BRANCH_NAME": "releases-secondary"
          }
        }
      ]
    }
  ]
}

Each action gets its own publish workflow with a distinct RELEASE_BRANCH_NAME, and the GitHub Releases for each action use a uniquely-prefixed tag (e.g., primary-v1.2.3, secondary-v1.0.0). Nova's force-retag step is prefix-aware: the floating major derivation matches an optional alphanumeric prefix before the v (so primary-v1.2.3primary-v1, v2.1.3v2). Consumers reference each action via its prefixed tag: uses: owner/repo@primary-v1, uses: owner/repo@secondary-v1.

Similarly, only one github-pages-docusaurus destination may be declared across all workflows in your nova.config.json — GitHub Pages is repo-scoped, so two deployments would overwrite each other regardless of whether they live in the same workflow or separate ones. Nova validates this at workflow-generation time.

The same cross-workflow uniqueness check applies to these other publish targets:

TargetUniqueness check
cloudflare-pages-docusaurusCLOUDFLARE_PROJECT_NAME must not be duplicated across workflows.
vercel-nextjsVERCEL_ORG_ID + VERCEL_PROJECT_ID must not be duplicated across workflows.
github-actionRELEASE_BRANCH_NAME must not be duplicated across workflows.
github-pages-docusaurusAt most one destination across all workflows (GitHub Pages is repo-scoped).

Tag protection rules

The force-retag steps require permission to overwrite existing tags. If the consuming repo has tag protection rules covering the v* pattern, the workflow's default GITHUB_TOKEN will be denied — even with contents: write permission. Either disable tag protection for the patterns you ship, or replace GITHUB_TOKEN with a GitHub App token that has the required maintain-permission scope.