diff --git a/.github/workflows/__shared-ci.yml b/.github/workflows/__shared-ci.yml
index da3b73d..f4243a2 100644
--- a/.github/workflows/__shared-ci.yml
+++ b/.github/workflows/__shared-ci.yml
@@ -61,3 +61,12 @@ jobs:
issues: read
security-events: write
secrets: inherit
+
+ test-workflow-release:
+ name: Test workflow "release"
+ needs: linter
+ uses: ./.github/workflows/__test-workflow-release.yml
+ permissions:
+ contents: read
+ packages: write
+ id-token: write
diff --git a/.github/workflows/__test-action-package.yml b/.github/workflows/__test-action-package.yml
index 1bedfc5..03c24c0 100644
--- a/.github/workflows/__test-action-package.yml
+++ b/.github/workflows/__test-action-package.yml
@@ -11,21 +11,22 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
+ outputs:
+ package-tarball-artifact-id-npm: ${{ steps.package-tarball-artifact.outputs.package-tarball-artifact-id-npm }}
+ package-tarball-artifact-id-pnpm: ${{ steps.package-tarball-artifact.outputs.package-tarball-artifact-id-pnpm }}
+ package-tarball-artifact-id-pnpm-package-manager: ${{ steps.package-tarball-artifact.outputs.package-tarball-artifact-id-pnpm-package-manager }}
+ package-tarball-artifact-id-yarn: ${{ steps.package-tarball-artifact.outputs.package-tarball-artifact-id-yarn }}
strategy:
matrix:
include:
- working-directory: tests/npm
artifact-suffix: npm
- install-command: npm install --force --legacy-peer-deps --no-audit --no-fund --loglevel=warn
- working-directory: tests/pnpm
artifact-suffix: pnpm
- install-command: pnpm install
- working-directory: tests/pnpm-package-manager
artifact-suffix: pnpm-package-manager
- install-command: pnpm install
- working-directory: tests/yarn
artifact-suffix: yarn
- install-command: yarn add
steps:
- name: Arrange - Checkout sources
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -35,7 +36,7 @@ jobs:
uses: ./actions/package
with:
working-directory: ${{ matrix.working-directory }}
- artifact-name: package-tarball-${{ matrix.artifact-suffix }}
+ artifact-name: package-tarball-${{ matrix.artifact-suffix }}-${{ github.run_id }}-${{ github.run_attempt }}
- name: Assert - Check "package" outputs
env:
@@ -57,6 +58,42 @@ jobs:
exit 1
fi
+ - id: package-tarball-artifact
+ name: Arrange - Export package tarball artifact ID
+ env:
+ ARTIFACT_SUFFIX: ${{ matrix.artifact-suffix }}
+ PACKAGE_TARBALL_ARTIFACT_ID: ${{ steps.act-package.outputs.package-tarball-artifact-id }}
+ run: echo "package-tarball-artifact-id-${ARTIFACT_SUFFIX}=${PACKAGE_TARBALL_ARTIFACT_ID}" >> "$GITHUB_OUTPUT"
+
+ assert-package-tarball:
+ name: Assert package tarball (${{ matrix.working-directory }})
+ needs: test
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ strategy:
+ matrix:
+ include:
+ - working-directory: tests/npm
+ artifact-suffix: npm
+ install-command: npm install --force --legacy-peer-deps --no-audit --no-fund --loglevel=warn
+ package-tarball-artifact-id: ${{ needs.test.outputs['package-tarball-artifact-id-npm'] }}
+ - working-directory: tests/pnpm
+ artifact-suffix: pnpm
+ install-command: pnpm install
+ package-tarball-artifact-id: ${{ needs.test.outputs['package-tarball-artifact-id-pnpm'] }}
+ - working-directory: tests/pnpm-package-manager
+ artifact-suffix: pnpm-package-manager
+ install-command: pnpm install
+ package-tarball-artifact-id: ${{ needs.test.outputs['package-tarball-artifact-id-pnpm-package-manager'] }}
+ - working-directory: tests/yarn
+ artifact-suffix: yarn
+ install-command: yarn add
+ package-tarball-artifact-id: ${{ needs.test.outputs['package-tarball-artifact-id-yarn'] }}
+ steps:
+ - name: Arrange - Checkout sources
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
- name: Arrange - Configure Node.js version
run: echo "lts/*" > .nvmrc
working-directory: ${{ matrix.working-directory }}
@@ -67,22 +104,44 @@ jobs:
with:
working-directory: ${{ matrix.working-directory }}
+ - name: Assert - Check package tarball artifact ID
+ env:
+ PACKAGE_TARBALL_ARTIFACT_ID: ${{ matrix.package-tarball-artifact-id }}
+ run: |
+ if [ -z "$PACKAGE_TARBALL_ARTIFACT_ID" ]; then
+ echo "package-tarball-artifact-id output is empty"
+ exit 1
+ fi
+
- name: Assert - Download package tarball artifact by ID
+ id: download-package-tarball
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
- artifact-ids: ${{ steps.act-package.outputs.package-tarball-artifact-id }}
+ artifact-ids: ${{ matrix.package-tarball-artifact-id }}
path: ${{ runner.temp }}
skip-decompress: true
+ github-token: ${{ github.token }}
- name: Assert - Check downloaded package tarball artifact
env:
- TARBALL_PATH: ${{ steps.act-package.outputs.package-tarball-path }}
+ DOWNLOAD_PATH: ${{ steps.download-package-tarball.outputs.download-path }}
INSTALL_COMMAND: ${{ matrix.install-command }}
- working-directory: ${{ runner.temp }}
run: |
- tarball_name="$(basename "$TARBALL_PATH")"
+ tarball_path="$DOWNLOAD_PATH"
+
+ if [ -d "$tarball_path" ]; then
+ tarball_matches="$(find "$tarball_path" -maxdepth 1 -type f -name '*.tgz')"
+ tarball_count="$(printf '%s\n' "$tarball_matches" | sed '/^$/d' | wc -l)"
+
+ if [ "$tarball_count" -ne 1 ]; then
+ echo "Expected exactly one downloaded package tarball, found $tarball_count"
+ exit 1
+ fi
+
+ tarball_path="$tarball_matches"
+ fi
- if [ ! -f "./$tarball_name" ]; then
+ if [ ! -f "$tarball_path" ]; then
echo "Downloaded package tarball artifact does not exist"
exit 1
fi
@@ -93,4 +152,4 @@ jobs:
fi
# Install the tarball to verify it's a valid npm package
- $INSTALL_COMMAND "./$tarball_name"
+ $INSTALL_COMMAND "$tarball_path"
diff --git a/.github/workflows/__test-workflow-release.yml b/.github/workflows/__test-workflow-release.yml
new file mode 100644
index 0000000..08cfd20
--- /dev/null
+++ b/.github/workflows/__test-workflow-release.yml
@@ -0,0 +1,38 @@
+name: Internal - Tests for "release" workflow
+
+on:
+ workflow_call:
+
+permissions: {}
+
+jobs:
+ package:
+ name: Arrange package tarball
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ outputs:
+ package-tarball-artifact-id: ${{ steps.package.outputs.package-tarball-artifact-id }}
+ steps:
+ - name: Arrange - Checkout sources
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - id: package
+ name: Arrange - Create package tarball
+ uses: ./actions/package
+ with:
+ working-directory: tests/npm
+ artifact-name: release-package-tarball-${{ github.run_id }}-${{ github.run_attempt }}
+
+ release:
+ name: Act - Run "release" workflow
+ needs: package
+ permissions:
+ contents: read
+ id-token: write
+ packages: write
+ uses: ./.github/workflows/release.yml
+ with:
+ package-tarball-artifact-id: ${{ needs.package.outputs.package-tarball-artifact-id }}
+ dry-run: true
+ provenance: false
diff --git a/.github/workflows/release.md b/.github/workflows/release.md
new file mode 100644
index 0000000..5691bb5
--- /dev/null
+++ b/.github/workflows/release.md
@@ -0,0 +1,205 @@
+
+
+# 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 publish the exact Node.js package tarball produced and verified by
+CI.
+
+The workflow downloads a raw `.tgz` artifact by immutable artifact ID, verifies
+that exactly one tarball is present, configures Node.js for the target registry,
+and runs `npm publish` against that tarball.
+
+### Permissions
+
+- **`contents`**: `read`
+- **`id-token`**: `write` (required for provenance)
+- **`packages`**: `write`
+
+
+
+
+
+## Usage
+
+### Publish a CI Package Tarball
+
+```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:
+ package-tarball-artifact-id: ${{ needs.ci.outputs.package-tarball-artifact-id }}
+```
+
+
+
+
+
+
+
+## Inputs
+
+### Workflow Call Inputs
+
+| **Input** | **Description** | **Required** | **Type** | **Default** |
+| --------------------------------- | ---------------------------------------------------------------------------------- | ------------ | ----------- | ---------------------------- |
+| **`runs-on`** | JSON array of runner(s) to use. | **false** | **string** | `["ubuntu-latest"]` |
+| | See . | | | |
+| **`package-tarball-artifact-id`** | Artifact ID of the package tarball produced by CI. | **true** | **string** | - |
+| **`registry-url`** | Registry URL used by npm publish. | **false** | **string** | `https://registry.npmjs.org` |
+| **`access`** | Package access level passed to npm publish. | **false** | **string** | `public` |
+| | Leave empty to use npm defaults. | | | |
+| **`tag`** | npm distribution tag for the published package. | **false** | **string** | `latest` |
+| | Common values: `latest`, `next`, `canary`. | | | |
+| **`provenance`** | Whether to generate npm provenance for npmjs.org publishes. | **false** | **boolean** | `true` |
+| **`dry-run`** | Whether to run npm publish without publishing the package. | **false** | **boolean** | `false` |
+
+
+
+
+
+
+
+## Secrets
+
+| **Secret** | **Description** | **Required** |
+| -------------------- | --------------------------------------------------------- | ------------ |
+| **`registry-token`** | Authentication token for token-based registry publishing. | **false** |
+
+
+
+
+
+## Examples
+
+### Publish Tested Tarball to npm
+
+```yaml
+name: Release
+
+on:
+ push:
+ tags: ["*"]
+
+permissions: {}
+
+jobs:
+ ci:
+ uses: ./.github/workflows/__shared-ci.yml
+ secrets: inherit
+ permissions:
+ contents: read
+ id-token: write
+ packages: read
+
+ 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:
+ package-tarball-artifact-id: ${{ needs.ci.outputs.package-tarball-artifact-id }}
+```
+
+### Dry Run
+
+```yaml
+name: Release dry run
+
+on:
+ workflow_dispatch:
+ inputs:
+ package-tarball-artifact-id:
+ description: Package tarball artifact ID from a previous CI run
+ required: true
+ type: string
+
+permissions: {}
+
+jobs:
+ dry-run:
+ uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/release.yml@main
+ permissions:
+ contents: read
+ packages: write
+ id-token: write
+ with:
+ package-tarball-artifact-id: ${{ inputs.package-tarball-artifact-id }}
+ dry-run: true
+ provenance: false
+```
+
+
+
+
+
+## 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..60d013d
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,184 @@
+# Workflow to release Node.js packages from a package tarball produced by CI.
+
+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
+ package-tarball-artifact-id:
+ description: "Artifact ID of the package tarball produced by CI."
+ type: string
+ required: true
+ registry-url:
+ description: "Registry URL used by npm publish."
+ type: string
+ required: false
+ default: "https://registry.npmjs.org"
+ access:
+ description: "Package access level passed to npm publish. Leave empty to use npm defaults."
+ type: string
+ required: false
+ default: "public"
+ 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"
+ provenance:
+ description: "Whether to generate npm provenance for npmjs.org publishes."
+ type: boolean
+ required: false
+ default: true
+ dry-run:
+ description: "Whether to run npm publish without publishing the package."
+ type: boolean
+ required: false
+ default: false
+ secrets:
+ github-token:
+ description: |
+ GitHub token to use for authentication.
+ Defaults to `GITHUB_TOKEN` if not provided.
+ required: false
+ registry-token:
+ description: "Authentication token for the package registry."
+ required: false
+
+permissions: {}
+
+jobs:
+ release:
+ name: 🚀 Release
+ runs-on: ${{ inputs.runs-on && fromJson(inputs.runs-on) || 'ubuntu-latest' }}
+ permissions:
+ contents: read
+ packages: write
+ id-token: write # Required for provenance
+ steps:
+ - name: Setup Node.js
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ with:
+ registry-url: ${{ inputs.registry-url }}
+
+ - name: Download package tarball
+ id: download-package-tarball
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ artifact-ids: ${{ inputs.package-tarball-artifact-id }}
+ path: ${{ runner.temp }}/package-tarball-${{ inputs.package-tarball-artifact-id }}
+ skip-decompress: true
+ github-token: ${{ secrets.github-token || github.token }}
+
+ - id: package-tarball
+ name: Locate package tarball
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ PACKAGE_TARBALL_DOWNLOAD_PATH: ${{ steps.download-package-tarball.outputs.download-path }}
+ with:
+ script: |
+ const fs = require('node:fs');
+ const path = require('node:path');
+
+ const downloadPath = process.env.PACKAGE_TARBALL_DOWNLOAD_PATH;
+
+ if (!fs.existsSync(downloadPath)) {
+ return core.setFailed(`Package tarball download path does not exist: ${downloadPath}`);
+ }
+
+ function findTarballs(directory) {
+ return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
+ const entryPath = path.join(directory, entry.name);
+
+ if (entry.isDirectory()) {
+ return findTarballs(entryPath);
+ }
+
+ if (entry.isFile() && entry.name.endsWith('.tgz')) {
+ return [entryPath];
+ }
+
+ return [];
+ });
+ }
+
+ const packageTarballPaths = findTarballs(downloadPath);
+
+ if (packageTarballPaths.length === 0) {
+ return core.setFailed(`Package tarball not found in ${downloadPath}`);
+ }
+
+ if (packageTarballPaths.length > 1) {
+ return core.setFailed(`Expected one package tarball, found ${packageTarballPaths.length}: ${packageTarballPaths.join(', ')}`);
+ }
+
+ core.info(`Package tarball: ${packageTarballPaths[0]}`);
+ core.setOutput('path', packageTarballPaths[0]);
+
+ - name: Publish package
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.registry-token }}
+ PACKAGE_TARBALL_PATH: ${{ steps.package-tarball.outputs.path }}
+ ACCESS: ${{ inputs.access }}
+ TAG: ${{ inputs.tag }}
+ PROVENANCE: ${{ inputs.provenance }}
+ DRY_RUN: ${{ inputs.dry-run }}
+ REGISTRY_URL: ${{ inputs.registry-url }}
+ with:
+ script: |
+ const path = require('node:path');
+
+ const packageTarballPath = process.env.PACKAGE_TARBALL_PATH;
+ const access = process.env.ACCESS.trim();
+ const tag = process.env.TAG.trim();
+ const registryUrl = process.env.REGISTRY_URL;
+ const dryRun = process.env.DRY_RUN === 'true';
+ const provenance = process.env.PROVENANCE === 'true';
+
+ const args = ['publish', packageTarballPath];
+
+ if (access) {
+ args.push('--access', access);
+ }
+
+ if (tag) {
+ args.push('--tag', tag);
+ }
+
+ if (provenance && registryUrl.includes('registry.npmjs.org')) {
+ args.push('--provenance');
+ }
+
+ if (dryRun) {
+ args.push('--dry-run');
+ }
+
+ core.info(`Publishing ${path.basename(packageTarballPath)} to ${registryUrl} with npm ${args.join(' ')}`);
+
+ try {
+ const exitCode = await exec.exec('npm', args, {
+ 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}`);
+ }
diff --git a/README.md b/README.md
index 765143c..c81f257 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,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 that publishes CI-produced package tarballs.
+
## Contributing
Contributions are welcome! Please review the [contributing guidelines](CONTRIBUTING.md) before opening a PR.