diff --git a/.github/workflows/__main-ci.yml b/.github/workflows/__main-ci.yml
index 3fba605..27a829d 100644
--- a/.github/workflows/__main-ci.yml
+++ b/.github/workflows/__main-ci.yml
@@ -34,7 +34,7 @@ jobs:
release:
needs: ci
if: github.event_name != 'schedule'
- uses: hoverkraft-tech/ci-github-publish/.github/workflows/release-actions.yml@1ee0354c40e4cd0a46c69cbe305c74fc67338042 # 0.28.0
+ uses: hoverkraft-tech/ci-github-publish/.github/workflows/release-actions.yml@a0a9d185c51c10710a5987822e78feb2b5ce1932 # 0.29.0
permissions:
contents: read
with:
diff --git a/.github/workflows/__shared-ci.yml b/.github/workflows/__shared-ci.yml
index 86bb0e8..59c09a5 100644
--- a/.github/workflows/__shared-ci.yml
+++ b/.github/workflows/__shared-ci.yml
@@ -56,6 +56,14 @@ jobs:
permissions:
contents: read
+ test-action-publish:
+ name: Test action "publish"
+ needs: linter
+ uses: ./.github/workflows/__test-action-publish.yml
+ permissions:
+ actions: read
+ contents: read
+
test-workflow-continuous-integration:
name: Test workflow "continuous-integration"
needs: linter
@@ -67,12 +75,3 @@ jobs:
id-token: write
issues: read
security-events: write
-
- 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-publish.yml b/.github/workflows/__test-action-publish.yml
new file mode 100644
index 0000000..4531194
--- /dev/null
+++ b/.github/workflows/__test-action-publish.yml
@@ -0,0 +1,83 @@
+name: Internal - Tests for "publish" action
+
+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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - id: package
+ name: Arrange - Create package tarball
+ uses: ./actions/package
+ with:
+ working-directory: tests/npm
+
+ publish:
+ name: Publish dry run (${{ matrix.os }}, ${{ matrix.tag || 'default tag' }})
+ needs: package
+ runs-on: ${{ matrix.os }}
+ permissions:
+ actions: read
+ contents: read
+ strategy:
+ matrix:
+ os: [ubuntu-latest, windows-latest]
+ tag: ["", next]
+ steps:
+ - name: Arrange - Checkout sources
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Act - Run "publish" action
+ uses: ./actions/publish
+ with:
+ package-tarball-artifact-id: ${{ needs.package.outputs.package-tarball-artifact-id }}
+ tag: ${{ matrix.tag }}
+ access: ${{ matrix.tag == 'next' && 'restricted' || 'public' }}
+ dry-run: "true"
+ provenance: "false"
+
+ - id: invalid-artifact
+ name: Act - Reject multiple artifact IDs
+ continue-on-error: true
+ uses: ./actions/publish
+ with:
+ package-tarball-artifact-id: "1,2"
+ dry-run: "true"
+ provenance: "false"
+
+ - id: invalid-dry-run
+ name: Act - Reject invalid dry-run input
+ continue-on-error: true
+ uses: ./actions/publish
+ with:
+ package-tarball-artifact-id: ${{ needs.package.outputs.package-tarball-artifact-id }}
+ dry-run: "yes"
+ provenance: "false"
+
+ - name: Assert - Invalid inputs fail before publishing
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ INVALID_ARTIFACT_OUTCOME: ${{ steps.invalid-artifact.outcome }}
+ INVALID_DRY_RUN_OUTCOME: ${{ steps.invalid-dry-run.outcome }}
+ with:
+ script: |
+ for (const outcome of [process.env.INVALID_ARTIFACT_OUTCOME, process.env.INVALID_DRY_RUN_OUTCOME]) {
+ if (outcome !== 'failure') {
+ core.setFailed('Expected invalid publish inputs to fail');
+ }
+ }
diff --git a/.github/workflows/__test-workflow-release.yml b/.github/workflows/__test-workflow-release.yml
deleted file mode 100644
index 3d7acee..0000000
--- a/.github/workflows/__test-workflow-release.yml
+++ /dev/null
@@ -1,39 +0,0 @@
-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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
-
- - id: package
- name: Arrange - Create package tarball
- uses: ./actions/package
- with:
- working-directory: tests/npm
-
- 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
index 1c78eab..08cc6f2 100644
--- a/.github/workflows/release.md
+++ b/.github/workflows/release.md
@@ -1,235 +1,216 @@
-
+# Release a Node.js package
-# GitHub Reusable Workflow: Node.js Release
+Publish a package to npm and attach the same tarball to a GitHub release:
-
-

-
+**Plan version → Run CI → Package build output → Publish to npm → Create GitHub release**
----
+The workflow below releases a single package from the repository root using stable versions such as `1.2.3`.
+It sets the version during packaging. If your build reads the package version or you commit version changes,
+use [Commit the version before building](#commit-the-version-before-building).
-
+## Set up
-
+1. Configure [release labels and version rules](https://github.com/hoverkraft-tech/ci-github-publish/blob/0.29.0/.github/workflows/prepare-release.md).
+ These determine the version and release notes.
+2. Check the package's `package.json`: set `name`, `files`, and `repository.url`, and ensure `private` is not `true`.
+3. Configure [npm trusted publishing](../../actions/publish/README.md#npm-trusted-publishing) for your repository and workflow filename, `release.yml`.
+ The example uses GitHub-hosted runners and `id-token: write`; no npm token is needed.
+ For a new npm package, publish its first version before configuring the trusted publisher.
+4. Save the workflow below as `.github/workflows/release.yml` in your project. Before running it:
+ - Replace every `` with the **same `ci-github-nodejs` commit containing `actions/publish`**.
+ The `ci-github-publish` actions are already pinned to `0.29.0`.
+ - Set the `build`, `lint:ci`, and `test:ci` script names and the `dist/` output path to match your project.
+ See [CI options](continuous-integration.md) if you need a different setup.
-[](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 from a package tarball produced by CI.
-
-### Permissions
-
-- **`contents`**: `read`
-- **`id-token`**: `write`
-- **`packages`**: `write`
-
-
-
-
-
-## Usage
-
-```yaml
-name: Node.js Release
-on:
- push:
- branches:
- - main
-permissions: {}
-jobs:
- release:
- uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/release.yml@df348077afa4e79725151d50606e9dc63f86dcb6 # 0.24.4
- permissions:
- contents: read
- id-token: write
- packages: write
- secrets:
- # GitHub token to use when downloading the package tarball artifact.
- # Defaults to `GITHUB_TOKEN` if not provided.
- github-token: ""
- with:
- # JSON array of runner(s) to use.
- # See https://docs.github.com/en/actions/using-jobs/choosing-the-runner-for-a-job.
- #
- # Default: `["ubuntu-latest"]`
- runs-on: '["ubuntu-latest"]'
-
- # Artifact ID of the package tarball produced by CI.
- # This input is required.
- package-tarball-artifact-id: ""
-
- # Registry URL used by npm publish.
- # Default: `https://registry.npmjs.org`
- registry-url: https://registry.npmjs.org
-
- # Package access level passed to npm publish. Leave empty to use npm defaults.
- # Default: `public`
- access: public
-
- # npm distribution tag for the published package. Leave empty to use npm defaults.
- # Common values:
- # - `latest` - Default tag for stable releases
- # - `next` - Prerelease or beta versions
- # - `canary` - Canary/nightly builds
- #
- # See https://docs.npmjs.com/adding-dist-tags-to-packages.
- tag: ""
-
- # Whether to generate npm provenance for npmjs.org publishes.
- # Default: `true`
- provenance: true
-
- # Whether to run npm publish without publishing the package.
- dry-run: false
-```
-
-
-
-
-
-
-
-## 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. Leave empty to use npm defaults. | **false** | **string** | `public` |
-| **`tag`** | npm distribution tag for the published package. Leave empty to use npm defaults. | **false** | **string** | - |
-| | Common values: | | | |
-| | - `latest` - Default tag for stable releases | | | |
-| | - `next` - Prerelease or beta versions | | | |
-| | - `canary` - Canary/nightly builds | | | |
-| | | | | |
-| | See . | | | |
-| **`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** |
-| ------------------ | ------------------------------------------------------------------ | ------------ |
-| **`github-token`** | GitHub token to use when downloading the package tarball artifact. | **false** |
-| | Defaults to `GITHUB_TOKEN` if not provided. | |
-
-
-
-
-
-
-
-
-## Examples
-
-### Publish Tested Tarball to npm
+## Workflow
```yaml
name: Release
on:
- push:
- tags: ["*"]
+ schedule:
+ - cron: "25 8 * * 1"
+ workflow_dispatch:
+ inputs:
+ force:
+ description: Release even when no relevant changes are detected
+ type: boolean
+ required: false
+ default: false
permissions: {}
+concurrency:
+ group: release-${{ github.repository }}
+ cancel-in-progress: false
+
jobs:
+ plan:
+ if: github.ref_name == github.event.repository.default_branch
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: read
+ outputs:
+ should-release: ${{ steps.plan.outputs.has-changes == 'true' || (github.event_name == 'workflow_dispatch' && inputs.force) }}
+ tag: ${{ steps.plan.outputs.tag }}
+ version: ${{ steps.version.outputs.version }}
+ steps:
+ - name: Plan release
+ id: plan
+ uses: hoverkraft-tech/ci-github-publish/actions/release/plan@a0a9d185c51c10710a5987822e78feb2b5ce1932 # 0.29.0
+
+ - id: version
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ RELEASE_TAG: ${{ steps.plan.outputs.tag }}
+ with:
+ script: |
+ // This example uses tags such as v1.2.3 or 1.2.3.
+ const version = process.env.RELEASE_TAG.replace(/^v/, '');
+ if (!/^\d+\.\d+\.\d+$/.test(version)) {
+ return core.setFailed('Expected a stable SemVer tag; adapt the mapping for prereleases or monorepos');
+ }
+ core.setOutput('version', version);
+
ci:
- uses: ./.github/workflows/__shared-ci.yml
- secrets: inherit
+ needs: plan
+ if: needs.plan.outputs.should-release == 'true'
+ uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/continuous-integration.yml@
permissions:
contents: read
- id-token: write
packages: read
+ pull-requests: write
+ id-token: write
+ security-events: write
+ with:
+ build: '{"commands":["build"],"artifact":{"paths":["dist/"]}}'
+ lint: '{"command":"lint:ci"}'
+ test: '{"command":"test:ci"}'
release:
- needs: ci
- uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/release.yml@df348077afa4e79725151d50606e9dc63f86dcb6 # 0.24.4
+ needs: [plan, ci]
+ if: needs.plan.outputs.should-release == 'true'
+ runs-on: ubuntu-latest
permissions:
- contents: read
- packages: write
+ actions: read
+ contents: write
+ pull-requests: read
id-token: write
- with:
- package-tarball-artifact-id: ${{ needs.ci.outputs.package-tarball-artifact-id }}
+ steps:
+ - name: Package build output
+ id: package
+ uses: hoverkraft-tech/ci-github-nodejs/actions/package@
+ with:
+ version: ${{ needs.plan.outputs.version }}
+ build-artifact-id: ${{ needs.ci.outputs.build-artifact-id }}
+ # The reusable CI workflow preserves absolute paths in build artifacts.
+ build-artifact-path: /
+
+ # Add any package smoke tests before publishing.
+ - name: Check package publishing
+ uses: hoverkraft-tech/ci-github-nodejs/actions/publish@
+ with:
+ package-tarball-artifact-id: ${{ steps.package.outputs.package-tarball-artifact-id }}
+ dry-run: "true"
+ provenance: "false"
+
+ # Wrap the raw .tgz in a ZIP artifact for release/create.
+ - id: release-assets
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: release-assets
+ path: ${{ steps.package.outputs.package-tarball-path }}
+ if-no-files-found: error
+
+ - id: publish-package
+ name: Publish package
+ uses: hoverkraft-tech/ci-github-nodejs/actions/publish@
+ with:
+ package-tarball-artifact-id: ${{ steps.package.outputs.package-tarball-artifact-id }}
+ tag: latest
+
+ - name: Create GitHub release
+ uses: hoverkraft-tech/ci-github-publish/actions/release/create@a0a9d185c51c10710a5987822e78feb2b5ce1932 # 0.29.0
+ with:
+ tag: ${{ needs.plan.outputs.tag }}
+ target-sha: ${{ github.sha }}
+ release-artifact-id: ${{ steps.release-assets.outputs.artifact-id }}
+ publish: "true"
```
-### Dry Run
-
-```yaml
-name: Release dry run
+[Package](../../actions/package/README.md) checks out the source, installs dependencies, restores the CI build output,
+and creates the tarball. [Publish](../../actions/publish/README.md) publishes that tarball.
+The package version is changed locally; it is not committed. GitHub's source archive therefore keeps the version from the source commit.
+If `package.json` already contains the planned version, omit the Package action's `version` input.
-on:
- workflow_dispatch:
- inputs:
- package-tarball-artifact-id:
- description: Package tarball artifact ID from a previous CI run
- required: true
- type: string
+## Run a release
-permissions: {}
+In **Actions → Release → Run workflow**, select the default branch. The workflow also runs every Monday at 08:25 UTC.
+Releases are serialized so two runs cannot publish the same package concurrently.
-jobs:
- dry-run:
- uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/release.yml@df348077afa4e79725151d50606e9dc63f86dcb6 # 0.24.4
- permissions:
- contents: read
- packages: write
- id-token: write
- with:
- package-tarball-artifact-id: ${{ inputs.package-tarball-artifact-id }}
- dry-run: true
- provenance: false
-```
+| Situation | Result |
+| ---------------------------------------------- | ---------------------------------------------------------------------------------- |
+| Relevant changes since the last GitHub release | Plan the next version, run CI, and release. |
+| No relevant changes | Skip CI and publishing successfully. |
+| Manual run with `force` checked | Release the planned version even without changes; CI must still pass. |
+| No previous matching GitHub release | Plan the first GitHub release. Registry authentication must already be configured. |
-
+The dry run checks the tarball with `npm publish --dry-run`. It does not verify registry credentials or trusted publishing access.
+For a first trial without publishing, keep the dry-run step and remove the **Publish package** and **Create GitHub release** steps.
-
+## Commit the version before building
-## Contributing
+Use this when the build embeds the version or the released source must include updated version files or a changelog:
-Contributions are welcome! Please see the [contributing guidelines](https://github.com/hoverkraft-tech/ci-github-nodejs/blob/main/CONTRIBUTING.md) for more details.
+1. Plan the version with `release/plan`, using the same no-change and `force` rules as the workflow above.
+2. Update the version on a release branch, for example with `npm version 1.2.3 --no-git-tag-version`.
+ Commit the package manifest, lockfile, changelog, and any generated version files through a pull request.
+3. After merging, run CI and packaging **from the merged commit**. Omit the Package action's `version` input; the version is already in `package.json`.
+4. Publish the tarball, then create the GitHub release with the planned `tag` and merged commit as `target-sha`.
-
+Keep the planned tag throughout this process; do not calculate another version after merging.
+If you use a second workflow for publication, read the committed version instead of calling `release/plan` again,
+and skip it when that version is already released.
-
-
+The Package action performs its own checkout of the workflow's source ref.
+Checking out the merged commit in an earlier step does not change that ref: start the packaging workflow at the merged commit.
+See the [release preparation example](https://github.com/hoverkraft-tech/ci-github-publish/blob/0.29.0/.github/workflows/release.md#example-3-validate-source-update-files-create-release-artifacts-and-release)
+for automating the pull request and merge.
-
+## Other setups
-## License
+- **GitHub Packages or registry tokens:** use the [authentication examples](../../actions/publish/README.md#registry-tokens-and-github-packages).
+ The `github-token` input only authenticates artifact downloads; registry credentials use `NODE_AUTH_TOKEN`.
+- **Prereleases:** set `prerelease: "true"` on `release/plan` and `release/create`, and `tag: next` on both Publish steps.
+ Update the version conversion to accept your full prerelease version, such as `1.2.3-rc.1`.
+ The npm distribution tag (`next`) is separate from the Git release tag (`v1.2.3-rc.1`).
+- **Monorepos:** scope CI and both release actions with `working-directory`.
+ For Package, set `working-directory` to the dependency installation root and `package-directory` to the package's relative path.
+ Convert prefixed Git tags such as `my-package/v1.2.3` to npm versions, and use a separate concurrency group and artifact name per package.
+- **Drafts and signing:** create a draft with `publish: "false"`, then use `release/update` to attach assets and publish after npm publication succeeds.
+ See the [draft release example](https://github.com/hoverkraft-tech/ci-github-publish/blob/0.29.0/.github/workflows/release.md#example-2-validate-source-create-release-artifacts-and-release).
+ Use `release/delete` with `draft-only: "true"` to clean up a temporary draft on failure **only if the npm package has not been published**.
-This project is licensed under the MIT License.
+## Recover a failed release
-SPDX-License-Identifier: MIT
+Check the registry before retrying: an npm package version cannot be overwritten, and a failed run may still have published it.
-Copyright © 2026 hoverkraft-tech
+| Where it failed | What to do |
+| ------------------------------------------------- | ------------------------------------------------------------------------ |
+| Before npm publication | Fix the failure and rerun the workflow. |
+| npm published, but GitHub release creation failed | Run only release creation with the original tag, source SHA, and assets. |
+| npm published, but a draft was not finalized | Keep the draft and retry `release/update`. |
-For more details, see the [license](http://choosealicense.com/licenses/mit/).
+After npm publication, do not rerun the whole release job or plan a new version to finish the same release.
+Deleting a draft does not undo npm publication.
-
-
+## Migrate from the reusable release workflow
----
+Replace calls to `ci-github-nodejs/.github/workflows/release.yml` with a job that uses [the Publish action](../../actions/publish/README.md#usage):
-This documentation was automatically generated by [CI Dokumentor](https://github.com/hoverkraft-tech/ci-dokumentor).
+- Move `runs-on` and permissions to the caller job.
+- Move the publishing inputs to the action step, quoting `provenance` and `dry-run` as `"true"` or `"false"`.
+- Move the optional `github-token` secret to the action's `github-token` input.
-
+Use the workflow above when you also need version planning, CI, and a GitHub release.
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
deleted file mode 100644
index fbf4e12..0000000
--- a/.github/workflows/release.yml
+++ /dev/null
@@ -1,181 +0,0 @@
-# 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. Leave empty to use npm defaults.
- Common values:
- - `latest` - Default tag for stable releases
- - `next` - Prerelease or beta versions
- - `canary` - Canary/nightly builds
-
- See https://docs.npmjs.com/adding-dist-tags-to-packages.
- type: string
- required: false
- default: ""
- 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 when downloading the package tarball artifact.
- Defaults to `GITHUB_TOKEN` if not provided.
- 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 generation and publishing to registries that require authentication with an OIDC token
- steps:
- - name: Setup Node.js
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- with:
- registry-url: ${{ inputs.registry-url }}
- node-version: "lts/*"
-
- - 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 }} # zizmor: ignore[secrets-outside-env] reusable workflow token override is intentional
-
- - 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:
- 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 670527f..2b72b23 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,8 @@ _Actions for continuous integration steps: build, lint, and test._
#### - [Test](actions/test/README.md)
+#### - [Publish](actions/publish/README.md)
+
### Dependencies
_Actions dedicated to caching and validating Node.js dependencies._
@@ -49,7 +51,7 @@ _Actions focused on discovering and preparing the Node.js environment._
#### - [Setup node](actions/setup-node/README.md)
-## Reusable Workflows
+## Reusable Workflows and Guides
### Continuous Integration
@@ -57,7 +59,7 @@ _Actions focused on discovering and preparing the Node.js environment._
### Release
-- [Release](.github/workflows/release.md) — documentation for the reusable Node.js release workflow that publishes CI-produced package tarballs.
+- [Release](.github/workflows/release.md) — guide to Node.js project releases using `ci-github-publish` release actions and the package/publish actions.
## Contributing
diff --git a/actions/package/README.md b/actions/package/README.md
index 031357b..69a5c07 100644
--- a/actions/package/README.md
+++ b/actions/package/README.md
@@ -99,6 +99,9 @@ Action to create and upload an npm package tarball from a Node.js project
## Examples
+Use the [Publish action](../publish/README.md) to publish the resulting tarball.
+See the [release guide](../../.github/workflows/release.md) for version planning and GitHub release orchestration.
+
```yaml
jobs:
package:
diff --git a/actions/publish/README.md b/actions/publish/README.md
new file mode 100644
index 0000000..f5bf7b8
--- /dev/null
+++ b/actions/publish/README.md
@@ -0,0 +1,108 @@
+
+
+# GitHub Action: Publish
+
+
+
+
+## Overview
+
+Publish the exact Node.js package tarball produced by the [Package action](../package/README.md) to an npm-compatible registry.
+The action downloads one artifact, locates exactly one `.tgz`, sets up the current Node.js LTS runtime, and runs `npm publish`.
+It does not rebuild the package, change its version, or create a GitHub release.
+
+For version planning, source validation, release files, and GitHub releases, follow the [Node.js release guide](../../.github/workflows/release.md).
+
+
+
+
+## Usage
+
+Replace `` with a commit containing this action, then pin that revision in your workflow.
+
+```yaml
+jobs:
+ publish:
+ needs: package
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ contents: read
+ id-token: write
+ steps:
+ - uses: hoverkraft-tech/ci-github-nodejs/actions/publish@
+ with:
+ package-tarball-artifact-id: ${{ needs.package.outputs.package-tarball-artifact-id }}
+```
+
+The `package` job must expose the Package action's `package-tarball-artifact-id` output.
+No checkout or dependency installation is required in the publishing job.
+
+
+
+
+## Inputs
+
+| Input | Description | Required | Default |
+| ----------------------------- | ------------------------------------------------------------------ | -------- | ---------------------------- |
+| `package-tarball-artifact-id` | One artifact ID from the Package action. | Yes | — |
+| `registry-url` | Target npm-compatible registry URL. | No | `https://registry.npmjs.org` |
+| `access` | `public`, `restricted`, or empty for npm defaults. | No | `public` |
+| `tag` | npm distribution tag, such as `latest`, `next`, or `canary`. | No | Empty (npm defaults) |
+| `provenance` | Request provenance for the public npm registry: `true` or `false`. | No | `true` |
+| `dry-run` | Validate publishing without uploading: `true` or `false`. | No | `false` |
+| `github-token` | Token for downloading the artifact, separate from registry access. | No | `${{ github.token }}` |
+
+Boolean inputs are strings in a composite action; quote `"true"` and `"false"` in YAML.
+Artifacts uploaded by the Package action use `archive: false`; this action downloads them with `skip-decompress: true`.
+When supplying a GitHub token, grant it `actions: read` on the artifact's repository.
+
+
+
+
+## Authentication
+
+### npm trusted publishing
+
+Configure an [npm trusted publisher](https://docs.npmjs.com/trusted-publishers/) for your repository and the caller workflow filename.
+Use a GitHub-hosted runner and grant the publishing job `id-token: write`.
+The action uses the current Node.js LTS runtime; trusted publishing requires Node.js 22.14.0 or newer and npm 11.5.1 or newer.
+No npm token is needed. The npm package must already exist before configuring its trusted publisher.
+For public packages, ensure `package.json` has a `repository.url` matching the GitHub repository for provenance.
+
+### Registry tokens and GitHub Packages
+
+Provide registry credentials as `NODE_AUTH_TOKEN` on the action step.
+For npm token authentication, use an npm publishing token stored in a repository or environment secret.
+For GitHub Packages, use a scoped package name, set the registry URL, and grant the job `packages: write`:
+
+```yaml
+- uses: hoverkraft-tech/ci-github-nodejs/actions/publish@
+ with:
+ package-tarball-artifact-id: ${{ needs.package.outputs.package-tarball-artifact-id }}
+ registry-url: https://npm.pkg.github.com
+ provenance: "false"
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+```
+
+The `github-token` input only authenticates artifact downloads; it is not an npm publishing credential.
+
+## Dry runs and prereleases
+
+A dry run downloads the same tarball and executes `npm publish --dry-run` without publishing:
+
+```yaml
+- uses: hoverkraft-tech/ci-github-nodejs/actions/publish@
+ with:
+ package-tarball-artifact-id: ${{ needs.package.outputs.package-tarball-artifact-id }}
+ dry-run: "true"
+ provenance: "false"
+ tag: next
+```
+
+Dry runs do not verify registry authorization or reserve the package version.
+Use `tag: next` for a prerelease tarball such as `1.2.0-rc.1`; the distribution tag does not change the version inside it.
+Publish only after the checks for that exact artifact succeed. Re-running a successful publish for the same package version fails because npm versions are immutable.
+
+
diff --git a/actions/publish/action.yml b/actions/publish/action.yml
new file mode 100644
index 0000000..06434e8
--- /dev/null
+++ b/actions/publish/action.yml
@@ -0,0 +1,194 @@
+name: "Publish"
+description: "Publish a CI-produced Node.js package tarball to an npm-compatible registry"
+author: hoverkraft
+branding:
+ icon: upload-cloud
+ color: blue
+
+inputs:
+ package-tarball-artifact-id:
+ description: "Artifact ID of one package tarball uploaded by the package action"
+ required: true
+ registry-url:
+ description: "Registry URL used by npm publish"
+ required: false
+ default: "https://registry.npmjs.org"
+ access:
+ description: "Package access: public, restricted, or empty to use npm defaults"
+ required: false
+ default: "public"
+ tag:
+ description: "npm distribution tag, such as latest, next, or canary; empty uses npm defaults"
+ required: false
+ default: ""
+ provenance:
+ description: "Whether to request provenance for npmjs.org publishes (true or false)"
+ required: false
+ default: "true"
+ dry-run:
+ description: "Validate publishing without uploading the package (true or false)"
+ required: false
+ default: "false"
+ github-token:
+ description: "GitHub token for downloading the artifact; registry authentication uses NODE_AUTH_TOKEN or OIDC"
+ required: false
+ default: ${{ github.token }}
+
+runs:
+ using: "composite"
+ steps:
+ - id: validate
+ name: Validate publish inputs
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ PACKAGE_TARBALL_ARTIFACT_ID: ${{ inputs.package-tarball-artifact-id }}
+ ACCESS: ${{ inputs.access }}
+ PROVENANCE: ${{ inputs.provenance }}
+ DRY_RUN: ${{ inputs.dry-run }}
+ REGISTRY_URL: ${{ inputs.registry-url }}
+ RUNNER_TEMP: ${{ runner.temp }}
+ with:
+ script: |
+ const fs = require('node:fs');
+ const path = require('node:path');
+
+ if (!/^[1-9][0-9]*$/.test(process.env.PACKAGE_TARBALL_ARTIFACT_ID || '')) {
+ return core.setFailed('package-tarball-artifact-id must be a single positive artifact ID');
+ }
+
+ if (!['', 'public', 'restricted'].includes(process.env.ACCESS.trim())) {
+ return core.setFailed('access must be public, restricted, or empty');
+ }
+
+ for (const input of ['PROVENANCE', 'DRY_RUN']) {
+ if (!['true', 'false'].includes(process.env[input])) {
+ return core.setFailed(`${input.toLowerCase().replaceAll('_', '-')} must be true or false`);
+ }
+ }
+
+ try {
+ const registry = new URL(process.env.REGISTRY_URL);
+ if (!['https:', 'http:'].includes(registry.protocol)) {
+ return core.setFailed('registry-url must use HTTP or HTTPS');
+ }
+ } catch {
+ return core.setFailed('registry-url must be a valid URL');
+ }
+
+ core.setOutput('download-path', fs.mkdtempSync(path.join(process.env.RUNNER_TEMP, 'package-publish-')));
+
+ - name: Setup Node.js
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ registry-url: ${{ inputs.registry-url }}
+ node-version: "lts/*"
+
+ - 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: ${{ steps.validate.outputs.download-path }}
+ skip-decompress: true
+ github-token: ${{ inputs.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 = fs.statSync(downloadPath).isDirectory()
+ ? findTarballs(downloadPath)
+ : downloadPath.endsWith('.tgz') ? [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:
+ 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, '--registry', registryUrl];
+
+ if (access) {
+ args.push('--access', access);
+ }
+
+ if (tag) {
+ args.push('--tag', tag);
+ }
+
+ if (provenance && new URL(registryUrl).origin === 'https://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(dryRun ? 'Package publish dry run completed successfully.' : 'Package published successfully!');
+ } catch (error) {
+ return core.setFailed(`Package publish failed: ${error.message}`);
+ }