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
afteron 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 innova.config.json. Usenova utility initializeto configure it interactively.
Usage
Options
| Flag | Description |
|---|---|
-d, --dry-run | Run without writing any files. |
-r, --replace-file | Overwrite 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:
| Field | Required | Description |
|---|---|---|
template | Yes | The name of a bundled workflow template (see Available Templates). |
name | Yes | A label appended to the output filename and workflow name. Must be unique within the same template. |
triggers | Yes | An array of triggers (see Available Triggers). Each trigger must be valid for the template. |
build | No | An array of workspace paths (keys from workspaces) to include in the check / build step filter. |
deploy | No | An array of publish destinations for the publish template. See Deploy Fields. |
with | No | Key-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.
| Field | Required | Description |
|---|---|---|
to | Yes | One of the nine target types (see Available Target Types). |
path | Yes | Path to the workspace that owns the destination (e.g., "./packages/nova"). |
after | No | Paths of other same-type destinations that must finish publishing before this one starts. |
with | No | Key-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:
{
"template": "publish",
"name": "project",
"triggers": ["release"],
"build": ["./packages/core"],
"deploy": [
{
"to": "npm",
"path": "./packages/core"
}
]
}
Serialize dependent publishes with after:
{
"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:
{
"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.
| Template | Description | Notes |
|---|---|---|
publish | Build once and publish to one or more destinations | Uses deploy. Each destination declares its own secret / variable setup. |
lock-inactive-issues | Lock stale issues | No manual setup needed (uses automatic GITHUB_TOKEN). Pick a cadence via triggers. |
check-sponsor-gated-issues | Gate issues by GitHub sponsor status | Requires 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.
| Variable | Format | Description |
|---|---|---|
GITHUB_TOKEN | secret | Automatic token supplied by GitHub Actions (no manual setup). |
PERSONAL_ACCESS_TOKEN | secret | Personal access token with read:org and read:user scopes for sponsor lookups. |
ISSUE_LABELS | var | Comma-separated labels to apply when gating issues. |
ISSUE_LIMIT_COMMENTER | var | Whether to limit comments to the issue opener and repo owners. |
ISSUE_LOCK_ON_CLOSE | var | Whether to lock issues when closed. |
ISSUE_MESSAGE_NOT_SPONSOR | var | Message shown to non-sponsors when they open an issue. |
ISSUE_MESSAGE_WELCOME | var | Welcome message shown to sponsors when they open an issue. |
IS_ORGANIZATION | var | Whether the repository owner is an organization. |
SPONSOR_ACTIVE_ONLY | var | Whether to require active sponsorship status. |
SPONSOR_EXEMPT_FILE_LOCATION | var | Path to a file listing users exempt from sponsor requirements. |
SPONSOR_MINIMUM | var | Minimum 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 value | Description | Secrets / Variables |
|---|---|---|
npm | Publish a package to npm | NPM_TOKEN (secret) - npm access token for publishing. Skip creating this secret in GitHub if using OIDC trusted publishing. |
github-action | Publish a JavaScript GitHub Action via orphan tags | None 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-packages | Publish a package to GitHub Packages | GITHUB_TOKEN (secret, supplied automatically - no manual setup). |
docker-hub | Publish a Docker image to Docker Hub | DOCKERHUB_TOKEN (secret) - Docker Hub access token for publishing images. DOCKERHUB_USERNAME (var) - Docker Hub username or organization name. |
ghcr | Publish a Docker image to GitHub Container Registry | GITHUB_TOKEN (secret, supplied automatically - no manual setup). |
cloudflare-pages-docusaurus | Deploy a Docusaurus site to Cloudflare Pages | CLOUDFLARE_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-workers | Deploy a Worker with static assets to Cloudflare Workers | CLOUDFLARE_API_TOKEN (secret) - Cloudflare API token with Workers deployment permissions. CLOUDFLARE_ACCOUNT_ID (var) - Cloudflare account ID. |
github-pages-docusaurus | Deploy a Docusaurus site to GitHub Pages | GITHUB_TOKEN (secret, supplied automatically - no manual setup). |
vercel-nextjs | Deploy a Next.js app to Vercel | VERCEL_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.
| Template | Triggers |
|---|---|
publish | release, tag-push, push, workflow-run-success, workflow-run-failure, workflow-run-any |
lock-inactive-issues | schedule-daily, schedule-weekly, schedule-monthly |
check-sponsor-gated-issues | issues, issue-comment |
| Trigger | Carries workflows |
|---|---|
release | No |
tag-push | No |
push | No |
schedule-daily | No |
schedule-weekly | No |
schedule-monthly | No |
issues | No |
issue-comment | No |
workflow-run-success | Yes |
workflow-run-failure | Yes |
workflow-run-any | Yes |
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, emittingon: push: tags: ["v*"]. Configure it with the bare string"tag-push"(tags default tov*), 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 requiredbranchesfilter, 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
workflowsarray 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.
{
"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:
| Storage | buildOnly: 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: falsevalue is pushed to the destination under its prefixed name, and the running app reads it asenv.NAMEon Workers orprocess.env.NAMEon 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.
| Target | Reconcile mechanism | Runtime scope |
|---|---|---|
cloudflare-workers | wrangler secret for encrypted values and wrangler deploy --var for plaintext, run around wrangler deploy. | Live worker. |
vercel-nextjs | vercel 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:
| Format | Substitution behavior |
|---|---|
secret | Produces ${{ secrets.NAME }} in the output. If a with entry is provided, the secret reference uses that value as the name instead. |
var | Produces ${{ vars.NAME }} in the output. If a with entry is provided, the variable reference uses that value as the name instead. |
literal | Replaces 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:
nova-{template}-{name}.yml
Examples:
nova-lock-inactive-issues-project.ymlnova-publish-project.ymlnova-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:
- 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.). - The template name matches a known bundled template.
nameis present and a non-empty string, and no two entries share the same template andname.- Each trigger is valid for the entry's template, and object-form filter lists are non-empty string arrays.
- A
pushtrigger uses the object form with abranchesfilter, and a workflow-run trigger carriesworkflowsreferences that each resolve to a sibling workflow. - Each
buildscope is a registered workspace, and no workspace still carries the retireddotenvblock — that model moved to the top-levelenvironmentblock classified bysecret, so an old-shape config is rejected with a migration hint. - Each deploy
tois a supported target type, and each deploypathis a registered workspace. - All
literalvariables without a default have a corresponding value inwith. - 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.ymlat 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.ymlaction/— the built output (renamed from the workspace'sbuild/directory during publish).
Optional (copied if present at repo root, skipped silently if not):
README.mdLICENSESECURITY.mdNOTICECHANGELOG.md
Example nova.config.json Entry
{
"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.
| Value | Description |
|---|---|
ACTION_ENTRY_POINT | Filename of the bundled action entry script (relative to ACTION_OUTPUT_PATH). Example: index.js. |
ACTION_OUTPUT_PATH | Path to the built action output directory. |
ACTION_YML_PATH | Path to the action.yml file from repo root. |
RELEASE_BRANCH_NAME | Branch name for the orphan release history. |
Release Mechanics
When a GitHub Release is published:
- The base
buildjob runs the workspace's build command (npx turbo run buildif aturbo.jsonis present at repo root, otherwisenpm run build), producing output in the workspace'sbuild/directory. - The
publish-github-action-*job spins up a separate git workspace at$RUNNER_TEMP/release, initialized with the release branch name. - If the release branch already exists remotely, the workspace fetches and resets to it. Otherwise it stays as a fresh orphan branch.
- The release tree is staged:
action.yml, the optional files above, and the built output copied intoaction/(renamingbuild/→action/). - The staged tree is committed as
Release <tag>and force-pushed to the release branch. - The semver tag (e.g.,
v2.1.0) is force-retagged onto the new orphan commit. GitHub creates this tag onmain's HEAD when you publish the release; this step moves it to point at the orphan commit souses: owner/[email protected]resolves correctly. - The floating major tag (e.g.,
v2) is force-moved to the same commit. - A SLSA build-provenance attestation is emitted on the resolved entry script (
ACTION_OUTPUT_PATH/ACTION_ENTRY_POINT) viaactions/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:
mainand the release branch share no history. Runninggit log mainwill never show release commits. Runninggit log <release-branch>shows only release commits, never source commits. To find whichmaincommit produced a given release, usegit logwith 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.0puts you in a working tree containing onlyaction.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.0is moved frommain's HEAD to the orphan commit, the originalmaincommit 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
v1tag from a prior major line is never touched by this workflow. Only the floating major matching the releasedv<N>.x.y(e.g.,v2for av2.1.0release) 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:
{
"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.3 → primary-v1, v2.1.3 → v2). 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:
| Target | Uniqueness check |
|---|---|
cloudflare-pages-docusaurus | CLOUDFLARE_PROJECT_NAME must not be duplicated across workflows. |
vercel-nextjs | VERCEL_ORG_ID + VERCEL_PROJECT_ID must not be duplicated across workflows. |
github-action | RELEASE_BRANCH_NAME must not be duplicated across workflows. |
github-pages-docusaurus | At 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.