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.
  • Coordinates package, container, and hosting destinations from one workflow while preserving each deliverable's build boundary.
  • 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.
  • Corepack package manager — A project with a publish workflow must declare an exact root packageManager value for npm, pnpm, or Yarn. Ranges, partial versions, missing values, and unsupported clients are rejected before any workflow file changes.
  • Configured workflows — The "workflows" array must exist in nova.config.json. Use nova utility configure to configure it interactively.
  • GitHub Action release strategy — A github-action publish destination requires SemVer because the generated release job maintains floating major aliases such as v1. Nova rejects that target under CalVer before writing a workflow.

Usage

Options

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

Package Manager Selection

The project root's packageManager field selects how generated publish workflows install dependencies and run project-owned tools. Nova passes the client through the entire workflow instead of translating every project to npm.

Root descriptorInstall stepLocal executableRoot script
npm@<exact-version>npm installnpx <tool>npm run <script>
pnpm@<exact-version>pnpm installpnpm exec <tool>pnpm run <script>
yarn@<exact-version>yarn installyarn exec <tool>yarn run <script>

For npm, the workflow uses the client bundled with Node.js directly. For pnpm and Yarn, each install job first installs Nova's pinned Corepack release and enables the selected client. Corepack then reads the full descriptor from the checked-out root package.json, so the workflow uses the pinned package-manager version instead of a floating latest release. This explicit bootstrap keeps Node.js 26 and later frictionless even though Node.js no longer bundles Corepack. Root scripts that orchestrate workspaces share the client-independent command nova utility run-workspaces <script>. Nova reads the workspace graph from nova.config.json and executes each child through the pinned client.

This selection governs project tooling; it does not rename deploy destinations. The npm and github-packages targets still use the npm publishing protocol and commands even when pnpm or Yarn manages the repository.

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.
runsOnNoRunner applied to every generated job. The only supported value is "ubuntu-latest"; omission keeps the template defaults.
timeoutMinutesNoWhole number from 1 through 360 applied to every generated job; omission keeps each job's template timeout.
containerPlatformsNoContainer platforms for the repository image. Supports "linux/amd64" and "linux/arm64"; omission selects both.
buildNoAn array of non-root 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.

Job Execution

Use runsOn and timeoutMinutes when every job in one generated workflow should share the same execution ceiling:

json
{
  "template": "publish",
  "name": "project",
  "triggers": ["release"],
  "runsOn": "ubuntu-latest",
  "timeoutMinutes": 45
}

Nova intentionally offers only ubuntu-latest as the runner selection. timeoutMinutes accepts a whole number from 1 through 360. A timeout is only the point where GitHub cancels a still-running job; it does not reserve that amount of runner time. Leave the timeout unset to retain the different defaults owned by each workflow template and job.

Deploy Fields

Each entry in the deploy array pins one publish destination to a workspace or to the repository-wide container.

FieldRequiredDescription
toYesOne of the nine target types (see Available Target Types).
pathYesWorkspace path that owns the destination, or "./" for the repository-wide docker-hub and ghcr targets.
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",
      "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.

Before the build job hands files to GitHub, Nova validates every declared artifact root. Missing or empty roots, sensitive hidden paths such as .env and .npmrc, broken symlinks, and symlinks that escape their artifact root fail the job. Valid deployment files such as .well-known/, .assetsignore, .nojekyll, .gitignore, and .gitattributes remain included in the uploaded artifact.

The cloudflare-pages and github-pages targets are framework-neutral. They deploy the static files produced in a workspace's build/ directory, whether the workspace uses Docusaurus, Astro, Vite, or another static-site framework.

Publishing Container Images

Nova publishes exactly one repository-wide container image. Multiple independently built images from one repository are unsupported.

Configure the Docker Hub and GHCR destinations in the same publish workflow when you want both registries:

json
{
  "template": "publish",
  "name": "container",
  "triggers": ["release"],
  "containerPlatforms": ["linux/amd64", "linux/arm64"],
  "deploy": [
    {
      "to": "docker-hub",
      "path": "./",
      "with": {
        "IMAGE_NAMESPACE": "example",
        "IMAGE_REPOSITORY": "example-project"
      }
    },
    {
      "to": "ghcr",
      "path": "./",
      "with": {
        "IMAGE_REPOSITORY": "example-project"
      }
    }
  ]
}

The workflow builds one multi-platform OCI artifact from the root Dockerfile covering every selected containerPlatforms entry, then distributes that exact artifact to each configured registry. Supported values are "linux/amd64" and "linux/arm64"; omitting the field selects both. Registry jobs do not compile workspaces or rebuild the image.

Docker Hub requires literal IMAGE_NAMESPACE and IMAGE_REPOSITORY values. DOCKERHUB_USERNAME and DOCKERHUB_TOKEN authenticate the push only; they do not decide the image name.

GHCR derives its namespace from github.repository_owner and requires only the literal IMAGE_REPOSITORY. Both container targets use path: "./" because the image belongs to the repository rather than an individual workspace.

The build array never accepts "./". It contains only non-container workspace deliverables; the multi-stage container build compiles its own inputs from the repository root.

See Docker Images for the generated image and runtime contract.

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 the repository image to Docker HubIMAGE_NAMESPACE and IMAGE_REPOSITORY (literal) name the image. DOCKERHUB_USERNAME (var) and DOCKERHUB_TOKEN (secret) authenticate the push only.
ghcrPublish the repository image to GitHub Container RegistryIMAGE_REPOSITORY (literal) names the image under github.repository_owner. GITHUB_TOKEN is supplied automatically.
cloudflare-pagesDeploy a static 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-pagesDeploy a static 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 start a new publish workflow after a named upstream workflow finishes. Choose the variant that matches the upstream result you accept:

TriggerWhen the downstream jobs run
workflow-run-successAfter an upstream run finishes successfully.
workflow-run-failureAfter an upstream run finishes with a failure.
workflow-run-anyAfter an upstream run finishes, regardless of its conclusion.

Declare one of these triggers in object form. Its workflows array contains sibling <template>-<name> keys from the same workflows config:

json
{
  "template": "publish",
  "name": "preset",
  "triggers": [
    {
      "name": "workflow-run-success",
      "workflows": ["publish-core"]
    }
  ],
  "build": ["./packages/preset"],
  "deploy": [
    {
      "to": "npm",
      "path": "./packages/preset"
    }
  ]
}

Nova turns publish-core into the generated upstream workflow's display name. When that workflow finishes successfully, GitHub starts publish-preset as a separate run.

Multiple names mean “any,” not “all”

GitHub treats multiple entries in workflows as alternatives. This configuration starts publish-preset when either publish-core or publish-cli finishes successfully; it does not wait for both. If both finish, each completion can start its own downstream run.

json
{
  "name": "workflow-run-success",
  "workflows": ["publish-core", "publish-cli"]
}

If several packages must publish in one coordinated run, keep them in one publish workflow and use deploy[].after for destination ordering.

Each referenced key must resolve to a sibling workflow, and Nova rejects circular references. The downstream run starts fresh; Nova does not automatically carry build files or artifacts over from the upstream run.

GitHub also requires the downstream workflow file to exist on the repository's default branch and limits workflow_run chains to three levels. See GitHub's workflow_run documentation before designing a longer chain.

Environment Variables and Secrets

A publish workflow reads workspace environment values declared under environment.workspaces. Every value has a required reach, which tells Nova where the value is allowed to travel:

reachLocal .envGitHub storageCI build environmentDeployed runtime
localYesNoNoNo
managedYes, blankYesNoNo
buildYesYesYesNo
runtimeYesYesNoYes

For managed, build, and runtime, the independent secret flag controls storage: secret: false uses a GitHub Variable, while secret: true uses a GitHub Secret. A local value never leaves the developer's machine, so it does not accept secret and does not need a namespace prefix.

A build value exists only while the build runs on the CI runner. A Variable that application code inlines (for example, import.meta.env.PUBLIC_SITE_KEY) can become part of the output; a Secret used for authentication can be consumed without being emitted. "Build" means "available to the build process," not automatically "shipped to users." Whether a value is exposed in client output depends on how the application uses it.

A runtime value is reconciled into a running server's environment and read by reference (env.X or process.env.X). A managed value is tracked locally and on GitHub but is intentionally delivered to neither destination.

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

Deployment Environment Selection

Environment profiles are optional. If environment.profiles is not enabled, Nova treats the project as being in implicit development mode: local commands use .env, generated publish workflows keep their original environment-agnostic behavior, and .env.development is not used.

When profiles are enabled, local commands use the explicit development profile. A Cloudflare Workers publish workflow can additionally select staging for prerelease tags or manual staging runs and production for stable tags or manual production runs. The selected profile is isolated: Nova never falls back from staging to production, from production to staging, or to an unselected profile when a required value is missing.

Configure the selection on a dedicated Cloudflare Workers publish workflow:

json
{
  "workflows": [
    {
      "template": "publish",
      "name": "workers",
      "triggers": [
        "release",
        "tag-push"
      ],
      "environmentSelection": {
        "versionTags": {
          "prerelease": "staging",
          "stable": "production"
        },
        "manual": true
      },
      "build": [
        "./apps/workers"
      ],
      "deploy": [
        {
          "to": "cloudflare-workers",
          "path": "./apps/workers"
        }
      ]
    }
  ]
}
InvocationSelected profileAssociated dotenv file
Profiles are not configuredImplicit development.env
Local command with profiles enableddevelopment.env.development
Prerelease tag or manual selectionstaging.env.staging
Stable tag or manual selectionproduction.env.production

The generated workflow sets CLOUDFLARE_ENV for Wrangler and validates every Workers target before any project check or build begins. Missing profile-specific GitHub variables or secrets fail that preflight; Nova does not reuse values from another profile.

Build Delivery

Every workspace value with reach: "build" 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 Environment Sync

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

  • Upsert — Every declared runtime 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 reach: "runtime" 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

When at least one valid publish entry exists, the generator validates the root packageManager first. If that project-level setting is missing or invalid, the whole generation run stops before writing, backing up, or deleting workflow files.

The generator also checks each configured entry:

  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 non-root workspace, and no workspace still carries the retired dotenv block — that model moved to the top-level environment block classified by reach and, where applicable, secret, so an old-shape config is rejected with a migration hint.
  7. Each deploy to is a supported target type, workspace targets use a registered workspace path, and container targets use the repository path "./".
  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 entry-level failure emits a diagnostic and skips the affected workflow, while other valid workflows still build. The project-level package manager guard is the exception because every publish entry shares it.

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, deploy destinations, and selected Corepack client — 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 template secret and var, plus every reach: "build" workspace 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.

OIDC, Registry Authentication, and Provenance

These features are related, but they solve different problems:

MechanismWhat it answersHow Nova uses it
OpenID Connect (OIDC)“Which trusted workflow is asking?”npm trusted publishing exchanges GitHub's short-lived identity token for publish access. The job needs id-token: write; no long-lived npm token is stored.
GITHUB_TOKEN“May this workflow write to this repository?”GitHub Packages and GHCR use GitHub's short-lived repository token with the required package permission. This is not npm trusted publishing.
npm provenance“Where and how was this npm package built?”The npm target publishes with --provenance. With npm trusted publishing, npm automatically associates the supported GitHub Actions build evidence.
GitHub artifact attestation“Which workflow produced this artifact?”GitHub Action and container targets use actions/attest after publishing their built artifact.

For npm trusted publishing, configure the package on npm with the exact GitHub owner, repository, generated workflow filename, and optional GitHub environment. Do not create NPM_TOKEN for that target. If trusted publishing is not configured, Nova can still use a repository NPM_TOKEN secret as the explicit fallback.

Provenance is proof about the build; it is not permission to publish. OIDC supplies the short-lived identity used for trusted publishing, while the registry decides whether that identity is authorized.

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 configure 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 root checks and then uses Nova's dependency-aware workspace runner to build the configured scopes, producing output in each 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. Nova permits an empty commit here so every published semantic version receives a distinct action release commit even when its bundled runtime bytes did not change.
  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@v4.

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.
  • Runtime-identical releases still exist. A documentation-only or metadata-only release can produce the same action files as the previous version. Nova still creates a new Release <tag> commit, then points that semantic-version tag at it, so the action's release history stays aligned with GitHub Releases.

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.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 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-pagesCLOUDFLARE_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-pagesAt 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.