From 722c54acf70d8c41a92109ca61784ccaa508a683 Mon Sep 17 00:00:00 2001 From: Emilien Escalle Date: Wed, 30 Sep 2026 19:05:48 +0200 Subject: [PATCH] feat(release)!: replace release workflow with publish action Publish CI-produced package tarballs from a composite action so callers can control validation and GitHub release ordering in their own jobs. Add Linux and Windows dry-run coverage, pin ci-github-publish to 0.29.0, and document setup, release planning, authentication, and recovery. BREAKING CHANGE: remove .github/workflows/release.yml. Callers must use actions/publish in a job, set runner and permissions on that job, pass github-token as an action input, and quote boolean inputs as strings. --- .github/workflows/__main-ci.yml | 2 +- .github/workflows/__shared-ci.yml | 17 +- .github/workflows/__test-action-publish.yml | 83 ++++ .github/workflows/__test-workflow-release.yml | 39 -- .github/workflows/release.md | 363 +++++++++--------- .github/workflows/release.yml | 181 --------- README.md | 6 +- actions/package/README.md | 3 + actions/publish/README.md | 108 ++++++ actions/publish/action.yml | 194 ++++++++++ 10 files changed, 573 insertions(+), 423 deletions(-) create mode 100644 .github/workflows/__test-action-publish.yml delete mode 100644 .github/workflows/__test-workflow-release.yml delete mode 100644 .github/workflows/release.yml create mode 100644 actions/publish/README.md create mode 100644 actions/publish/action.yml 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: -
- Node.js 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. -[![Release](https://img.shields.io/github/v/release/hoverkraft-tech/ci-github-nodejs)](https://github.com/hoverkraft-tech/ci-github-nodejs/releases) -[![License](https://img.shields.io/github/license/hoverkraft-tech/ci-github-nodejs)](http://choosealicense.com/licenses/mit/) -[![Stars](https://img.shields.io/github/stars/hoverkraft-tech/ci-github-nodejs?style=social)](https://img.shields.io/github/stars/hoverkraft-tech/ci-github-nodejs?style=social) -[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/hoverkraft-tech/ci-github-nodejs/blob/main/CONTRIBUTING.md) -![GitHub Verified Creator](https://img.shields.io/badge/GitHub-Verified%20Creator-4493F8?logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiIgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2IiBmaWxsPSJyZ2IoNjgsIDE0NywgMjQ4KSI+CiAgPHBhdGggZD0ibTkuNTg1LjUyLjkyOS42OGMuMTUzLjExMi4zMzEuMTg2LjUxOC4yMTVsMS4xMzguMTc1YTIuNjc4IDIuNjc4IDAgMCAxIDIuMjQgMi4yNGwuMTc0IDEuMTM5Yy4wMjkuMTg3LjEwMy4zNjUuMjE1LjUxOGwuNjguOTI4YTIuNjc3IDIuNjc3IDAgMCAxIDAgMy4xN2wtLjY4LjkyOGExLjE3NCAxLjE3NCAwIDAgMC0uMjE1LjUxOGwtLjE3NSAxLjEzOGEyLjY3OCAyLjY3OCAwIDAgMS0yLjI0MSAyLjI0MWwtMS4xMzguMTc1YTEuMTcgMS4xNyAwIDAgMC0uNTE4LjIxNWwtLjkyOC42OGEyLjY3NyAyLjY3NyAwIDAgMS0zLjE3IDBsLS45MjgtLjY4YTEuMTc0IDEuMTc0IDAgMCAwLS41MTgtLjIxNUwzLjgzIDE0LjQxYTIuNjc4IDIuNjc4IDAgMCAxLTIuMjQtMi4yNGwtLjE3NS0xLjEzOGExLjE3IDEuMTcgMCAwIDAtLjIxNS0uNTE4bC0uNjgtLjkyOGEyLjY3NyAyLjY3NyAwIDAgMSAwLTMuMTdsLjY4LS45MjhjLjExMi0uMTUzLjE4Ni0uMzMxLjIxNS0uNTE4bC4xNzUtMS4xNGEyLjY3OCAyLjY3OCAwIDAgMSAyLjI0LTIuMjRsMS4xMzktLjE3NWMuMTg3LS4wMjkuMzY1LS4xMDMuNTE4LS4yMTVsLjkyOC0uNjhhMi42NzcgMi42NzcgMCAwIDEgMy4xNyAwWk03LjMwMyAxLjcyOGwtLjkyNy42OGEyLjY3IDIuNjcgMCAwIDEtMS4xOC40ODlsLTEuMTM3LjE3NGExLjE3OSAxLjE3OSAwIDAgMC0uOTg3Ljk4N2wtLjE3NCAxLjEzNmEyLjY3NyAyLjY3NyAwIDAgMS0uNDg5IDEuMThsLS42OC45MjhhMS4xOCAxLjE4IDAgMCAwIDAgMS4zOTRsLjY4LjkyN2MuMjU2LjM0OC40MjQuNzUzLjQ4OSAxLjE4bC4xNzQgMS4xMzdjLjA3OC41MDkuNDc4LjkwOS45ODcuOTg3bDEuMTM2LjE3NGEyLjY3IDIuNjcgMCAwIDEgMS4xOC40ODlsLjkyOC42OGMuNDE0LjMwNS45NzkuMzA1IDEuMzk0IDBsLjkyNy0uNjhhMi42NyAyLjY3IDAgMCAxIDEuMTgtLjQ4OWwxLjEzNy0uMTc0YTEuMTggMS4xOCAwIDAgMCAuOTg3LS45ODdsLjE3NC0xLjEzNmEyLjY3IDIuNjcgMCAwIDEgLjQ4OS0xLjE4bC42OC0uOTI4YTEuMTc2IDEuMTc2IDAgMCAwIDAtMS4zOTRsLS42OC0uOTI3YTIuNjg2IDIuNjg2IDAgMCAxLS40ODktMS4xOGwtLjE3NC0xLjEzN2ExLjE3OSAxLjE3OSAwIDAgMC0uOTg3LS45ODdsLTEuMTM2LS4xNzRhMi42NzcgMi42NzcgMCAwIDEtMS4xOC0uNDg5bC0uOTI4LS42OGExLjE3NiAxLjE3NiAwIDAgMC0xLjM5NCAwWk0xMS4yOCA2Ljc4bC0zLjc1IDMuNzVhLjc1Ljc1IDAgMCAxLTEuMDYgMEw0LjcyIDguNzhhLjc1MS43NTEgMCAwIDEgLjAxOC0xLjA0Mi43NTEuNzUxIDAgMCAxIDEuMDQyLS4wMThMNyA4Ljk0bDMuMjItMy4yMmEuNzUxLjc1MSAwIDAgMSAxLjA0Mi4wMTguNzUxLjc1MSAwIDAgMSAuMDE4IDEuMDQyWiI+PC9wYXRoPgo8L3N2Zz4K) - - - - - -## 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}`); + }