diff --git a/.github/workflows/release.md b/.github/workflows/release.md
new file mode 100644
index 0000000..0357987
--- /dev/null
+++ b/.github/workflows/release.md
@@ -0,0 +1,408 @@
+
+
+# GitHub Reusable Workflow: Node.js Release
+
+
+

+
+
+---
+
+
+
+
+
+[](https://github.com/hoverkraft-tech/ci-github-nodejs/releases)
+[](http://choosealicense.com/licenses/mit/)
+[](https://img.shields.io/github/stars/hoverkraft-tech/ci-github-nodejs?style=social)
+[](https://github.com/hoverkraft-tech/ci-github-nodejs/blob/main/CONTRIBUTING.md)
+
+
+
+
+
+## Overview
+
+Workflow to release Node.js packages with support for:
+
+- Publishing to various registries (npm, GitHub Packages)
+- Publishing from build artifacts or source code
+- Publishing pre-built package tarballs
+- Generating documentation (optional)
+- Provenance attestation for npm packages
+- Distribution tags for versioning
+- Scoped package access control
+
+### Permissions
+
+- **`contents`**: `read`
+- **`id-token`**: `write` (required for provenance)
+- **`packages`**: `write`
+
+
+
+
+
+## Usage
+
+### Basic Release from Source
+
+```yaml
+name: Release
+
+on:
+ release:
+ types: [published]
+
+permissions: {}
+
+jobs:
+ release:
+ uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/release.yml@main
+ permissions:
+ contents: read
+ packages: write
+ id-token: write
+ secrets:
+ registry-token: ${{ secrets.NPM_TOKEN }}
+```
+
+### Release with Build Artifacts from CI
+
+```yaml
+name: Release
+
+on:
+ push:
+ tags: ["*"]
+
+permissions: {}
+
+jobs:
+ ci:
+ uses: ./.github/workflows/__shared-ci.yml
+ permissions:
+ contents: read
+ id-token: write
+ packages: read
+ secrets: inherit
+
+ release:
+ needs: ci
+ uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/release.yml@main
+ permissions:
+ contents: read
+ packages: write
+ id-token: write
+ secrets:
+ registry-token: ${{ secrets.NPM_TOKEN }}
+ with:
+ # Download build artifacts from CI job
+ build-artifact-id: ${{ needs.ci.outputs.build-artifact-id }}
+ access: public
+```
+
+### Release Pre-built Tarball
+
+```yaml
+name: Release
+
+on:
+ push:
+ tags: ["*"]
+
+permissions: {}
+
+jobs:
+ ci:
+ uses: ./.github/workflows/__shared-ci.yml
+ permissions:
+ contents: read
+ id-token: write
+ secrets: inherit
+
+ release:
+ needs: ci
+ uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/release.yml@main
+ permissions:
+ contents: read
+ packages: write
+ id-token: write
+ secrets:
+ registry-token: ${{ secrets.NPM_TOKEN }}
+ with:
+ build-artifact-id: ${{ needs.ci.outputs.package-tarball-artifact-id }}
+ package-tarball: "*.tgz"
+ access: public
+ provenance: true
+```
+
+
+
+
+
+
+
+## Inputs
+
+### Workflow Call Inputs
+
+| **Input** | **Description** | **Required** | **Type** | **Default** |
+| ----------------------- | ---------------------------------------------------------------------------------- | ------------ | ----------- | ------------------- |
+| **`runs-on`** | JSON array of runner(s) to use. | **false** | **string** | `["ubuntu-latest"]` |
+| | See . | | | |
+| **`build-artifact-id`** | Build artifact ID from CI to download before publishing. | **false** | **string** | - |
+| | Contains built package or tarball from a previous job. | | | |
+| **`package-tarball`** | Path/pattern to pre-built tarball to publish (e.g., `*.tgz`). | **false** | **string** | - |
+| | Use when publishing a specific tarball instead of from source. | | | |
+| **`access`** | Package access level: `public` or `restricted`. | **false** | **string** | - |
+| | Leave empty to use package.json default. | | | |
+| **`docs`** | Documentation generation parameters. | **false** | **string** | - |
+| | Set to empty string or `false` to disable. | | | |
+| | Set to `true` for default command (`docs`). | | | |
+| | Accepts JSON object with `command`, `output`, and `artifact` properties. | | | |
+| **`registry`** | Registry configuration for package publishing. | **false** | **string** | `npm` |
+| | Supported values: `npm`, `github`, URL, or JSON object with `url` and `scope`. | | | |
+| **`publish-command`** | Command to run for publishing the package. | **false** | **string** | `publish` |
+| | Defaults to `publish` which runs `npm publish` or equivalent. | | | |
+| **`tag`** | npm distribution tag for the published package. | **false** | **string** | `latest` |
+| | Common values: `latest`, `next`, `canary`. | | | |
+| **`dry-run`** | Whether to perform a dry run (no actual publish). | **false** | **boolean** | `false` |
+| **`provenance`** | Whether to generate provenance attestation for the published package. | **false** | **boolean** | `true` |
+| | Requires npm 9.5.0+ and appropriate permissions. | | | |
+| **`working-directory`** | Working directory where the package is located. | **false** | **string** | `.` |
+
+
+
+
+
+
+
+## Secrets
+
+| **Secret** | **Description** | **Required** |
+| -------------------- | ---------------------------------------------------------------------------------- | ------------ |
+| **`registry-token`** | Authentication token for the registry. | **true** |
+| | For npm: Use an npm access token with publish permissions. | |
+| | For GitHub Packages: Use `GITHUB_TOKEN` or a PAT with `packages:write` permission. | |
+
+
+
+
+
+## Outputs
+
+| **Output** | **Description** |
+| ---------------------- | ----------------------------------------------- |
+| **`version`** | The version of the published package. |
+| **`package-name`** | The name of the published package. |
+| **`docs-artifact-id`** | ID of the documentation artifact (if uploaded). |
+
+
+
+
+
+## Examples
+
+### Basic Release to npm
+
+```yaml
+name: Release to npm
+
+on:
+ release:
+ types: [published]
+
+permissions: {}
+
+jobs:
+ release:
+ uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/release.yml@main
+ permissions:
+ contents: read
+ packages: write
+ id-token: write
+ secrets:
+ registry-token: ${{ secrets.NPM_TOKEN }}
+```
+
+### Release to GitHub Packages
+
+```yaml
+name: Release to GitHub Packages
+
+on:
+ release:
+ types: [published]
+
+permissions: {}
+
+jobs:
+ release:
+ uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/release.yml@main
+ permissions:
+ contents: read
+ packages: write
+ id-token: write
+ secrets:
+ registry-token: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ registry: github
+ provenance: false
+```
+
+### Release with Documentation
+
+```yaml
+name: Release with Documentation
+
+on:
+ release:
+ types: [published]
+
+permissions: {}
+
+jobs:
+ release:
+ uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/release.yml@main
+ permissions:
+ contents: read
+ packages: write
+ id-token: write
+ secrets:
+ registry-token: ${{ secrets.NPM_TOKEN }}
+ with:
+ docs: |
+ {
+ "command": "build:docs",
+ "output": "docs-dist",
+ "artifact": true
+ }
+```
+
+### Prerelease with Next Tag
+
+```yaml
+name: Pre-release
+
+on:
+ release:
+ types: [prereleased]
+
+permissions: {}
+
+jobs:
+ release:
+ uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/release.yml@main
+ permissions:
+ contents: read
+ packages: write
+ id-token: write
+ secrets:
+ registry-token: ${{ secrets.NPM_TOKEN }}
+ with:
+ tag: next
+```
+
+### Custom Registry
+
+```yaml
+name: Release to Custom Registry
+
+on:
+ release:
+ types: [published]
+
+permissions: {}
+
+jobs:
+ release:
+ uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/release.yml@main
+ permissions:
+ contents: read
+ packages: write
+ id-token: write
+ secrets:
+ registry-token: ${{ secrets.CUSTOM_REGISTRY_TOKEN }}
+ with:
+ registry: |
+ {
+ "url": "https://my-registry.example.com",
+ "scope": "@myorg"
+ }
+ provenance: false
+```
+
+### Dry Run for Testing
+
+```yaml
+name: Test Release
+
+on:
+ workflow_dispatch:
+
+permissions: {}
+
+jobs:
+ test-release:
+ uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/release.yml@main
+ permissions:
+ contents: read
+ packages: write
+ id-token: write
+ secrets:
+ registry-token: ${{ secrets.NPM_TOKEN }}
+ with:
+ dry-run: true
+```
+
+### Monorepo Package Release
+
+```yaml
+name: Release Package
+
+on:
+ release:
+ types: [published]
+
+permissions: {}
+
+jobs:
+ release:
+ uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/release.yml@main
+ permissions:
+ contents: read
+ packages: write
+ id-token: write
+ secrets:
+ registry-token: ${{ secrets.NPM_TOKEN }}
+ with:
+ working-directory: packages/my-package
+```
+
+
+
+
+
+## Contributing
+
+Contributions are welcome! Please see the [contributing guidelines](https://github.com/hoverkraft-tech/ci-github-nodejs/blob/main/CONTRIBUTING.md) for more details.
+
+
+
+
+
+
+
+
+## License
+
+This project is licensed under the MIT License.
+
+SPDX-License-Identifier: MIT
+
+Copyright © 2025 hoverkraft-tech
+
+For more details, see the [license](http://choosealicense.com/licenses/mit/).
+
+
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..7cfe115
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,513 @@
+# Workflow to release Node.js packages:
+#
+# - Generate documentation (optional)
+# - Publish to registry (npm, GitHub Packages)
+
+name: Node.js Release
+
+on:
+ workflow_call:
+ inputs:
+ runs-on:
+ description: |
+ JSON array of runner(s) to use.
+ See https://docs.github.com/en/actions/using-jobs/choosing-the-runner-for-a-job.
+ type: string
+ default: '["ubuntu-latest"]'
+ required: false
+ build-artifact-id:
+ description: |
+ Build artifact ID from a previous CI job to download before publishing.
+ This artifact typically contains the built package or tarball.
+ If not provided, the workflow will publish from source.
+ type: string
+ required: false
+ default: ""
+ package-tarball:
+ description: |
+ Path to a pre-built package tarball to publish (e.g., `my-package-1.0.0.tgz`).
+ Use this when publishing a specific tarball file instead of running npm publish from source.
+ Supports glob patterns to match tarball files.
+ type: string
+ required: false
+ default: ""
+ access:
+ description: |
+ Package access level for npm publish.
+ - `public` — Publicly accessible package
+ - `restricted` — Scoped package with restricted access
+ Leave empty to use package.json default.
+ type: string
+ required: false
+ default: ""
+ docs:
+ description: |
+ Documentation generation parameters.
+ Set to empty string or `false` to disable documentation generation.
+ Set to `true` or a string to enable documentation generation with the default command `docs`.
+ Accepts a JSON object for advanced options:
+
+ - `command`: Command to run for documentation generation (default: `docs`).
+ - `output`: Output directory for documentation (default: `docs`).
+ - `artifact`: Whether to upload documentation as an artifact (default: `false`).
+
+ Example:
+ ```json
+ {
+ "command": "build:docs",
+ "output": "docs-dist",
+ "artifact": true
+ }
+ ```
+ type: string
+ required: false
+ default: ""
+ registry:
+ description: |
+ Registry configuration for package publishing.
+ Accepts a string for the registry URL or a JSON object for advanced options.
+
+ Supported registries:
+ - `npm` or `https://registry.npmjs.org` — npm public registry (default)
+ - `github` or `https://npm.pkg.github.com` — GitHub Packages
+
+ JSON object format:
+ - `url`: Registry URL
+ - `scope`: Package scope (e.g., `@myorg`). Defaults to repository owner for GitHub Packages.
+
+ Example:
+ ```json
+ {
+ "url": "https://npm.pkg.github.com",
+ "scope": "@myorg"
+ }
+ ```
+ type: string
+ required: false
+ default: "npm"
+ publish-command:
+ description: |
+ Command to run for publishing the package.
+ Defaults to `publish` which runs `npm publish` or equivalent for the detected package manager.
+ Can be customized for monorepo setups or specific publish requirements.
+
+ Examples:
+ - `publish` — Default npm/pnpm/yarn publish
+ - `release` — Custom publish script in package.json
+ - `publish --access public` — Publish with specific npm flags
+ type: string
+ required: false
+ default: "publish"
+ tag:
+ description: |
+ npm distribution tag for the published package.
+ Common values:
+ - `latest` — Default tag for stable releases
+ - `next` — Pre-release or beta versions
+ - `canary` — Canary/nightly builds
+
+ See https://docs.npmjs.com/adding-dist-tags-to-packages.
+ type: string
+ required: false
+ default: "latest"
+ dry-run:
+ description: |
+ Whether to perform a dry run (no actual publish).
+ Useful for testing the release workflow without publishing.
+ type: boolean
+ required: false
+ default: false
+ provenance:
+ description: |
+ Whether to generate provenance attestation for the published package.
+ Requires npm 9.5.0+ and appropriate permissions.
+ See https://docs.npmjs.com/generating-provenance-statements.
+ type: boolean
+ required: false
+ default: true
+ working-directory:
+ description: "Working directory where the package is located."
+ type: string
+ required: false
+ default: "."
+ secrets:
+ registry-token:
+ description: |
+ Authentication token for the registry.
+ For npm: Use an npm access token with publish permissions.
+ For GitHub Packages: Use `GITHUB_TOKEN` or a PAT with `packages:write` permission.
+ required: true
+ outputs:
+ version:
+ description: "The version of the published package."
+ value: ${{ jobs.release.outputs.version }}
+ package-name:
+ description: "The name of the published package."
+ value: ${{ jobs.release.outputs.package-name }}
+ docs-artifact-id:
+ description: "ID of the documentation artifact (if uploaded)."
+ value: ${{ jobs.release.outputs.docs-artifact-id }}
+
+permissions: {}
+
+jobs:
+ prepare:
+ name: 📦 Prepare configuration
+ runs-on: ${{ inputs.runs-on && fromJson(inputs.runs-on) || 'ubuntu-latest' }}
+ permissions: {}
+ outputs:
+ registry-url: ${{ steps.parse-registry.outputs.registry-url }}
+ registry-scope: ${{ steps.parse-registry.outputs.registry-scope }}
+ docs-command: ${{ steps.parse-docs.outputs.command }}
+ docs-output: ${{ steps.parse-docs.outputs.output }}
+ docs-artifact: ${{ steps.parse-docs.outputs.artifact }}
+ steps:
+ - id: parse-registry
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ env:
+ REGISTRY_INPUT: ${{ inputs.registry }}
+ REPO_OWNER: ${{ github.repository_owner }}
+ with:
+ script: |
+ const registryInput = process.env.REGISTRY_INPUT.trim();
+ const repoOwner = process.env.REPO_OWNER;
+
+ let registryUrl = 'https://registry.npmjs.org';
+ let registryScope = '';
+
+ // Handle shorthand values
+ if (registryInput === 'npm' || registryInput === '') {
+ registryUrl = 'https://registry.npmjs.org';
+ } else if (registryInput === 'github') {
+ registryUrl = 'https://npm.pkg.github.com';
+ registryScope = `@${repoOwner}`;
+ } else if (registryInput.startsWith('https://') || registryInput.startsWith('http://')) {
+ registryUrl = registryInput;
+ // Auto-detect GitHub Packages scope
+ if (registryInput.includes('npm.pkg.github.com')) {
+ registryScope = `@${repoOwner}`;
+ }
+ } else if (registryInput.startsWith('{')) {
+ // Parse JSON object
+ try {
+ const config = JSON.parse(registryInput);
+ registryUrl = config.url || 'https://registry.npmjs.org';
+ registryScope = config.scope || '';
+ // Auto-detect GitHub Packages scope if not specified
+ if (registryUrl.includes('npm.pkg.github.com') && !registryScope) {
+ registryScope = `@${repoOwner}`;
+ }
+ } catch (error) {
+ return core.setFailed(`Failed to parse registry input as JSON: ${error.message}`);
+ }
+ } else {
+ return core.setFailed(`Invalid registry input: ${registryInput}`);
+ }
+
+ core.info(`Registry URL: ${registryUrl}`);
+ core.info(`Registry scope: ${registryScope || '(none)'}`);
+
+ core.setOutput('registry-url', registryUrl);
+ core.setOutput('registry-scope', registryScope);
+
+ - id: parse-docs
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ env:
+ DOCS_INPUT: ${{ inputs.docs }}
+ with:
+ script: |
+ const docsInput = process.env.DOCS_INPUT.trim();
+
+ if (!docsInput || docsInput === 'false') {
+ core.info('Documentation generation disabled');
+ return;
+ }
+
+ let command = 'docs';
+ let output = 'docs';
+ let artifact = false;
+
+ if (docsInput === 'true') {
+ // Use defaults
+ } else if (docsInput.startsWith('{')) {
+ try {
+ const config = JSON.parse(docsInput);
+ command = config.command || 'docs';
+ output = config.output || 'docs';
+ artifact = config.artifact === true;
+ } catch (error) {
+ return core.setFailed(`Failed to parse docs input as JSON: ${error.message}`);
+ }
+ } else {
+ // Treat as custom command
+ command = docsInput;
+ }
+
+ core.info(`Docs command: ${command}`);
+ core.info(`Docs output: ${output}`);
+ core.info(`Docs artifact: ${artifact}`);
+
+ core.setOutput('command', command);
+ core.setOutput('output', output);
+ core.setOutput('artifact', artifact ? 'true' : '');
+
+ release:
+ name: 🚀 Release
+ runs-on: ${{ inputs.runs-on && fromJson(inputs.runs-on) || 'ubuntu-latest' }}
+ needs:
+ - prepare
+ permissions:
+ contents: read
+ packages: write
+ id-token: write # Required for provenance
+ outputs:
+ version: ${{ steps.package-info.outputs.version }}
+ package-name: ${{ steps.package-info.outputs.name }}
+ docs-artifact-id: ${{ steps.upload-docs.outputs.artifact-id }}
+ steps:
+ - uses: hoverkraft-tech/ci-github-common/actions/checkout@5ac504609f6ef35c5ac94bd8199063aa32104721 # 0.31.3
+
+ - id: local-workflow-actions
+ uses: hoverkraft-tech/ci-github-common/actions/local-workflow-actions@5ac504609f6ef35c5ac94bd8199063aa32104721 # 0.31.3
+ with:
+ actions-path: actions
+
+ - id: setup-node
+ uses: ./self-workflow/actions/setup-node
+ with:
+ working-directory: ${{ inputs.working-directory }}
+ registry-url: ${{ needs.prepare.outputs.registry-url }}
+
+ - name: Download build artifacts
+ if: inputs.build-artifact-id != ''
+ uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
+ with:
+ name: ${{ inputs.build-artifact-id }}
+ path: ${{ github.workspace }}
+
+ - id: package-info
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ env:
+ WORKING_DIRECTORY: ${{ inputs.working-directory }}
+ with:
+ script: |
+ const fs = require('node:fs');
+ const path = require('node:path');
+
+ let workingDirectory = process.env.WORKING_DIRECTORY || '.';
+ if (!path.isAbsolute(workingDirectory)) {
+ workingDirectory = path.join(process.env.GITHUB_WORKSPACE, workingDirectory);
+ }
+
+ const packageJsonPath = path.join(workingDirectory, 'package.json');
+ if (!fs.existsSync(packageJsonPath)) {
+ return core.setFailed(`package.json not found at ${packageJsonPath}`);
+ }
+
+ let packageJson;
+ try {
+ packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
+ } catch (error) {
+ return core.setFailed(`Failed to parse package.json: ${error.message}`);
+ }
+
+ const name = packageJson.name;
+ const version = packageJson.version;
+
+ if (!name) {
+ return core.setFailed('Package name is not defined in package.json');
+ }
+ if (!version) {
+ return core.setFailed('Package version is not defined in package.json');
+ }
+
+ core.info(`Package: ${name}@${version}`);
+ core.setOutput('name', name);
+ core.setOutput('version', version);
+
+ - name: Generate documentation
+ if: needs.prepare.outputs.docs-command != ''
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ env:
+ DOCS_COMMAND: ${{ needs.prepare.outputs.docs-command }}
+ RUN_SCRIPT_COMMAND: ${{ steps.setup-node.outputs.run-script-command }}
+ WORKING_DIRECTORY: ${{ inputs.working-directory }}
+ with:
+ script: |
+ const fs = require('node:fs');
+ const path = require('node:path');
+
+ let workingDirectory = process.env.WORKING_DIRECTORY || '.';
+ if (!path.isAbsolute(workingDirectory)) {
+ workingDirectory = path.join(process.env.GITHUB_WORKSPACE, workingDirectory);
+ }
+
+ if (!fs.existsSync(workingDirectory)) {
+ return core.setFailed(`Working directory does not exist: ${workingDirectory}`);
+ }
+
+ const docsCommand = process.env.DOCS_COMMAND;
+ const runScriptCommand = process.env.RUN_SCRIPT_COMMAND;
+
+ core.info(`Running documentation command: ${docsCommand}`);
+
+ try {
+ const exitCode = await exec.exec(runScriptCommand, [docsCommand], {
+ cwd: workingDirectory,
+ ignoreReturnCode: true
+ });
+
+ if (exitCode !== 0) {
+ return core.setFailed(`Documentation generation failed with exit code ${exitCode}`);
+ }
+ } catch (error) {
+ return core.setFailed(`Documentation generation failed: ${error.message}`);
+ }
+
+ - id: upload-docs
+ if: needs.prepare.outputs.docs-artifact == 'true'
+ uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
+ with:
+ name: documentation-${{ github.run_id }}
+ path: ${{ inputs.working-directory }}/${{ needs.prepare.outputs.docs-output }}
+ if-no-files-found: error
+
+ - name: Publish package
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.registry-token }}
+ PUBLISH_COMMAND: ${{ inputs.publish-command }}
+ PACKAGE_TARBALL: ${{ inputs.package-tarball }}
+ ACCESS: ${{ inputs.access }}
+ TAG: ${{ inputs.tag }}
+ DRY_RUN: ${{ inputs.dry-run }}
+ PROVENANCE: ${{ inputs.provenance }}
+ RUN_SCRIPT_COMMAND: ${{ steps.setup-node.outputs.run-script-command }}
+ WORKING_DIRECTORY: ${{ inputs.working-directory }}
+ REGISTRY_URL: ${{ needs.prepare.outputs.registry-url }}
+ with:
+ script: |
+ const fs = require('node:fs');
+ const path = require('node:path');
+
+ let workingDirectory = process.env.WORKING_DIRECTORY || '.';
+ if (!path.isAbsolute(workingDirectory)) {
+ workingDirectory = path.join(process.env.GITHUB_WORKSPACE, workingDirectory);
+ }
+
+ if (!fs.existsSync(workingDirectory)) {
+ return core.setFailed(`Working directory does not exist: ${workingDirectory}`);
+ }
+
+ const publishCommand = process.env.PUBLISH_COMMAND;
+ const packageTarball = process.env.PACKAGE_TARBALL;
+ const access = process.env.ACCESS;
+ const tag = process.env.TAG;
+ const dryRun = process.env.DRY_RUN === 'true';
+ const provenance = process.env.PROVENANCE === 'true';
+ const runScriptCommand = process.env.RUN_SCRIPT_COMMAND;
+ const registryUrl = process.env.REGISTRY_URL;
+
+ // Simple glob matching function
+ function matchGlob(pattern, filename) {
+ const regexPattern = pattern
+ .replace(/\./g, '\\.')
+ .replace(/\*/g, '.*')
+ .replace(/\?/g, '.');
+ return new RegExp(`^${regexPattern}$`).test(filename);
+ }
+
+ // Determine what to publish
+ let publishTarget = null;
+
+ if (packageTarball) {
+ // Publishing a specific tarball file
+ let searchDir = workingDirectory;
+ let filePattern = packageTarball;
+
+ if (path.isAbsolute(packageTarball)) {
+ searchDir = path.dirname(packageTarball);
+ filePattern = path.basename(packageTarball);
+ } else if (packageTarball.includes('/')) {
+ searchDir = path.join(workingDirectory, path.dirname(packageTarball));
+ filePattern = path.basename(packageTarball);
+ }
+
+ if (!fs.existsSync(searchDir)) {
+ return core.setFailed(`Search directory does not exist: ${searchDir}`);
+ }
+
+ // Find matching files
+ const allFiles = fs.readdirSync(searchDir);
+ const matches = allFiles
+ .filter(file => matchGlob(filePattern, file))
+ .map(file => path.join(searchDir, file))
+ .filter(file => fs.statSync(file).isFile());
+
+ if (matches.length === 0) {
+ return core.setFailed(`No tarball found matching pattern: ${packageTarball} in ${searchDir}`);
+ }
+
+ if (matches.length > 1) {
+ core.warning(`Multiple tarballs found: ${matches.join(', ')}. Using first match.`);
+ }
+
+ publishTarget = matches[0];
+ core.info(`Publishing tarball: ${publishTarget}`);
+ }
+
+ // Build publish arguments
+ const args = [publishCommand];
+
+ // Add tarball target if specified
+ if (publishTarget) {
+ args.push(publishTarget);
+ }
+
+ // Add access flag
+ if (access) {
+ args.push('--access', access);
+ }
+
+ // Add tag
+ if (tag) {
+ args.push('--tag', tag);
+ }
+
+ // Add dry run flag
+ if (dryRun) {
+ args.push('--dry-run');
+ core.info('Dry run mode enabled - package will not be published');
+ }
+
+ // Add provenance flag (npm only, requires npm 9.5.0+)
+ if (provenance && registryUrl.includes('registry.npmjs.org')) {
+ args.push('--provenance');
+ core.info('Provenance attestation enabled');
+ }
+
+ core.info(`Publishing with command: ${runScriptCommand} ${args.join(' ')}`);
+
+ try {
+ const exitCode = await exec.exec(runScriptCommand, args, {
+ cwd: workingDirectory,
+ ignoreReturnCode: true
+ });
+
+ if (exitCode !== 0) {
+ return core.setFailed(`Package publish failed with exit code ${exitCode}`);
+ }
+
+ core.info('Package published successfully!');
+ } catch (error) {
+ return core.setFailed(`Package publish failed: ${error.message}`);
+ }
+
+ # jscpd:ignore-start
+ - uses: hoverkraft-tech/ci-github-common/actions/local-workflow-actions@5ac504609f6ef35c5ac94bd8199063aa32104721 # 0.31.3
+ if: always() && steps.local-workflow-actions.outputs.repository
+ with:
+ actions-path: actions
+ repository: ${{ steps.local-workflow-actions.outputs.repository }}
+ ref: ${{ steps.local-workflow-actions.outputs.ref }}
+ # jscpd:ignore-end
diff --git a/README.md b/README.md
index 6b773f8..e8bcf26 100644
--- a/README.md
+++ b/README.md
@@ -52,6 +52,10 @@ _Actions focused on discovering and preparing the Node.js environment._
- [Continuous Integration](.github/workflows/continuous-integration.md) — documentation for the reusable Node.js CI workflow.
+### Release
+
+- [Release](.github/workflows/release.md) — documentation for the reusable Node.js release workflow with support for multiple registries and documentation generation.
+
## Contributing
Contributions are welcome! Please review the [contributing guidelines](CONTRIBUTING.md) before opening a PR.