diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index acf2a593..51c907b7 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -35,6 +35,7 @@ updates:
versioning-strategy: increase
directories:
- "/"
+ - "/actions/**/*"
- "/tests/npm"
- "/tests/pnpm"
- "/tests/pnpm-package-manager"
diff --git a/.github/workflows/__shared-ci.yml b/.github/workflows/__shared-ci.yml
index 7d0c1813..bc33e30b 100644
--- a/.github/workflows/__shared-ci.yml
+++ b/.github/workflows/__shared-ci.yml
@@ -17,6 +17,17 @@ jobs:
security-events: write
statuses: write
+ test-release:
+ name: Test release preparation and recovery
+ uses: $/.github/workflows/__test-release.yml
+ permissions:
+ actions: read
+ contents: read
+ id-token: write
+ packages: read
+ pull-requests: write
+ security-events: write
+
test-action-dependencies-cache:
name: Test action "dependencies-cache"
needs: linter
diff --git a/.github/workflows/__test-action-package-publish.yml b/.github/workflows/__test-action-package-publish.yml
index 88ac66ca..711d140e 100644
--- a/.github/workflows/__test-action-package-publish.yml
+++ b/.github/workflows/__test-action-package-publish.yml
@@ -75,14 +75,26 @@ jobs:
dry-run: "yes"
provenance: "false"
+ - id: wrong-identity
+ name: Act - Reject an unintended release package
+ continue-on-error: true
+ uses: $/actions/package/publish
+ with:
+ package-tarball-artifact-id: ${{ needs.package.outputs.package-tarball-artifact-id }}
+ name: another-package
+ version: 0.1.0
+ dry-run: "true"
+ 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 }}
+ WRONG_IDENTITY_OUTCOME: ${{ steps.wrong-identity.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]) {
+ for (const outcome of [process.env.INVALID_ARTIFACT_OUTCOME, process.env.INVALID_DRY_RUN_OUTCOME, process.env.WRONG_IDENTITY_OUTCOME]) {
if (outcome !== 'failure') {
core.setFailed('Expected invalid publish inputs to fail');
}
diff --git a/.github/workflows/__test-release.yml b/.github/workflows/__test-release.yml
new file mode 100644
index 00000000..aa800ad4
--- /dev/null
+++ b/.github/workflows/__test-release.yml
@@ -0,0 +1,179 @@
+name: Internal - Tests for package and release actions
+
+on:
+ workflow_call:
+
+permissions: {}
+
+jobs:
+ continuous-integration:
+ name: Unit tests for ${{ matrix.action }}
+ strategy:
+ fail-fast: false
+ matrix:
+ action:
+ [package/restore, release/prepare, release/verify, release/finalize]
+ uses: $/.github/workflows/continuous-integration.yml
+ permissions:
+ contents: read
+ id-token: write
+ packages: read
+ pull-requests: write
+ security-events: write
+ with:
+ working-directory: actions/${{ matrix.action }}
+ build: null
+ lint: null
+ test: '{ "coverage": null }'
+
+ integration-tests:
+ name: Test release action inputs, outputs and failures
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ contents: read
+ steps:
+ - name: Arrange - Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: $/actions/setup-node
+ with:
+ working-directory: actions/release/prepare
+ install-dependencies: "false"
+
+ - name: Act - Reject preparation of a private package
+ id: private-project
+ continue-on-error: true
+ uses: $/actions/release/prepare
+ with:
+ working-directory: actions/release/prepare
+ - name: Assert - Preparation failed
+ env:
+ OUTCOME: ${{ steps.private-project.outcome }}
+ run: test "$OUTCOME" = failure
+
+ - name: Arrange - Create an installable release tarball
+ id: fixture
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ with:
+ script: |
+ const fs = require('node:fs');
+ const path = require('node:path');
+ const { createHash } = require('node:crypto');
+ const directory = fs.mkdtempSync(path.join(process.env.RUNNER_TEMP, 'release-fixture-'));
+ const name = `hoverkraft-release-test-${context.runId}-${process.env.GITHUB_RUN_ATTEMPT}`;
+ fs.writeFileSync(path.join(directory, 'package.json'), JSON.stringify({
+ name, version: '1.2.3', bin: { 'release-fixture': 'cli.js' },
+ scripts: { prepare: 'exit 91', prepack: 'exit 91', postpack: 'exit 91' },
+ }));
+ fs.writeFileSync(path.join(directory, 'cli.js'), '#!/usr/bin/env node\nconsole.log("1.2.3");\n', { mode: 0o755 });
+ const { stdout } = await exec.getExecOutput('npm', ['pack', '--json', '--ignore-scripts'], { cwd: directory });
+ const tarball = path.join(directory, JSON.parse(stdout)[0].filename);
+ core.setOutput('name', name);
+ core.setOutput('tarball', tarball);
+ core.setOutput('digest', createHash('sha256').update(fs.readFileSync(tarball)).digest('hex'));
+ core.setOutput('packages', [{ name, slug: name, version: '1.2.3', tag: 'v1.2.3', directory: '.' }]);
+
+ - name: Act - Reject an invalid package artifact ID
+ id: invalid-artifact
+ continue-on-error: true
+ uses: $/actions/release/verify
+ with:
+ packages: ${{ steps.fixture.outputs.packages }}
+ package-tarball-artifact-id: "0"
+ - name: Act - Reject a mismatched tarball version
+ id: mismatched-version
+ continue-on-error: true
+ uses: $/actions/package/restore
+ with:
+ name: ${{ steps.fixture.outputs.name }}
+ version: 9.9.9
+ tarball-path: ${{ steps.fixture.outputs.tarball }}
+ - name: Act - Check an unpublished npm version
+ id: registry
+ uses: $/actions/package/restore
+ with:
+ name: ${{ steps.fixture.outputs.name }}
+ version: 1.2.3
+ tarball-path: ${{ steps.fixture.outputs.tarball }}
+ - name: Assert - Rejections and registry output
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ ARTIFACT_OUTCOME: ${{ steps.invalid-artifact.outcome }}
+ VERSION_OUTCOME: ${{ steps.mismatched-version.outcome }}
+ PUBLISHED: ${{ steps.registry.outputs.published }}
+ TARBALL: ${{ steps.fixture.outputs.tarball }}
+ EXPECTED_DIGEST: ${{ steps.fixture.outputs.digest }}
+ with:
+ script: |
+ const assert = require('node:assert/strict');
+ const fs = require('node:fs');
+ const { createHash } = require('node:crypto');
+ assert.equal(process.env.ARTIFACT_OUTCOME, 'failure');
+ assert.equal(process.env.VERSION_OUTCOME, 'failure');
+ assert.equal(process.env.PUBLISHED, 'false');
+ assert.equal(createHash('sha256').update(fs.readFileSync(process.env.TARBALL)).digest('hex'), process.env.EXPECTED_DIGEST);
+
+ - name: Arrange - Upload the unpublished package
+ id: unpublished-package
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: unpublished-package-${{ github.run_id }}-${{ github.run_attempt }}
+ path: ${{ steps.fixture.outputs.tarball }}
+ archive: false
+ if-no-files-found: error
+ - name: Act - Publish dry run with package recovery enabled
+ id: publish
+ uses: $/actions/package/publish
+ with:
+ package-tarball-artifact-id: ${{ steps.unpublished-package.outputs.artifact-id }}
+ recover-existing: "true"
+ dry-run: "true"
+ provenance: "false"
+ - name: Assert - Publish preserved the unpublished tarball
+ env:
+ ORIGINAL_TARBALL: ${{ steps.fixture.outputs.tarball }}
+ PUBLISH_TARBALL: ${{ steps.publish.outputs.package-tarball-path }}
+ run: cmp "$ORIGINAL_TARBALL" "$PUBLISH_TARBALL"
+
+ - name: Act - Install and run the release package
+ id: smoke
+ uses: $/actions/package/test
+ env:
+ EXPECTED_PACKAGE_NAME: ${{ steps.fixture.outputs.name }}
+ with:
+ working-directory: actions/package/test
+ tarball-path: ${{ steps.fixture.outputs.tarball }}
+ commands: |
+ test "$PWD" = "$GITHUB_WORKSPACE/actions/package/test"
+ test "$PACKAGE_NAME" = "$EXPECTED_PACKAGE_NAME"
+ test "$PACKAGE_VERSION" = 1.2.3
+ consumer=$(mktemp -d)
+ npm install --prefix "$consumer" --ignore-scripts --offline --no-audit --no-fund "$PACKAGE_TARBALL_PATH"
+ test "$("$consumer/node_modules/.bin/release-fixture")" = "$PACKAGE_VERSION"
+ touch "$RUNNER_TEMP/release-smoke-ran"
+ - name: Act - Reject a failing package command
+ id: failing-command
+ continue-on-error: true
+ uses: $/actions/package/test
+ with:
+ tarball-path: ${{ steps.fixture.outputs.tarball }}
+ commands: exit 7
+ - name: Act - Reject modified tarball bytes
+ id: modified-tarball
+ continue-on-error: true
+ uses: $/actions/package/test
+ with:
+ tarball-path: ${{ steps.fixture.outputs.tarball }}
+ commands: printf changed >> "$PACKAGE_TARBALL_PATH"
+ - name: Assert - Smoke checks ran and rejected both failures
+ env:
+ SMOKE_OUTCOME: ${{ steps.smoke.outcome }}
+ COMMAND_OUTCOME: ${{ steps.failing-command.outcome }}
+ TARBALL_OUTCOME: ${{ steps.modified-tarball.outcome }}
+ run: |
+ test "$SMOKE_OUTCOME" = success
+ test -f "$RUNNER_TEMP/release-smoke-ran"
+ test "$COMMAND_OUTCOME" = failure
+ test "$TARBALL_OUTCOME" = failure
diff --git a/.github/workflows/__test-workflow-continuous-integration.yml b/.github/workflows/__test-workflow-continuous-integration.yml
index 04e72794..d73ac609 100644
--- a/.github/workflows/__test-workflow-continuous-integration.yml
+++ b/.github/workflows/__test-workflow-continuous-integration.yml
@@ -17,6 +17,14 @@ jobs:
security-events: write
with:
working-directory: tests/npm
+ package: true
+ package-tests: |
+ tar -xOf "$PACKAGE_TARBALL_PATH" package/dist/test.txt > "$RUNNER_TEMP/package-build.txt"
+ test "$(cat "$RUNNER_TEMP/package-build.txt")" = 'build test'
+ test "$PACKAGE_NAME" = test-for-ci-with-npm
+ consumer=$(mktemp -d)
+ npm install --prefix "$consumer" --ignore-scripts --offline --no-audit --no-fund "$PACKAGE_TARBALL_PATH"
+ test -f "$consumer/node_modules/$PACKAGE_NAME/dist/test.txt"
code-ql: |
{
"languages": "javascript-typescript",
@@ -46,6 +54,22 @@ jobs:
- name: Check the build artifacts
run: test -f tests/npm/dist/test.txt
+ - name: Act - Consume the validated CI package without repacking
+ id: validated-package
+ uses: $/actions/package/publish
+ with:
+ package-tarball-artifact-id: ${{ needs.act-without-container.outputs.package-tarball-artifact-id }}
+ name: test-for-ci-with-npm
+ version: 0.1.0
+ dry-run: "true"
+ provenance: "false"
+ - name: Assert - The CI package contains the compiled output
+ env:
+ TARBALL: ${{ steps.validated-package.outputs.package-tarball-path }}
+ run: |
+ tar -xOf "$TARBALL" package/dist/test.txt > "$RUNNER_TEMP/ci-packaged-build.txt"
+ cmp tests/npm/dist/test.txt "$RUNNER_TEMP/ci-packaged-build.txt"
+
- name: Arrange packaging hooks that must not run
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
@@ -82,6 +106,22 @@ jobs:
tar -xOf "$TARBALL" package/dist/test.txt > "$RUNNER_TEMP/packaged-build.txt"
cmp "$RUNNER_TEMP/prepared-fixture/dist/test.txt" "$RUNNER_TEMP/packaged-build.txt"
test ! -d tests/npm/node_modules
+ - name: Run package smoke checks with the project runtime
+ uses: $/actions/package/test
+ with:
+ working-directory: tests/npm
+ tarball-path: ${{ steps.package.outputs.package-tarball-path }}
+ commands: |
+ node <<'NODE'
+ const assert = require('node:assert/strict');
+ const fs = require('node:fs');
+ const expected = fs.readFileSync('.node-version', 'utf8').trim().split('.');
+ assert.deepEqual(process.versions.node.split('.').slice(0, expected.length), expected);
+ NODE
+ test "$PACKAGE_NAME" = prepared-fixture
+ test "$PACKAGE_VERSION" = 1.2.3
+ tar -xOf "$PACKAGE_TARBALL_PATH" package/dist/test.txt > "$RUNNER_TEMP/smoke-output.txt"
+ cmp "$RUNNER_TEMP/prepared-fixture/dist/test.txt" "$RUNNER_TEMP/smoke-output.txt"
- name: Validate publishing the prepared tarball
uses: $/actions/package/publish
with:
@@ -111,6 +151,16 @@ jobs:
{"coverage": "github"}
# jscpd:ignore-end
+ assert-package-disabled:
+ name: Assert - Package creation is opt-in
+ needs: act-without-container-and-github-reports
+ runs-on: ubuntu-latest
+ permissions: {}
+ steps:
+ - env:
+ PACKAGE_ARTIFACT_ID: ${{ needs.act-without-container-and-github-reports.outputs.package-tarball-artifact-id }}
+ run: test -z "$PACKAGE_ARTIFACT_ID"
+
arrange-with-container:
name: Arrange - Build container image for CI workflow
permissions:
diff --git a/.github/workflows/continuous-integration.md b/.github/workflows/continuous-integration.md
index 53539484..7f29ba3d 100644
--- a/.github/workflows/continuous-integration.md
+++ b/.github/workflows/continuous-integration.md
@@ -123,6 +123,16 @@ jobs:
# Default: `build`
build: build
+ # Create and validate an npm package from the checked-out source and build output.
+ # Disabled by default for projects that do not distribute npm packages.
+ # Requires non-container CI with the default Ubuntu artifact layout.
+ package: false
+
+ # Optional shell commands testing the packed tarball when package is enabled.
+ # Runs from working-directory with PACKAGE_TARBALL_PATH, PACKAGE_NAME and PACKAGE_VERSION.
+ # Install consumer test dependencies explicitly. Commands must not modify the tarball.
+ package-tests: ""
+
# Optional flag to enable check steps.
# Default: `true`
checks: true
@@ -262,6 +272,12 @@ jobs:
| | | | | |
| | Example: | | | |
| |
{
"commands": [
"build",
"generate-artifacts"
],
"env": {
"CUSTOM_ENV_VAR": "value"
},
"artifact": [
"dist/",
"packages/package-a/build/"
]
} | | | |
+| **`package`** | Create and validate an npm package from the checked-out source and build output. | **false** | **boolean** | `false` |
+| | Disabled by default for projects that do not distribute npm packages. | | | |
+| | Requires non-container CI with the default Ubuntu artifact layout. | | | |
+| **`package-tests`** | Optional shell commands testing the packed tarball when package is enabled. | **false** | **string** | - |
+| | Runs from working-directory with PACKAGE_TARBALL_PATH, PACKAGE_NAME and PACKAGE_VERSION. | | | |
+| | Install consumer test dependencies explicitly. Commands must not modify the tarball. | | | |
| **`checks`** | Optional flag to enable check steps. | **false** | **boolean** | `true` |
| **`lint`** | Whether to enable linting. | **false** | **string** | `true` |
| | Set to `null` or empty to disable. | | | |
@@ -404,9 +420,10 @@ When specified, steps will execute inside this container instead of checking out
## Outputs
-| **Output** | **Description** |
-| ----------------------- | --------------------------------------------------------- |
-| **`build-artifact-id`** | ID of the build artifact) uploaded during the build step. |
+| **Output** | **Description** |
+| --------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
+| **`package-tarball-artifact-id`** | Artifact ID of the package validated by the packaging job. Available when package is enabled and validation succeeds. |
+| **`build-artifact-id`** | ID of the build artifact) uploaded during the build step. |
diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml
index a92d1841..6efd046e 100644
--- a/.github/workflows/continuous-integration.yml
+++ b/.github/workflows/continuous-integration.yml
@@ -53,6 +53,20 @@ on:
type: string
required: false
default: "build"
+ package:
+ description: |
+ Create and validate an npm package from the checked-out source and build output.
+ Disabled by default for projects that do not distribute npm packages.
+ Requires non-container CI with the default Ubuntu artifact layout.
+ type: boolean
+ default: false
+ package-tests:
+ description: |
+ Optional shell commands testing the packed tarball when package is enabled.
+ Runs from working-directory with PACKAGE_TARBALL_PATH, PACKAGE_NAME and PACKAGE_VERSION.
+ Install consumer test dependencies explicitly. Commands must not modify the tarball.
+ type: string
+ default: ""
checks:
description: "Optional flag to enable check steps."
type: boolean
@@ -195,6 +209,9 @@ on:
Defaults to `GITHUB_TOKEN` if not provided.
required: false
outputs:
+ package-tarball-artifact-id:
+ description: Artifact ID of the package validated by the packaging job. Available when package is enabled and validation succeeds.
+ value: ${{ jobs.package.outputs.artifact-id }}
build-artifact-id:
description: "ID of the build artifact) uploaded during the build step."
value: ${{ jobs.build.outputs.artifact-id }}
@@ -221,8 +238,16 @@ jobs:
CONTAINER_INPUT: ${{ inputs.container }}
CONTAINER_PASSWORD: ${{ secrets.container-password }}
WORKING_DIRECTORY: ${{ inputs.working-directory }}
+ PACKAGE_ENABLED: ${{ inputs.package }}
+ PACKAGE_TESTS: ${{ inputs.package-tests }}
with:
script: |
+ if (process.env.PACKAGE_TESTS.trim() && process.env.PACKAGE_ENABLED !== 'true') {
+ return core.setFailed('package-tests requires package: true.');
+ }
+ if (process.env.PACKAGE_ENABLED === 'true' && process.env.CONTAINER_INPUT.trim()) {
+ return core.setFailed('Package validation requires non-container CI with the default Ubuntu artifact layout.');
+ }
const containerInput = process.env.CONTAINER_INPUT.trim();
if (!containerInput) {
return;
@@ -616,3 +641,41 @@ jobs:
report-file: ${{ steps.prepare-test-options.outputs.report-file }}
path-mapping: ${{ needs.prepare.outputs.path-mapping || '' }}
github-token: ${{ secrets.github-token || github.token }}
+
+ package:
+ name: Validate package
+ if: inputs.checks == true && inputs.package
+ needs: build
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ outputs:
+ artifact-id: ${{ steps.package.outputs.package-tarball-artifact-id }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: $/actions/setup-node
+ with:
+ working-directory: ${{ inputs.working-directory }}
+ install-dependencies: "false"
+ - name: Package prepared files
+ id: package
+ uses: $/actions/package/create
+ with:
+ working-directory: ${{ inputs.working-directory }}
+ build-artifact-id: ${{ needs.build.outputs.artifact-id }}
+ build-artifact-path: /
+ - name: Test the package as a consumer
+ uses: $/actions/package/test
+ with:
+ working-directory: ${{ inputs.working-directory }}
+ tarball-path: ${{ steps.package.outputs.package-tarball-path }}
+ commands: ${{ inputs.package-tests }}
+ - name: Validate npm publication without publishing
+ uses: $/actions/package/publish
+ with:
+ package-tarball-artifact-id: ${{ steps.package.outputs.package-tarball-artifact-id }}
+ dry-run: "true"
+ provenance: "false"
+ github-token: ""
diff --git a/.github/workflows/prepare-package-release.md b/.github/workflows/prepare-package-release.md
new file mode 100644
index 00000000..4ea8679d
--- /dev/null
+++ b/.github/workflows/prepare-package-release.md
@@ -0,0 +1,94 @@
+
+
+# GitHub Reusable Workflow: Prepare Node.js Package 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
+
+Prepare Node.js package versions and lockfiles before building the release.
+Dispatches the calling workflow at the prepared tag; that run can build and publish.
+See [the release guide](release.md).
+
+### Permissions
+
+- **`actions`**: `write`
+- **`contents`**: `write`
+- **`pull-requests`**: `read`
+
+
+
+
+## Inputs
+
+### Workflow Call Inputs
+
+| **Input** | **Description** | **Required** | **Type** | **Default** |
+| -------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------ | ---------- | ----------- |
+| **`working-directory`** | Public npm project directory containing its package.json and lockfile. | **false** | **string** | `.` |
+| **`github-app-client-id`** | Optional GitHub App client ID, paired with the github-app-key secret to generate a repository-scoped preparation token. | **false** | **string** | - |
+
+
+
+
+## Secrets
+
+| **Secret** | **Description** | **Required** |
+| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------ |
+| **`github-app-key`** | Optional GitHub App private key, paired with the github-app-client-id input. The identity must be permitted by branch and tag rules. | **false** |
+
+
+
+
+## Outputs
+
+| **Output** | **Description** |
+| -------------- | -------------------------------------------------------------------------------------------------------- |
+| **`status`** | no-changes, dispatched, released or ready. Only ready should start building and publication in this run. |
+| **`packages`** | JSON package list for the release workflow's packages input. Available when status is ready. |
+
+
+
+
+## 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 © 2026 hoverkraft-tech
+
+For more details, see the [license](http://choosealicense.com/licenses/mit/).
+
+
+
+
+---
+
+This documentation was automatically generated by [CI Dokumentor](https://github.com/hoverkraft-tech/ci-dokumentor).
+
+
diff --git a/.github/workflows/prepare-package-release.yml b/.github/workflows/prepare-package-release.yml
new file mode 100644
index 00000000..30c7d91b
--- /dev/null
+++ b/.github/workflows/prepare-package-release.yml
@@ -0,0 +1,64 @@
+# Prepare Node.js package versions and lockfiles before building the release.
+# Dispatches the calling workflow at the prepared tag; that run can build and publish.
+# See [the release guide](release.md).
+name: Prepare Node.js Package Release
+
+on:
+ workflow_call:
+ inputs:
+ working-directory:
+ description: Public npm project directory containing its package.json and lockfile.
+ type: string
+ default: .
+ github-app-client-id:
+ description: Optional GitHub App client ID, paired with the github-app-key secret to generate a repository-scoped preparation token.
+ type: string
+ required: false
+ secrets:
+ github-app-key:
+ description: Optional GitHub App private key, paired with the github-app-client-id input. The identity must be permitted by branch and tag rules.
+ required: false
+ outputs:
+ status:
+ description: no-changes, dispatched, released or ready. Only ready should start building and publication in this run.
+ value: ${{ jobs.prepare.outputs.status }}
+ packages:
+ description: JSON package list for the release workflow's packages input. Available when status is ready.
+ value: ${{ jobs.prepare.outputs.packages }}
+
+permissions: {}
+
+concurrency:
+ group: node-release-prepare-${{ github.repository }}-${{ inputs.working-directory }}
+ cancel-in-progress: false
+
+jobs:
+ prepare:
+ name: Prepare package release
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ env:
+ HAS_APP_PRIVATE_KEY: ${{ secrets.github-app-key != '' }}
+ permissions:
+ actions: write
+ contents: write
+ pull-requests: read
+ outputs:
+ status: ${{ steps.prepare.outputs.status }}
+ packages: ${{ steps.prepare.outputs.packages }}
+ steps:
+ - name: Generate release App token
+ id: release-token
+ if: inputs.github-app-client-id != '' || env.HAS_APP_PRIVATE_KEY == 'true'
+ uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
+ with:
+ client-id: ${{ inputs.github-app-client-id }}
+ private-key: ${{ secrets.github-app-key }} # zizmor: ignore[secrets-outside-env] reusable workflow credentials are explicitly supplied by the caller
+ permission-actions: write
+ permission-contents: write
+ permission-pull-requests: read
+ - id: prepare
+ uses: $/actions/release/prepare
+ with:
+ working-directory: ${{ inputs.working-directory }}
+ github-token: ${{ steps.release-token.outputs.token || github.token }}
diff --git a/.github/workflows/release.md b/.github/workflows/release.md
index 88bd6db6..07ce6d93 100644
--- a/.github/workflows/release.md
+++ b/.github/workflows/release.md
@@ -1,49 +1,522 @@
-# Release a prepared Node.js package
+
-Use the package actions to publish prepared files and
-[`ci-github-publish`](https://github.com/hoverkraft-tech/ci-github-publish/blob/main/.github/workflows/release.md)
-for version planning and GitHub release notes.
+# GitHub Reusable Workflow: Node.js Release
-Before packaging, commit the intended version and lockfile changes, check out that
-source, configure Node.js and the project's package manager, and build the package.
-The committed source, compiled output and tarball must contain the same version.
-The [CI workflow](continuous-integration.md) can produce the build artifact; an
-existing build job can also supply it.
+
+

+
-## Package and publish
+---
-The following steps belong in a job after checkout and runtime setup. Replace
-`` with the same supported `ci-github-nodejs` commit for both actions and
-`needs.build.outputs.artifact-id` with the output of your artifact-producing job.
-Set `build-artifact-path` to the artifact's extraction directory. For files already
-prepared in the job, omit both artifact inputs.
+
+
+
+[](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
+
+Publish an already validated package tarball without rebuilding, repacking or running tests.
+Call after successful package validation at the prepared tag, using an artifact from the same run and commit.
+
+### Permissions
+
+- **`actions`**: `read`
+- **`contents`**: `write`
+- **`id-token`**: `write`
+- **`pull-requests`**: `read`
+
+
+
+## Release guide
+
+Connect **prepare -> validate -> release**. The caller owns manual and scheduled
+triggers and chooses the job that produces and validates the package tarball. The
+examples use the shared CI workflow for building, testing and packaging; any job
+meeting the artifact requirements can replace it. HoverKraft selects the version
+from Conventional Commits. Release publishes the validated tarball and finalizes
+the GitHub release, with an optional AI summary. It never builds, repacks or runs
+consumer test commands.
+
+[`prepare-package-release.yml`](prepare-package-release.md) updates package versions and lockfiles before building.
+The separate [`ci-github-publish` preparation workflow](https://github.com/hoverkraft-tech/ci-github-publish/blob/main/.github/workflows/prepare-release.yml)
+manages release labels on pull requests.
+
+## Supported project
+
+Use one public npm package with a stable version and an npm lockfile. Set
+`working-directory` to its directory, using the same value for preparation, building and
+release. The default is the repository root. The package must have its own lockfile;
+workspace roots, selected workspace packages and Lerna projects require a different
+pipeline. Use [custom composition](#custom-release-pipelines) for other package layouts,
+registries, prereleases, applications or containers.
+
+## Set up
+
+1. Align the committed version, existing tags, GitHub releases and npm versions.
+ HoverKraft plans from GitHub release history; the next version must not be older
+ than the committed version. Follow the [readiness guide](#establish-the-release-baseline).
+2. Configure [npm trusted publishing](../../actions/package/publish/README.md#npm-trusted-publishing)
+ for the package, repository and caller workflow filename. Leave npm's environment
+ field empty unless opting into a [release environment](#optional-release-environment).
+ A new package needs its first version published before configuring its trusted
+ publisher. Publication uses GitHub-hosted runners and `id-token: write`.
+3. Permit the preparation identity to push release commits and tags under your
+ branch and tag rules. For a GitHub App, pass its client ID through the
+ `github-app-client-id` input and its private key through the `github-app-key`
+ workflow secret. The preparation workflow generates and revokes
+ a token scoped to the calling repository, with Actions/Contents write and Pull
+ requests read permissions. The App still needs authorization under your rules.
+4. Pass `changelog-summary` when enabling [AI summaries](#changelog-summaries).
+ A Pages environment needs to permit tags if documentation deployment follows release.
+5. Copy the caller below into your repository at `.github/workflows/release.yml`.
+ Replace `` with the same supported `ci-github-nodejs` commit throughout.
+ Adapt the schedule, build command and artifact paths to your project.
+
+This example uses the shared CI workflow as the artifact producer:
+
+```yaml
+name: Release
+
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: "25 8 * * 1"
+
+permissions: {}
+
+concurrency:
+ group: release-${{ github.repository }}
+ cancel-in-progress: false
+
+jobs:
+ prepare:
+ uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/prepare-package-release.yml@
+ permissions:
+ actions: write
+ contents: write
+ pull-requests: read
+ with:
+ github-app-client-id: ${{ vars.CI_BOT_APP_CLIENT_ID }}
+ secrets:
+ github-app-key: ${{ secrets.CI_BOT_APP_PRIVATE_KEY }}
+
+ ci:
+ needs: prepare
+ if: needs.prepare.outputs.status == 'ready'
+ uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/continuous-integration.yml@
+ permissions:
+ contents: read
+ packages: read
+ pull-requests: write
+ security-events: write
+ id-token: write
+ with:
+ build: '{"commands":["build"],"artifact":["dist"]}'
+ package: true
+
+ release:
+ needs: [prepare, ci]
+ uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/release.yml@
+ permissions:
+ actions: read
+ contents: write
+ pull-requests: read
+ id-token: write
+ with:
+ packages: ${{ needs.prepare.outputs.packages }}
+ package-tarball-artifact-id: ${{ needs.ci.outputs.package-tarball-artifact-id }}
+```
+
+The caller concurrency group serializes the whole pipeline. Keep it distinct from
+those used by the reusable workflows. Any package validation job can replace the example's
+shared CI job if it meets the [artifact requirements](#build-once-and-test-the-package).
+Shared CI wrappers expose `package-tarball-artifact-id` for release and can retain
+`build-artifact-id` for documentation or other build outputs. CI has no release
+version-planning inputs and does not publish packages.
+The `packages` output contains exactly one package for the release workflow.
+
+The caller chooses where to store App credentials and maps them explicitly as shown.
+The example uses repository or organization variables and secrets; their names are
+not part of the shared workflow's contract. Preparation does not use a GitHub
+environment, so environment-scoped credentials are unavailable. A partially
+configured App fails before preparation. Without App credentials, omit both mappings to use
+`GITHUB_TOKEN` where repository rules allow it.
+
+## Establish the release baseline
+
+Compare the public package's manifest and lockfile version with its published npm
+version and the corresponding Git tag and GitHub release. Use the commit that
+actually produced the published package as evidence. Do not create a historical tag
+on an unrelated current commit, lower an already published version, or overwrite a tag.
+
+The next HoverKraft plan must not be older than the committed version. An equal
+plan can release an already-versioned package when its tag does not exist.
+Resolve manifest versions ahead of the plan before enabling the schedule: establish
+missing releases from verified historical source, or reconcile an unpublished
+manifest version in a reviewed change.
+Fetch full history; preparation does this automatically. A GitHub release does not
+prove npm publication succeeded. Resume its original tag only when that source
+contains the caller workflow and supported configuration. Otherwise use a reviewed
+recovery pipeline against the immutable source; never guess the published bytes.
+
+For a package that has never been published, establish its first version and matching
+release history before enabling trusted publication. The shared workflow deliberately
+does not create an npm package or invent a version baseline.
+
+## Authorize preparation
+
+The preparation identity must be allowed to push version commits and release tags.
+`contents: write` alone does not bypass repository rules. Preparation requires a
+GitHub-verified commit signature; do not remove protections merely to make automation pass.
+
+If rules require a PR, authorize a release identity under those rules. A GitHub App
+making a direct version push needs an **Always allow** bypass of the PR rule; a
+PR-only bypass is insufficient. Isolate that rule if signatures and other protections
+must remain enforced for the App.
+
+## Optional release environment
+
+Release runs without a GitHub environment by default. Omit `environment` or pass
+an empty string. No release environment setup is required for npm trusted publishing.
+
+To use environment approvals or deployment restrictions, pass its name through
+the release job's `with.environment`, for example `environment: release`. Configure
+that environment to permit the release tags. If npm's trusted publisher specifies
+an environment, its name must match this input; leave that field empty when using
+the default. Callers relying on an environment's protections or secrets must
+explicitly pass its name.
+
+## Versions and release notes
+
+HoverKraft selects the next version. npm applies that exact version to the manifest
+and lockfile with lifecycle scripts disabled. Release notes and optional AI summaries
+are published on GitHub. Preparation does not create or modify `CHANGELOG.md` files.
+Repository release configuration is preserved.
+Preparation creates version commits through GitHub's API, which signs them for
+the authenticated App or bot. It requires GitHub to verify the signature before
+atomically pushing the commit and tag, then dispatches the caller at the tag.
+An already-committed planned version is accepted when its tag does not exist;
+if npm leaves the manifest and lockfile unchanged, preparation tags the existing
+commit without creating an empty commit. A no-change plan creates neither. Concurrent branch updates or existing
+tag conflicts stop the push; tags are never overwritten.
+
+Stable bare tags, `v`-prefixed tags and directory-slug prefixes are supported.
+Version planning, release notes, optional AI summaries and GitHub publication
+use `ci-github-publish`; npm only applies the planned version.
+
+## Changelog summaries
+
+The optional `changelog-summary` input and secret accept the complete JSON
+configuration supported by
+[`ci-github-publish/actions/release/create`](https://github.com/hoverkraft-tech/ci-github-publish/tree/main/actions/release/create).
+The workflow forwards it unchanged, including `llmAuth`, `llmProvider`, `llmModel`,
+`llmConfig`, `workingDirectory` and `summaryTemplate`. Provider support, defaults
+and validation belong to that action.
+
+Use the secret for configuration containing credentials. For example, store the
+complete JSON as a repository or organization secret named `CHANGELOG_SUMMARY`,
+then pass it to the release job:
```yaml
-- name: Create package tarball
- id: package
- uses: hoverkraft-tech/ci-github-nodejs/actions/package/create@
- with:
- build-artifact-id: ${{ needs.build.outputs.artifact-id }}
- build-artifact-path: ${{ github.workspace }}
-
-- name: Publish package tarball
- uses: hoverkraft-tech/ci-github-nodejs/actions/package/publish@
- with:
- package-tarball-artifact-id: ${{ steps.package.outputs.package-tarball-artifact-id }}
+secrets:
+ changelog-summary: ${{ secrets.CHANGELOG_SUMMARY }}
+```
+
+Alternatively, construct the JSON in that secret mapping using your provider's
+credential secret; use `toJSON` when embedding string values. The same-named
+workflow input is available for non-sensitive configuration. If both are supplied,
+the non-empty secret replaces the entire input; their properties are not merged.
+Omit both to keep the full release notes without an AI summary. Invalid configuration
+or summary generation failures stop publication. Already published notes are preserved.
+
+## Build once and test the package
+
+Build and pack from the prepared tag because earlier artifacts can embed an old
+version. Enable `package: true` on the shared CI workflow to create a tarball,
+run optional `package-tests`, and validate it with `npm publish --dry-run`.
+The packaging job has read-only repository permissions, no release environment
+and no publication credentials. The option is disabled by default for applications
+and other projects that do not distribute npm packages. Supplying `package-tests`
+without enabling packaging is an error.
+
+Include every generated package file in the build artifact, preserving absolute
+paths for the default Ubuntu runner without a build container. Shared CI already
+produces this layout. Its packaging job restores those files without rebuilding,
+installing project dependencies or running lifecycle hooks. Include files normally
+generated by `prepare` or `prepack` in the build. CI packaging does not support
+container mode; custom validation jobs can use the standalone package actions.
+
+Dependency installation skips project and dependency lifecycle scripts by default.
+If your build needs installation hooks, set `ignore-scripts: false` and configure
+any script approvals required by your package manager. Explicit build, lint and
+test commands still run. Packaging always disables lifecycle scripts.
+
+Configure consumer checks on **CI**, not on the release workflow:
+
+```yaml
+with:
+ package: true
+ package-tests: |
+ test_project=$(mktemp -d)
+ npm install --prefix "$test_project" --ignore-scripts "$PACKAGE_TARBALL_PATH"
+ "$test_project/node_modules/.bin/my-cli" --help
+```
+
+Commands run from `working-directory`; `PACKAGE_TARBALL_PATH`, `PACKAGE_NAME` and
+`PACKAGE_VERSION` describe the actual tarball. Install consumer test dependencies
+explicitly. Tests must not change the tarball bytes. A failed check or dry run
+fails CI, preventing dependent release jobs from starting. A dry run does not
+validate npm authentication. Use this same CI configuration on pull requests.
+
+Pass the validated artifact ID through `package-tarball-artifact-id` and require
+successful validation through `needs`. Release accepts one raw `.tgz` uploaded
+with `archive: false`, verifies its run, source SHA and expiration, and checks its
+public package name and version against preparation before publication. It does
+not inspect the producing job's identity or infer that tests passed from artifact
+existence. Cross-run artifact reuse is unsupported. Custom producers can use any
+build layout because release downloads only the finished tarball.
+
+## Run and resume releases
+
+Run the caller from the default branch or wait for its schedule. Preparation reports:
+
+| Status | Meaning |
+| ------------ | ---------------------------------------------------- |
+| `no-changes` | No release needed; building and release are skipped. |
+| `dispatched` | Follow the new caller run at the prepared tag. |
+| `released` | This version already has a published GitHub release. |
+| `ready` | The tag run can build, then release. |
+
+The release job succeeds only after npm publication and GitHub release assets
+succeed. Use `needs.release.result == 'success'` for dependent jobs. Preparation
+success alone is not publication.
+
+Rerun a failed release job to reuse the validated tarball without rebuilding,
+repacking or repeating consumer tests. Artifact and package identity checks still run.
+Every attempt checks npm again. Already published versions are skipped, and their
+original tarballs are restored with SHA-512 verification for GitHub assets. Existing
+assets must have matching SHA-256 digests. Conflicts, missing digests and expired
+package artifacts fail rather than overwriting data or selecting another run.
+Published release notes are preserved.
+
+To build again and resume the same version, dispatch the caller at its original tag:
+
+```sh
+gh workflow run release.yml --ref v1.2.3
+```
+
+A tag run never plans another version. If source needs changing, make a new commit
+and release; never move a released tag. Review AI summaries for accuracy.
+
+Documentation deployment remains a separate caller job. Require
+`needs.release.result == 'success'` and consume the producer's `build-artifact-id`.
+The [`ci-github-publish` Pages action](https://github.com/hoverkraft-tech/ci-github-publish/tree/main/actions/deploy/github-pages)
+deploys and checks the site in one job. Its `github-pages` environment must permit
+release tags when deployment follows a tagged release.
+
+## Custom release pipelines
+
+For other layouts, registries or packaging rules, compose the existing actions
+while keeping preparation, building and release separate.
+
+### Preparation
+
+Use HoverKraft [`release/plan`](https://github.com/hoverkraft-tech/ci-github-publish/tree/main/actions/release/plan)
+for version selection. Commit version and lockfile updates before building
+the source, especially when compiled files embed the version. Keep the planned
+tag fixed throughout the pipeline. The [Node.js preparation action](../../actions/release/prepare/README.md)
+handles this for one public npm package and dispatches the caller at the tag.
+
+### Packaging and publication
+
+For a custom package validation job, check out the prepared source tag and configure Node.js
+and the package manager before calling the [Package action](../../actions/package/create/README.md).
+Prepare the version, dependencies and build files first. The action uses the existing
+checkout and never installs dependencies, changes versions or runs lifecycle scripts.
+It can pack files already present in the job or restore an optional build artifact.
+
+Package creation selects the native npm, pnpm or Yarn Classic command through
+`get-package-manager`, using `package.json.packageManager` before lockfiles.
+The command disables lifecycle scripts and writes to an isolated temporary
+folder, so packaging does not depend on the manager's console output format.
+
+Include generated files normally produced by `prepare` or `prepack` in that build.
+
+The shared CI packaging job restores build files with `build-artifact-path: /`
+on the default Ubuntu runner layout. Standalone Package calls can choose a
+different restoration path or omit the build artifact inputs. Run consumer tests
+and a publication dry run in this validation job, then pass the tarball artifact
+ID to a separate publication job that depends on validation succeeding.
+
+The [Publish action](../../actions/package/publish/README.md) consumes the resulting tarball
+artifact ID. A dry run validates `npm publish` inputs but does not prove registry
+authentication or trusted publishing. See its
+[authentication guide](../../actions/package/publish/README.md#registry-tokens-and-github-packages)
+for registries beyond npmjs.org.
+
+Use HoverKraft [`release/create`](https://github.com/hoverkraft-tech/ci-github-publish/tree/main/actions/release/create)
+to draft notes and optional AI summaries before npm publication. Pass the prepared
+tag and source SHA. Set `publish: "false"` until all packages and assets succeed.
+Use [`release/update`](https://github.com/hoverkraft-tech/ci-github-publish/tree/main/actions/release/update)
+to attach assets and publish the draft. Those actions expect ZIP artifacts for release
+assets; wrap the raw `.tgz` with `upload-artifact` using its default archive mode.
+
+### Other release products
+
+Node.js CI can build an application, container input or GitHub Action without
+publishing an npm library. Keep these consumers on the appropriate
+[`ci-github-publish` workflows](https://github.com/hoverkraft-tech/ci-github-publish/tree/main/.github/workflows)
+and container tooling. Attach their build artifacts or deployment jobs to the release
+caller. Do not introduce an npm package just to use shared release automation.
+
+For custom package checks, [Test package](../../actions/package/test/README.md)
+exposes the tarball path and rejects tests that change its bytes. Complete package
+checks before publication.
+
+### Recovery
+
+The Publish action supports `recover-existing: "true"` for the public npm registry.
+It checks the exact version on every attempt, restores the original registry tarball
+when present, skips npm publication, and returns its local path for assets so the
+remaining release steps can resume. Registry and integrity errors still fail the job.
+
+Pass that `package-tarball-path` directly to `release/finalize`, with the desired
+`.tgz` filename as `asset-name`. Finalization checks the existing asset's digest
+and stages the file only if it is missing. Callers do not need to copy the tarball
+into a predefined directory.
+
+npm publication across several packages cannot be atomic. Check exact package
+versions before retrying; published versions cannot be overwritten. Restore original
+published bytes for release assets, verify integrity and existing asset digests, and
+upload only missing assets. Do not delete a draft to undo an npm publication.
+
+The [recovery procedure above](#run-and-resume-releases) describes this behavior
+for the shared workflow. Custom pipelines own equivalent recovery rules. Keep the
+original tag and source identity when finishing a partial release.
+
+## Contributor validation
+
+Unit tests live beside the implementation of each action in
+[`actions/package`](../../actions/package) and [`actions/release`](../../actions/release).
+Each action with unit tests owns its private `package.json`, lockfile and test
+dependencies. Run one action's tests independently, for example:
+
+```sh
+npm ci --ignore-scripts --prefix actions/release/prepare
+npm test --prefix actions/release/prepare
```
-[Package creation](../../actions/package/create/README.md) detects npm, pnpm or
-Yarn Classic from the project configuration and uses its native pack command.
-It never installs dependencies, changes versions or runs lifecycle scripts.
-Include files normally generated by `prepare` or `prepack` in the prepared build.
-
-Configure [npm trusted publishing](../../actions/package/publish/README.md#npm-trusted-publishing)
-for the caller repository and workflow, using a GitHub-hosted runner and
-`id-token: write`. The caller also needs permission to read the artifact.
-Use `dry-run: "true"` to validate publication before enabling uploads.
-For registry tokens or GitHub Packages, follow the
-[authentication guide](../../actions/package/publish/README.md#registry-tokens-and-github-packages).
-
-When creating the GitHub release through `ci-github-publish`, attach the same
-tarball from `steps.package.outputs.package-tarball-path`. Keep version planning,
-source preparation and publication ordering in the caller workflow.
+`release/verify/index.test.js` checks release tags and package artifact
+run/commit/expiration. `package/restore/PackageRecovery.test.js` checks tarball
+manifests, registry integrity and recovery. `release/finalize/index.test.js` checks
+asset recovery without overwriting published assets. Each imports its action's
+implementation directly, mocking GitHub and registry responses for focused failure
+and recovery cases. Package validation and release lookup live in each action
+that needs them and are covered by its own tests.
+
+Scripts use the `core`, `exec`, `io` and `glob` helpers provided by
+`actions/github-script`. Pass the required helpers into action modules; tests use
+the actual toolkit packages declared in that action's `package.json`. Node APIs handle
+file contents, hashing and filesystem checks without a toolkit equivalent.
+
+`release/prepare/index.test.js` uses temporary local Git remotes to check staged-file
+identity, already-versioned source, signature rejection, tag conflicts and atomic
+push rejection after concurrent branch changes. GitHub API responses, including
+signature verification, are mocked; these tests do not prove live App signing or
+branch-rule authorization.
+
+The [release test workflow](__test-release.yml) is called
+by [shared CI](__shared-ci.yml). It runs the unit tests through
+the existing Node.js CI workflow in a separate matrix job per action and invokes the actual release actions in separate
+Arrange, Act and Assert steps. Integration checks cover private-package rejection,
+invalid package artifact IDs, mismatched tarball versions, the unpublished registry output,
+installed CLI behavior, failed smoke commands and modified tarball bytes. Assertions
+read action outputs and step outcomes; they do not extract scripts from YAML.
+An npm publication dry run with `recover-existing` enabled checks that `publish`
+correctly composes `package/restore` and preserves unpublished package bytes.
+The CI workflow tests exercise optional packaging, installed-package checks and
+consuming the validated tarball in a separate job. Publish action tests reject
+unexpected package identities before publication.
+
+The [CI workflow integration test](__test-workflow-continuous-integration.yml)
+checks real artifact restoration and packaging of prepared files with failing lifecycle hooks
+and unavailable dependencies. It verifies the compiled bytes and exercises publication
+only as an npm dry run. Release integration tests perform read-only npm lookups; they
+never publish npm packages, create GitHub releases or call an LLM provider.
+
+Live GitHub signing, protected-branch pushes and dispatch/retry of the complete
+release pipeline require a release-capable test repository. These scenarios are not
+covered by the local helper suite or the read-only action checks. A passing unit suite
+is not evidence that GitHub workflow conditions, permissions or dispatch wiring work.
+
+Each action test package links its `.node-version` directly to the root runtime file.
+[Renovate](../../docs/development/dependency-updates.md) proposes reviewed runtime updates. Run `make lint`
+for repository linting. Generate public action/workflow references through CI
+Dokumentor. Edit workflow/action metadata for generated reference sections. This
+release guide lives outside CI Dokumentor's section markers in this file, which
+serves as its template; regeneration preserves that authored content.
+
+
+
+## Inputs
+
+### Workflow Call Inputs
+
+| **Input** | **Description** | **Required** | **Type** | **Default** |
+| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ---------- | ----------- |
+| **`packages`** | JSON array containing one public npm package from prepare-package-release. | **true** | **string** | - |
+| **`package-tarball-artifact-id`** | Validated package tarball artifact ID from this workflow run and the prepared tag's commit. Upload one raw .tgz with archive: false. The caller must depend on successful validation; any job can produce the artifact. | **true** | **string** | - |
+| **`working-directory`** | Project directory relative to the caller repository root, matching release preparation. | **false** | **string** | `.` |
+| **`changelog-summary`** | Optional JSON configuration forwarded unchanged to ci-github-publish/actions/release/create. | **false** | **string** | - |
+| | Supports all upstream summary options. Use the changelog-summary secret when the configuration contains credentials. | | | |
+| | A non-empty secret overrides this input; omit both to disable summarization. Summary failures stop publication. | | | |
+| | See . | | | |
+| **`environment`** | Optional GitHub environment for release jobs. Leave empty to run without an environment. When set, its protection rules apply; use the same name if restricting npm trusted publishing to an environment. | **false** | **string** | - |
+
+
+
+
+## Secrets
+
+| **Secret** | **Description** | **Required** |
+| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ |
+| **`changelog-summary`** | Optional complete JSON summary configuration, including provider credentials. Overrides the changelog-summary input and is forwarded unchanged to ci-github-publish/actions/release/create. | **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 © 2026 hoverkraft-tech
+
+For more details, see the [license](http://choosealicense.com/licenses/mit/).
+
+
+
+
+---
+
+This documentation was automatically generated by [CI Dokumentor](https://github.com/hoverkraft-tech/ci-dokumentor).
+
+
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000..88ce863d
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,87 @@
+# Publish an already validated package tarball without rebuilding, repacking or running tests.
+# Call after successful package validation at the prepared tag, using an artifact from the same run and commit.
+name: Node.js Release
+
+on:
+ workflow_call:
+ inputs:
+ packages:
+ description: JSON array containing one public npm package from prepare-package-release.
+ type: string
+ required: true
+ package-tarball-artifact-id:
+ description: |
+ Validated package tarball artifact ID from this workflow run and the prepared tag's commit. Upload one raw .tgz with archive: false. The caller must depend on successful validation; any job can produce the artifact.
+ type: string
+ required: true
+ working-directory:
+ description: Project directory relative to the caller repository root, matching release preparation.
+ type: string
+ default: .
+ changelog-summary:
+ description: |
+ Optional JSON configuration forwarded unchanged to ci-github-publish/actions/release/create.
+ Supports all upstream summary options. Use the changelog-summary secret when the configuration contains credentials.
+ A non-empty secret overrides this input; omit both to disable summarization. Summary failures stop publication.
+ See https://github.com/hoverkraft-tech/ci-github-publish/tree/main/actions/release/create.
+ type: string
+ default: ""
+ environment:
+ description: Optional GitHub environment for release jobs. Leave empty to run without an environment. When set, its protection rules apply; use the same name if restricting npm trusted publishing to an environment.
+ type: string
+ default: ""
+ secrets:
+ changelog-summary:
+ description: Optional complete JSON summary configuration, including provider credentials. Overrides the changelog-summary input and is forwarded unchanged to ci-github-publish/actions/release/create.
+ required: false
+
+permissions: {}
+
+concurrency:
+ group: node-release-publish-${{ github.repository }}-${{ inputs.working-directory }}
+ cancel-in-progress: false
+
+jobs:
+ publish:
+ name: Release npm package
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ environment: ${{ inputs.environment }}
+ permissions:
+ actions: read
+ contents: write
+ pull-requests: read
+ id-token: write
+ steps:
+ - id: verify
+ uses: $/actions/release/verify
+ with:
+ packages: ${{ inputs.packages }}
+ package-tarball-artifact-id: ${{ inputs.package-tarball-artifact-id }}
+ working-directory: ${{ inputs.working-directory }}
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: hoverkraft-tech/ci-github-publish/actions/release/create@ea6682de88070c254dac619a6623a242a6d57cd3 # 1.0.3
+ with:
+ tag: ${{ github.ref_name }}
+ target-sha: ${{ github.sha }}
+ working-directory: ${{ inputs.working-directory != '.' && inputs.working-directory || '' }}
+ publish: "false"
+ changelog-summary: ${{ secrets.changelog-summary || inputs.changelog-summary }}
+ name: Create draft and optional AI summary
+ if: steps.verify.outputs.published != 'true'
+ - name: Publish missing npm version
+ id: publish
+ uses: $/actions/package/publish
+ with:
+ package-tarball-artifact-id: ${{ inputs.package-tarball-artifact-id }}
+ name: ${{ fromJSON(inputs.packages)[0].name }}
+ version: ${{ fromJSON(inputs.packages)[0].version }}
+ recover-existing: "true"
+ tag: latest
+ - uses: $/actions/release/finalize
+ with:
+ tag: ${{ github.ref_name }}
+ package-tarball-path: ${{ steps.publish.outputs.package-tarball-path }}
+ asset-name: ${{ fromJSON(inputs.packages)[0].slug }}-${{ fromJSON(inputs.packages)[0].version }}.tgz
diff --git a/.gitignore b/.gitignore
index fcbe5458..2efd1cf5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,4 +3,6 @@
/tests/*/coverage
/tests/yarn/package-lock.json
/dist
-.pnpm-store
\ No newline at end of file
+.pnpm-store
+/actions/**/node_modules
+/actions/**/coverage
diff --git a/Makefile b/Makefile
index 956feb60..0c094a57 100644
--- a/Makefile
+++ b/Makefile
@@ -19,10 +19,10 @@ lint-fix: ## Execute linting and fix
-e FIX_BIOME_FORMAT=true \
)
-deps-install: ## Install dependencies for all package.json files under tests/
+deps-install: ## Install dependencies for action and fixture packages
@sh scripts/test-packages.sh install
-deps-update-interactive: ## Interactively update dependencies under tests/
+deps-update-interactive: ## Interactively update action and fixture dependencies
@sh scripts/test-packages.sh update-interactive
deps-audit-fix: ## Execute dependency audit fix
diff --git a/README.md b/README.md
index 447ace5c..44bfaea5 100644
--- a/README.md
+++ b/README.md
@@ -38,7 +38,15 @@ _Actions for continuous integration steps: build, lint, and test._
### Package Actions
- [Create a package tarball](actions/package/create/README.md)
+- [Test a package tarball](actions/package/test/README.md)
- [Publish a package tarball](actions/package/publish/README.md)
+- [Restore a published package](actions/package/restore/README.md)
+
+### Release Actions
+
+- [Prepare a package release](actions/release/prepare/README.md)
+- [Verify a prepared release](actions/release/verify/README.md)
+- [Finalize a release](actions/release/finalize/README.md)
### Dependencies
@@ -64,7 +72,7 @@ _Actions focused on discovering and preparing the Node.js environment._
### Release
-- [Release](.github/workflows/release.md): publish prepared packages using the package actions and `ci-github-publish`.
+- [Release packages](.github/workflows/release.md): setup, manual and scheduled releases, recovery and workflow reference.
## Contributing
diff --git a/actions/package/publish/README.md b/actions/package/publish/README.md
index 000c3ce1..22bab655 100644
--- a/actions/package/publish/README.md
+++ b/actions/package/publish/README.md
@@ -17,7 +17,7 @@
## Overview
-Publish a CI-produced Node.js package tarball to an npm-compatible registry
+Publish an existing Node.js package tarball to an npm-compatible registry
@@ -27,6 +27,16 @@ Publish a CI-produced Node.js package tarball to an npm-compatible registry
```yaml
- uses: hoverkraft-tech/ci-github-nodejs/actions/package/publish@
with:
+ # Expected package name, paired with version to reject an unintended release tarball before publication.
+ name: ""
+
+ # Expected package version, paired with name to reject an unintended release tarball before publication.
+ version: ""
+
+ # Recover an existing npmjs.org version by restoring its original tarball instead of publishing again. Checks the registry on every attempt.
+ # Default: `false`
+ recover-existing: "false"
+
# Artifact ID of one package tarball uploaded by the package action
# This input is required.
package-tarball-artifact-id: ""
@@ -60,15 +70,18 @@ Publish a CI-produced Node.js package tarball to an npm-compatible registry
## Inputs
-| **Input** | **Description** | **Required** | **Default** |
-| --------------------------------- | ----------------------------------------------------------------------------------------------- | ------------ | ---------------------------- |
-| **`package-tarball-artifact-id`** | Artifact ID of one package tarball uploaded by the package action | **true** | - |
-| **`registry-url`** | Registry URL used by npm publish | **false** | `https://registry.npmjs.org` |
-| **`access`** | Package access: public, restricted, or empty to use npm defaults | **false** | `public` |
-| **`tag`** | npm distribution tag, such as latest, next, or canary; empty uses npm defaults | **false** | - |
-| **`provenance`** | Whether to request provenance for npmjs.org publishes (true or false) | **false** | `true` |
-| **`dry-run`** | Validate publishing without uploading the package (true or false) | **false** | `false` |
-| **`github-token`** | GitHub token for downloading the artifact; registry authentication uses NODE_AUTH_TOKEN or OIDC | **false** | `${{ github.token }}` |
+| **Input** | **Description** | **Required** | **Default** |
+| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------ | ---------------------------- |
+| **`name`** | Expected package name, paired with version to reject an unintended release tarball before publication. | **false** | - |
+| **`version`** | Expected package version, paired with name to reject an unintended release tarball before publication. | **false** | - |
+| **`recover-existing`** | Recover an existing npmjs.org version by restoring its original tarball instead of publishing again. Checks the registry on every attempt. | **false** | `false` |
+| **`package-tarball-artifact-id`** | Artifact ID of one package tarball uploaded by the package action | **true** | - |
+| **`registry-url`** | Registry URL used by npm publish | **false** | `https://registry.npmjs.org` |
+| **`access`** | Package access: public, restricted, or empty to use npm defaults | **false** | `public` |
+| **`tag`** | npm distribution tag, such as latest, next, or canary; empty uses npm defaults | **false** | - |
+| **`provenance`** | Whether to request provenance for npmjs.org publishes (true or false) | **false** | `true` |
+| **`dry-run`** | Validate publishing without uploading the package (true or false) | **false** | `false` |
+| **`github-token`** | GitHub token for downloading the artifact; registry authentication uses NODE_AUTH_TOKEN or OIDC | **false** | `${{ github.token }}` |
@@ -148,6 +161,13 @@ Publish only after the checks for that exact artifact succeed. Re-running a succ
+
+## Outputs
+
+| **Output** | **Description** |
+| -------------------------- | ------------------------------------------------------------------------------------------------------- |
+| **`package-tarball-path`** | Local path to the published tarball, restored from npm when recover-existing finds an existing version. |
+
+
+#  GitHub Action: Restore published Node.js package
+
+
+

+
+
+---
+
+
+
+
+[](https://github.com/marketplace/actions/restore-published-node.js-package)
+[](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
+
+Validate a release tarball, check an exact npm version and restore its original tarball with integrity verification when resuming publication.
+
+
+
+
+## Inputs
+
+| **Input** | **Description** | **Required** | **Default** |
+| ------------------ | ---------------------------------------------------------------------------- | ------------ | ----------- |
+| **`name`** | Expected npm package name. | **true** | - |
+| **`version`** | Expected committed package version. | **true** | - |
+| **`tarball-path`** | Local tarball path to replace with the original published bytes on recovery. | **true** | - |
+
+
+
+
+
+
+## Outputs
+
+| **Output** | **Description** |
+| --------------- | ----------------------------------------------------------------------------- |
+| **`published`** | Whether the exact version already exists on npm and its tarball was restored. |
+
+
+
+
+## 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 © 2026 hoverkraft
+
+For more details, see the [license](http://choosealicense.com/licenses/mit/).
+
+
+
+
+---
+
+This documentation was automatically generated by [CI Dokumentor](https://github.com/hoverkraft-tech/ci-dokumentor).
+
+
+
+
+## Usage
+
+```yaml
+- uses: hoverkraft-tech/ci-github-nodejs/actions/package/restore@
+ with:
+ # Expected npm package name.
+ # This input is required.
+ name: ""
+
+ # Expected committed package version.
+ # This input is required.
+ version: ""
+
+ # Local tarball path to replace with the original published bytes on recovery.
+ # This input is required.
+ tarball-path: ""
+```
+
+
+
+
diff --git a/actions/package/restore/action.yml b/actions/package/restore/action.yml
new file mode 100644
index 00000000..c187fe5c
--- /dev/null
+++ b/actions/package/restore/action.yml
@@ -0,0 +1,37 @@
+name: Restore published Node.js package
+description: Validate a release tarball, check an exact npm version and restore its original tarball with integrity verification when resuming publication.
+author: hoverkraft
+branding:
+ icon: package
+ color: blue
+inputs:
+ name:
+ description: Expected npm package name.
+ required: true
+ version:
+ description: Expected committed package version.
+ required: true
+ tarball-path:
+ description: Local tarball path to replace with the original published bytes on recovery.
+ required: true
+outputs:
+ published:
+ description: Whether the exact version already exists on npm and its tarball was restored.
+ value: ${{ steps.restore.outputs.published }}
+runs:
+ using: composite
+ steps:
+ - id: restore
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ ACTION_PATH: ${{ github.action_path }}
+ PACKAGE_NAME: ${{ inputs.name }}
+ PACKAGE_VERSION: ${{ inputs.version }}
+ TARBALL_PATH: ${{ inputs.tarball-path }}
+ with:
+ script: |
+ const { PackageRecovery } = require(`${process.env.ACTION_PATH}/PackageRecovery.js`);
+ const options = { name: process.env.PACKAGE_NAME, version: process.env.PACKAGE_VERSION, tarballPath: process.env.TARBALL_PATH };
+ await PackageRecovery.verifyTarball({ exec, ...options });
+ const published = await new PackageRecovery().restorePackage(options);
+ core.setOutput('published', String(published));
diff --git a/actions/package/restore/package-lock.json b/actions/package/restore/package-lock.json
new file mode 100644
index 00000000..f8a27f8d
--- /dev/null
+++ b/actions/package/restore/package-lock.json
@@ -0,0 +1,30 @@
+{
+ "name": "package-restore-tests",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "package-restore-tests",
+ "devDependencies": {
+ "@actions/exec": "^1.1.1"
+ }
+ },
+ "node_modules/@actions/exec": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz",
+ "integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@actions/io": "^1.0.1"
+ }
+ },
+ "node_modules/@actions/io": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz",
+ "integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/actions/package/restore/package.json b/actions/package/restore/package.json
new file mode 100644
index 00000000..72b92d46
--- /dev/null
+++ b/actions/package/restore/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "package-restore-tests",
+ "private": true,
+ "scripts": {
+ "test": "node --test",
+ "test:ci": "node --test"
+ },
+ "devDependencies": {
+ "@actions/exec": "^1.1.1"
+ }
+}
diff --git a/actions/package/test/README.md b/actions/package/test/README.md
new file mode 100644
index 00000000..549b9265
--- /dev/null
+++ b/actions/package/test/README.md
@@ -0,0 +1,93 @@
+
+
+#  GitHub Action: Test release package
+
+
+

+
+
+---
+
+
+
+
+[](https://github.com/marketplace/actions/test-release-package)
+[](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
+
+Run consumer smoke tests against the package tarball and reject changes to its bytes.
+
+
+
+
+## Inputs
+
+| **Input** | **Description** | **Required** | **Default** |
+| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ----------- |
+| **`commands`** | Shell commands run from working-directory, with PACKAGE_TARBALL_PATH, PACKAGE_NAME and PACKAGE_VERSION. Install test dependencies explicitly when needed. | **false** | - |
+| **`working-directory`** | Project or workspace root used by package tests. | **false** | `.` |
+| **`tarball-path`** | Absolute path to the package tarball. | **true** | - |
+
+
+
+
+
+
+
+
+## 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 © 2026 hoverkraft
+
+For more details, see the [license](http://choosealicense.com/licenses/mit/).
+
+
+
+
+---
+
+This documentation was automatically generated by [CI Dokumentor](https://github.com/hoverkraft-tech/ci-dokumentor).
+
+
+
+
+## Usage
+
+```yaml
+- uses: hoverkraft-tech/ci-github-nodejs/actions/package/test@
+ with:
+ # Shell commands run from working-directory, with PACKAGE_TARBALL_PATH, PACKAGE_NAME and PACKAGE_VERSION. Install test dependencies explicitly when needed.
+ commands: ""
+
+ # Project or workspace root used by package tests.
+ # Default: `.`
+ working-directory: .
+
+ # Absolute path to the package tarball.
+ # This input is required.
+ tarball-path: ""
+```
+
+
+
+
diff --git a/actions/package/test/action.yml b/actions/package/test/action.yml
new file mode 100644
index 00000000..1bc807d5
--- /dev/null
+++ b/actions/package/test/action.yml
@@ -0,0 +1,44 @@
+name: Test package
+description: Run consumer smoke tests against the package tarball and reject changes to its bytes.
+author: hoverkraft
+branding:
+ icon: check-circle
+ color: blue
+inputs:
+ commands:
+ description: Shell commands run from working-directory, with PACKAGE_TARBALL_PATH, PACKAGE_NAME and PACKAGE_VERSION. Install test dependencies explicitly when needed.
+ default: ""
+ working-directory:
+ description: Project or workspace root used by package tests.
+ default: .
+ tarball-path:
+ description: Absolute path to the package tarball.
+ required: true
+runs:
+ using: composite
+ steps:
+ - id: test
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ PACKAGE_TEST_COMMANDS: ${{ inputs.commands }}
+ WORKING_DIRECTORY: ${{ inputs.working-directory }}
+ PACKAGE_TARBALL_PATH: ${{ inputs.tarball-path }}
+ with:
+ script: |
+ const fs = require('node:fs');
+ const path = require('node:path');
+ const { createHash } = require('node:crypto');
+ if (!process.env.PACKAGE_TEST_COMMANDS.trim()) return;
+ const tarball = path.resolve(process.env.PACKAGE_TARBALL_PATH);
+ const digest = () => createHash('sha256').update(fs.readFileSync(tarball)).digest('hex');
+ const expected = digest();
+ const { stdout } = await exec.getExecOutput('tar', ['-xOf', tarball, 'package/package.json'], { silent: true });
+ const { name, version } = JSON.parse(stdout);
+ if (typeof name !== 'string' || !name || typeof version !== 'string' || !version) {
+ throw new Error('Package tarball must declare its name and version.');
+ }
+ await exec.exec('bash', ['--noprofile', '--norc', '-e', '-o', 'pipefail', '-c', process.env.PACKAGE_TEST_COMMANDS], {
+ cwd: path.resolve(process.env.GITHUB_WORKSPACE, process.env.WORKING_DIRECTORY),
+ env: { ...process.env, PACKAGE_TARBALL_PATH: tarball, PACKAGE_NAME: name, PACKAGE_VERSION: version },
+ });
+ if (digest() !== expected) throw new Error('Package tests must not modify the package tarball.');
diff --git a/actions/release/finalize/.node-version b/actions/release/finalize/.node-version
new file mode 120000
index 00000000..7363229a
--- /dev/null
+++ b/actions/release/finalize/.node-version
@@ -0,0 +1 @@
+../../../.node-version
\ No newline at end of file
diff --git a/actions/release/finalize/README.md b/actions/release/finalize/README.md
new file mode 100644
index 00000000..bf33082e
--- /dev/null
+++ b/actions/release/finalize/README.md
@@ -0,0 +1,71 @@
+
+
+#  GitHub Action: Finalize Node.js release
+
+
+

+
+
+---
+
+
+
+
+[](https://github.com/marketplace/actions/finalize-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
+
+Verify a published npm tarball against the named release asset, then stage it only if missing and publish the draft with HoverKraft release/update using GITHUB_TOKEN.
+
+
+
+
+## Inputs
+
+| **Input** | **Description** | **Required** | **Default** |
+| -------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------ | ----------- |
+| **`tag`** | Committed release tag. | **true** | - |
+| **`package-tarball-path`** | Local tarball path returned by package/publish, including the original npm bytes restored during recovery. | **true** | - |
+| **`asset-name`** | Plain .tgz filename for the GitHub release asset. | **true** | - |
+
+
+
+
+
+
+
+
+## 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 © 2026 hoverkraft
+
+For more details, see the [license](http://choosealicense.com/licenses/mit/).
+
+
+
+
+---
+
+This documentation was automatically generated by [CI Dokumentor](https://github.com/hoverkraft-tech/ci-dokumentor).
+
+
diff --git a/actions/release/finalize/action.yml b/actions/release/finalize/action.yml
new file mode 100644
index 00000000..64d5dfbf
--- /dev/null
+++ b/actions/release/finalize/action.yml
@@ -0,0 +1,52 @@
+name: Finalize Node.js release
+description: Verify a published npm tarball against the named release asset, then stage it only if missing and publish the draft with HoverKraft release/update using GITHUB_TOKEN.
+author: hoverkraft
+branding:
+ icon: tag
+ color: blue
+inputs:
+ tag:
+ description: Committed release tag.
+ required: true
+ package-tarball-path:
+ description: Local tarball path returned by package/publish, including the original npm bytes restored during recovery.
+ required: true
+ asset-name:
+ description: Plain .tgz filename for the GitHub release asset.
+ required: true
+runs:
+ using: composite
+ steps:
+ - id: assets
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ ACTION_PATH: ${{ github.action_path }}
+ RELEASE_TAG: ${{ inputs.tag }}
+ PACKAGE_TARBALL_PATH: ${{ inputs.package-tarball-path }}
+ ASSET_NAME: ${{ inputs.asset-name }}
+ RUNNER_TEMP: ${{ runner.temp }}
+ with:
+ script: |
+ const releaseAssets = require(`${process.env.ACTION_PATH}/index.js`);
+ const { release, pendingDirectory, hasAssets } = await releaseAssets.prepareAssets({
+ github, repo: context.repo, tag: process.env.RELEASE_TAG,
+ tarballPath: process.env.PACKAGE_TARBALL_PATH, assetName: process.env.ASSET_NAME,
+ tempDirectory: process.env.RUNNER_TEMP,
+ });
+ core.setOutput('release-id', release.id);
+ core.setOutput('published', String(!release.draft));
+ core.setOutput('pending-directory', pendingDirectory);
+ core.setOutput('has-assets', String(hasAssets));
+ - id: pending-assets
+ if: steps.assets.outputs.has-assets == 'true'
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: release-pending-assets-${{ steps.assets.outputs.release-id }}-${{ github.run_id }}-${{ github.run_attempt }}
+ path: ${{ steps.assets.outputs.pending-directory }}/*.tgz
+ if-no-files-found: error
+ - uses: hoverkraft-tech/ci-github-publish/actions/release/update@ea6682de88070c254dac619a6623a242a6d57cd3 # 1.0.3
+ if: steps.assets.outputs.published != 'true'
+ with:
+ tag: ${{ inputs.tag }}
+ publish: "true"
+ release-artifact-id: ${{ steps.pending-assets.outputs.artifact-id }}
diff --git a/actions/release/finalize/index.js b/actions/release/finalize/index.js
new file mode 100644
index 00000000..65a3e0bd
--- /dev/null
+++ b/actions/release/finalize/index.js
@@ -0,0 +1,65 @@
+const { createHash } = require("node:crypto");
+const fs = require("node:fs");
+const path = require("node:path");
+
+async function findRelease(github, repo, tag) {
+ try {
+ const { data } = await github.rest.repos.getReleaseByTag({
+ ...repo,
+ tag,
+ });
+ return data;
+ } catch (error) {
+ if (error.status !== 404) throw error;
+ const releases = await github.paginate(github.rest.repos.listReleases, {
+ ...repo,
+ per_page: 100,
+ });
+ return releases.find((release) => release.tag_name === tag);
+ }
+}
+
+async function prepareAssets({
+ github,
+ repo,
+ tag,
+ tarballPath,
+ assetName,
+ tempDirectory,
+}) {
+ if (
+ typeof assetName !== "string" ||
+ !/^[A-Za-z0-9][A-Za-z0-9._-]*\.tgz$/.test(assetName)
+ )
+ throw new Error("asset-name must be a plain .tgz filename.");
+ if (!fs.lstatSync(tarballPath).isFile())
+ throw new Error("package-tarball-path must point to a regular file.");
+ const release = await findRelease(github, repo, tag);
+ if (!release) throw new Error(`No release found for ${tag}.`);
+ const assets = await github.paginate(github.rest.repos.listReleaseAssets, {
+ ...repo,
+ release_id: release.id,
+ per_page: 100,
+ });
+ const data = fs.readFileSync(tarballPath);
+ const digest = `sha256:${createHash("sha256").update(data).digest("hex")}`;
+ const existing = assets.find((asset) => asset.name === assetName);
+ if (existing) {
+ if (existing.digest !== digest)
+ throw new Error(
+ `Release asset ${assetName} has a different or unavailable digest.`,
+ );
+ return { release, pendingDirectory: "", hasAssets: false };
+ }
+ if (!release.draft)
+ throw new Error(
+ `Published release is missing expected asset ${assetName}.`,
+ );
+ const pendingDirectory = fs.mkdtempSync(
+ path.join(tempDirectory, "release-pending-"),
+ );
+ fs.writeFileSync(path.join(pendingDirectory, assetName), data);
+ return { release, pendingDirectory, hasAssets: true };
+}
+
+module.exports = { prepareAssets };
diff --git a/actions/release/finalize/index.test.js b/actions/release/finalize/index.test.js
new file mode 100644
index 00000000..39228193
--- /dev/null
+++ b/actions/release/finalize/index.test.js
@@ -0,0 +1,147 @@
+const assert = require("node:assert/strict");
+const { test } = require("node:test");
+const { createHash } = require("node:crypto");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+const releaseAssets = require("./index.js");
+
+function directory(t) {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "nodejs-release-assets-"));
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
+ return root;
+}
+function assetsFixture(t) {
+ const root = directory(t);
+ const data = Buffer.from("published package");
+ const tarballPath = path.join(root, "downloaded-artifact.tgz");
+ fs.writeFileSync(tarballPath, data);
+ const release = { id: 1, draft: true };
+ const assets = [];
+ const github = {
+ rest: {
+ repos: {
+ getReleaseByTag: async () => ({ data: release }),
+ },
+ },
+ paginate: async () => assets,
+ };
+ return {
+ release,
+ assets,
+ data,
+ args: {
+ github,
+ repo: { owner: "example", repo: "project" },
+ tag: "v1.2.0",
+ tarballPath,
+ assetName: "example-core-1.2.0.tgz",
+ tempDirectory: root,
+ },
+ };
+}
+
+test("stages the explicit tarball under its release name and skips identical assets", async (t) => {
+ const f = assetsFixture(t);
+ const result = await releaseAssets.prepareAssets(f.args);
+ assert.equal(result.hasAssets, true);
+ assert.deepEqual(
+ fs.readFileSync(
+ path.join(result.pendingDirectory, "example-core-1.2.0.tgz"),
+ ),
+ f.data,
+ );
+ assert.deepEqual(fs.readdirSync(result.pendingDirectory), [f.args.assetName]);
+ assert.deepEqual(fs.readFileSync(f.args.tarballPath), f.data);
+ f.assets.push({
+ name: "example-core-1.2.0.tgz",
+ digest: `sha256:${createHash("sha256").update(f.data).digest("hex")}`,
+ });
+ const retry = await releaseAssets.prepareAssets(f.args);
+ assert.equal(retry.hasAssets, false);
+ assert.equal(retry.pendingDirectory, "");
+ f.release.draft = false;
+ assert.equal((await releaseAssets.prepareAssets(f.args)).hasAssets, false);
+});
+
+test("does not overwrite mismatched assets or modify an incomplete published release", async (t) => {
+ const f = assetsFixture(t);
+ f.assets.push({ name: "example-core-1.2.0.tgz", digest: "sha256:different" });
+ await assert.rejects(
+ releaseAssets.prepareAssets(f.args),
+ /different or unavailable digest/,
+ );
+ f.assets.length = 0;
+ f.release.draft = false;
+ await assert.rejects(
+ releaseAssets.prepareAssets(f.args),
+ /missing expected asset/,
+ );
+});
+
+test("attaches only the requested file even when other tarballs are present", async (t) => {
+ const f = assetsFixture(t);
+ fs.writeFileSync(
+ path.join(f.args.tempDirectory, "unrelated.tgz"),
+ "other package",
+ );
+ const result = await releaseAssets.prepareAssets(f.args);
+ assert.deepEqual(fs.readdirSync(result.pendingDirectory), [f.args.assetName]);
+});
+
+test("rejects asset names that could escape staging or are not tarball filenames", async (t) => {
+ const f = assetsFixture(t);
+ for (const assetName of [
+ "",
+ "../outside.tgz",
+ "/outside.tgz",
+ "dir\\outside.tgz",
+ "package.zip",
+ "bad\nname.tgz",
+ ]) {
+ await assert.rejects(
+ releaseAssets.prepareAssets({ ...f.args, assetName }),
+ /asset-name must be a plain .tgz filename/,
+ );
+ }
+ assert.deepEqual(fs.readdirSync(f.args.tempDirectory), [
+ "downloaded-artifact.tgz",
+ ]);
+});
+
+test("rejects missing tarballs, directories and symbolic links", async (t) => {
+ const f = assetsFixture(t);
+ const folder = path.join(f.args.tempDirectory, "directory.tgz");
+ const link = path.join(f.args.tempDirectory, "link.tgz");
+ fs.mkdirSync(folder);
+ fs.symlinkSync(f.args.tarballPath, link);
+ for (const tarballPath of [folder, link]) {
+ await assert.rejects(
+ releaseAssets.prepareAssets({ ...f.args, tarballPath }),
+ /package-tarball-path must point to a regular file/,
+ );
+ }
+ fs.unlinkSync(f.args.tarballPath);
+ await assert.rejects(releaseAssets.prepareAssets(f.args), /ENOENT/);
+});
+
+test("recovers draft assets through release listing and propagates lookup failures", async (t) => {
+ const f = assetsFixture(t);
+ const { github } = f.args;
+ github.rest.repos.getReleaseByTag = async () => {
+ throw Object.assign(new Error("Not found"), { status: 404 });
+ };
+ github.rest.repos.listReleases = Symbol("list releases");
+ github.rest.repos.listReleaseAssets = Symbol("list assets");
+ github.paginate = async (method) =>
+ method === github.rest.repos.listReleases
+ ? [{ ...f.release, tag_name: f.args.tag }]
+ : f.assets;
+ assert.equal((await releaseAssets.prepareAssets(f.args)).hasAssets, true);
+ github.paginate = async () => [];
+ await assert.rejects(releaseAssets.prepareAssets(f.args), /No release found/);
+ github.rest.repos.getReleaseByTag = async () => {
+ throw Object.assign(new Error("Forbidden"), { status: 403 });
+ };
+ await assert.rejects(releaseAssets.prepareAssets(f.args), /Forbidden/);
+});
diff --git a/actions/release/finalize/package-lock.json b/actions/release/finalize/package-lock.json
new file mode 100644
index 00000000..c6ee39bb
--- /dev/null
+++ b/actions/release/finalize/package-lock.json
@@ -0,0 +1,10 @@
+{
+ "name": "release-finalize-tests",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "release-finalize-tests"
+ }
+ }
+}
diff --git a/actions/release/finalize/package.json b/actions/release/finalize/package.json
new file mode 100644
index 00000000..fcf7eb4a
--- /dev/null
+++ b/actions/release/finalize/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "release-finalize-tests",
+ "private": true,
+ "scripts": {
+ "test": "node --test",
+ "test:ci": "node --test"
+ }
+}
diff --git a/actions/release/prepare/.node-version b/actions/release/prepare/.node-version
new file mode 120000
index 00000000..7363229a
--- /dev/null
+++ b/actions/release/prepare/.node-version
@@ -0,0 +1 @@
+../../../.node-version
\ No newline at end of file
diff --git a/actions/release/prepare/README.md b/actions/release/prepare/README.md
new file mode 100644
index 00000000..1271a94a
--- /dev/null
+++ b/actions/release/prepare/README.md
@@ -0,0 +1,78 @@
+
+
+#  GitHub Action: Prepare Node.js package release
+
+
+

+
+
+---
+
+
+
+
+[](https://github.com/marketplace/actions/prepare-node.js-package-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
+
+Apply the HoverKraft Conventional Commit plan to one public npm package and dispatch publication from the committed tag.
+
+
+
+
+## Inputs
+
+| **Input** | **Description** | **Required** | **Default** |
+| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------ | --------------------- |
+| **`working-directory`** | Public npm project directory containing its package.json and lockfile. | **false** | `.` |
+| **`github-token`** | Token with contents and actions write and pull-requests read permissions. Its identity must be allowed to push release commits and tags. | **false** | `${{ github.token }}` |
+
+
+
+
+
+
+## Outputs
+
+| **Output** | **Description** |
+| -------------- | ------------------------------------------------------------------------------------------------------------ |
+| **`status`** | One of no-changes, dispatched, ready or released. Only ready should start building, followed by publication. |
+| **`packages`** | JSON array containing the public npm package at the committed release version. |
+
+
+
+
+## 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 © 2026 hoverkraft
+
+For more details, see the [license](http://choosealicense.com/licenses/mit/).
+
+
+
+
+---
+
+This documentation was automatically generated by [CI Dokumentor](https://github.com/hoverkraft-tech/ci-dokumentor).
+
+
diff --git a/actions/release/prepare/action.yml b/actions/release/prepare/action.yml
new file mode 100644
index 00000000..18300174
--- /dev/null
+++ b/actions/release/prepare/action.yml
@@ -0,0 +1,159 @@
+name: Prepare Node.js package release
+description: Apply the HoverKraft Conventional Commit plan to one public npm package and dispatch publication from the committed tag.
+author: hoverkraft
+branding:
+ icon: git-commit
+ color: blue
+inputs:
+ working-directory:
+ description: Public npm project directory containing its package.json and lockfile.
+ default: .
+ github-token:
+ description: Token with contents and actions write and pull-requests read permissions. Its identity must be allowed to push release commits and tags.
+ default: ${{ github.token }}
+outputs:
+ status:
+ description: One of no-changes, dispatched, ready or released. Only ready should start building, followed by publication.
+ value: ${{ steps.inspect.outputs.status || (steps.dispatch.outcome == 'success' && 'dispatched') || 'no-changes' }}
+ packages:
+ description: JSON array containing the public npm package at the committed release version.
+ value: ${{ steps.inspect.outputs.packages || '[]' }}
+runs:
+ using: composite
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+ token: ${{ inputs.github-token }}
+ persist-credentials: true
+ - name: Inspect release project and existing tag
+ id: inspect
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ ACTION_PATH: ${{ github.action_path }}
+ WORKING_DIRECTORY: ${{ inputs.working-directory }}
+ CALLER_WORKFLOW_REF: ${{ github.workflow_ref }}
+ with:
+ github-token: ${{ inputs.github-token }}
+ script: |
+ const fs = require('node:fs');
+ const path = require('node:path');
+ const releasePackage = require(`${process.env.ACTION_PATH}/index.js`);
+ const workspace = fs.realpathSync(process.env.GITHUB_WORKSPACE);
+ const directory = fs.realpathSync(path.resolve(workspace, process.env.WORKING_DIRECTORY));
+ const relativeDirectory = path.relative(workspace, directory);
+ if (relativeDirectory.startsWith('..') || path.isAbsolute(relativeDirectory)) throw new Error('Release directory must be inside the repository.');
+ const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf8'));
+ if (manifest.private || manifest.workspaces || !['package-lock.json', 'npm-shrinkwrap.json'].some(file => fs.existsSync(path.join(directory, file))) || (manifest.packageManager && !manifest.packageManager.startsWith('npm@')) || ['lerna.json', 'pnpm-workspace.yaml', 'pnpm-lock.yaml', 'yarn.lock'].some(file => fs.existsSync(path.join(directory, file)))) {
+ throw new Error('Release preparation requires one public npm package with its own lockfile.');
+ }
+ const { name, version } = manifest;
+ const item = { name, version, directory: '.', slug: typeof name === 'string' ? name.replace(/^@/, '').replace('/', '-') : '' };
+ releasePackage.parsePackages(JSON.stringify([item]));
+ const versionFromTag = tag => releasePackage.versionFromTag(tag, directory, relativeDirectory);
+ let tag;
+ if (context.ref.startsWith('refs/tags/')) {
+ tag = context.ref.slice('refs/tags/'.length);
+ if (versionFromTag(tag) !== version) throw new Error('Release tag must match the committed package version.');
+ core.setOutput('status', 'ready');
+ core.setOutput('packages', [{ ...item, tag }]);
+ } else {
+ if (context.ref !== `refs/heads/${context.payload.repository.default_branch}`) throw new Error('Run releases from the default branch or an existing release tag.');
+ const { stdout } = await exec.getExecOutput('git', ['tag', '--points-at', context.sha], { cwd: directory });
+ const tags = stdout.trim().split('\n').filter(candidate => {
+ try { return versionFromTag(candidate) === version; } catch { return false; }
+ });
+ if (tags.length > 1) throw new Error('Several release tags match this commit; resume the intended tag explicitly.');
+ tag = tags[0];
+ const prefix = `${context.repo.owner}/${context.repo.repo}/`;
+ const workflow = process.env.CALLER_WORKFLOW_REF?.startsWith(prefix) ? process.env.CALLER_WORKFLOW_REF.slice(prefix.length).split('@')[0] : '';
+ if (!/^\.github\/workflows\/[^/]+\.ya?ml$/.test(workflow)) throw new Error('Cannot determine the caller workflow for publication dispatch.');
+ core.setOutput('workflow', workflow);
+ }
+ if (tag) {
+ const release = await releasePackage.findRelease(github, context.repo, tag);
+ if (!context.ref.startsWith('refs/tags/') && release && !release.draft) core.setOutput('status', 'released');
+ }
+ core.setOutput('tag', tag || '');
+ core.setOutput('version', version);
+ - name: Plan release with HoverKraft
+ id: plan
+ if: steps.inspect.outputs.tag == ''
+ uses: hoverkraft-tech/ci-github-publish/actions/release/plan@ea6682de88070c254dac619a6623a242a6d57cd3 # 1.0.3
+ with:
+ github-token: ${{ inputs.github-token }}
+ target-sha: ${{ github.sha }}
+ working-directory: ${{ inputs.working-directory != '.' && inputs.working-directory || '' }}
+ - name: Restore source checkout for version preparation
+ if: steps.plan.outputs.has-changes == 'true'
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+ token: ${{ inputs.github-token }}
+ persist-credentials: true
+ - uses: $/actions/setup-node
+ if: steps.plan.outputs.has-changes == 'true'
+ with:
+ working-directory: ${{ inputs.working-directory }}
+ ignore-scripts: "true"
+ - name: Validate planned version
+ id: version
+ if: steps.plan.outputs.has-changes == 'true'
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ ACTION_PATH: ${{ github.action_path }}
+ WORKING_DIRECTORY: ${{ inputs.working-directory }}
+ RELEASE_TAG: ${{ steps.plan.outputs.tag }}
+ CURRENT_VERSION: ${{ steps.inspect.outputs.version }}
+ with:
+ script: |
+ const fs = require('node:fs');
+ const path = require('node:path');
+ const releasePackage = require(`${process.env.ACTION_PATH}/index.js`);
+ const workspace = fs.realpathSync(process.env.GITHUB_WORKSPACE);
+ const directory = fs.realpathSync(path.resolve(workspace, process.env.WORKING_DIRECTORY));
+ const version = releasePackage.versionFromTag(process.env.RELEASE_TAG, directory, path.relative(workspace, directory));
+ releasePackage.validateVersion(version, process.env.CURRENT_VERSION);
+ const existing = await exec.getExecOutput('git', ['show-ref', '--verify', '--quiet', `refs/tags/${process.env.RELEASE_TAG}`], { cwd: directory, ignoreReturnCode: true });
+ if (existing.exitCode === 0) throw new Error(`Existing tag ${process.env.RELEASE_TAG} must not be overwritten.`);
+ if (existing.exitCode !== 1) throw new Error('Could not check the planned release tag.');
+ core.setOutput('version', version);
+ - name: Apply the planned version
+ if: steps.plan.outputs.has-changes == 'true'
+ shell: bash
+ working-directory: ${{ inputs.working-directory }}
+ env:
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ RELEASE_VERSION: ${{ steps.version.outputs.version }}
+ run: |
+ git switch --force-create "$DEFAULT_BRANCH" "$GITHUB_SHA"
+ if [ -n "$(git status --porcelain)" ]; then
+ echo "Release source must be clean before applying the planned version." >&2
+ exit 1
+ fi
+ npm version "$RELEASE_VERSION" --allow-same-version --no-git-tag-version --ignore-scripts
+ git add --all -- .
+ - name: Push the signed release commit and tag atomically
+ if: steps.plan.outputs.has-changes == 'true'
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ ACTION_PATH: ${{ github.action_path }}
+ WORKING_DIRECTORY: ${{ inputs.working-directory }}
+ RELEASE_TAG: ${{ steps.plan.outputs.tag }}
+ with:
+ github-token: ${{ inputs.github-token }}
+ script: |
+ const path = require('node:path');
+ const { pushRelease } = require(`${process.env.ACTION_PATH}/index.js`);
+ await pushRelease({ github, context, exec, directory: path.resolve(process.env.GITHUB_WORKSPACE, process.env.WORKING_DIRECTORY), tags: [process.env.RELEASE_TAG] });
+ - name: Dispatch the caller at the prepared tag
+ id: dispatch
+ if: steps.inspect.outputs.status == '' && (steps.inspect.outputs.tag != '' || steps.plan.outputs.has-changes == 'true')
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ CALLER_WORKFLOW: ${{ steps.inspect.outputs.workflow }}
+ RELEASE_TAG: ${{ steps.inspect.outputs.tag || steps.plan.outputs.tag }}
+ with:
+ github-token: ${{ inputs.github-token }}
+ script: |
+ await github.rest.actions.createWorkflowDispatch({ ...context.repo, workflow_id: process.env.CALLER_WORKFLOW, ref: process.env.RELEASE_TAG });
diff --git a/actions/release/prepare/index.js b/actions/release/prepare/index.js
new file mode 100644
index 00000000..1c97b7b6
--- /dev/null
+++ b/actions/release/prepare/index.js
@@ -0,0 +1,191 @@
+const path = require("node:path");
+
+function versionFromTag(tag, directory, relativeDirectory) {
+ const slug = path
+ .basename(directory)
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-|-$/g, "");
+ const prefixes = ["", "v", ...(relativeDirectory ? [`${slug}-`] : [])];
+ const version = prefixes
+ .map((prefix) => (tag.startsWith(prefix) ? tag.slice(prefix.length) : ""))
+ .find((candidate) =>
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(candidate),
+ );
+ if (!version)
+ throw new Error(
+ "Expected a stable version tag, optionally prefixed with v or the project directory slug.",
+ );
+ return version;
+}
+
+function parsePackages(input, version) {
+ const packages = JSON.parse(input);
+ if (!Array.isArray(packages) || packages.length !== 1)
+ throw new Error("Expected exactly one public npm package.");
+ for (const item of packages) {
+ if (
+ !item ||
+ typeof item.name !== "string" ||
+ !/^(?:@[a-z0-9-]+\/)?[a-z0-9][a-z0-9._-]*$/.test(item.name) ||
+ item.slug !== item.name.replace(/^@/, "").replace("/", "-") ||
+ typeof item.version !== "string" ||
+ !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(item.version) ||
+ item.directory !== "."
+ )
+ throw new Error(
+ "Expected a valid package name, slug, stable version and root package directory.",
+ );
+ }
+ if (version && packages.some((item) => item.version !== version))
+ throw new Error("Package versions must match the prepared release tag.");
+ return packages;
+}
+
+async function findRelease(github, repo, tag) {
+ try {
+ const { data } = await github.rest.repos.getReleaseByTag({
+ ...repo,
+ tag,
+ });
+ return data;
+ } catch (error) {
+ if (error.status !== 404) throw error;
+ const releases = await github.paginate(github.rest.repos.listReleases, {
+ ...repo,
+ per_page: 100,
+ });
+ return releases.find((release) => release.tag_name === tag);
+ }
+}
+
+function validateVersion(version, current) {
+ const previous = current.split(".").map(BigInt);
+ const difference = version
+ .split(".")
+ .map((part, index) => BigInt(part) - previous[index])
+ .find((part) => part !== 0n);
+ if (difference < 0n) {
+ throw new Error(
+ "Planned version is older than the committed version; reconcile the release history and configuration.",
+ );
+ }
+}
+
+async function pushRelease({ github, context, exec, directory, tags }) {
+ const git = async (...args) => {
+ const { stdout, stderr, exitCode } = await exec.getExecOutput("git", args, {
+ cwd: directory,
+ silent: true,
+ ignoreReturnCode: true,
+ });
+ if (exitCode !== 0)
+ throw new Error(
+ stderr.trim() || `git failed with exit code ${exitCode}.`,
+ );
+ return stdout.trim();
+ };
+ const branch = context.payload.repository.default_branch;
+ if ((await git("rev-parse", "HEAD")) !== context.sha) {
+ throw new Error("Release preparation must start from the planned source.");
+ }
+ if (
+ !Array.isArray(tags) ||
+ !tags.length ||
+ new Set(tags).size !== tags.length
+ ) {
+ throw new Error("Expected unique release tags.");
+ }
+ for (const tag of tags) await git("check-ref-format", `refs/tags/${tag}`);
+ await git("check-ref-format", `refs/heads/${branch}`);
+
+ let commit = context.sha;
+ const tree = await git("write-tree");
+ const baseTree = await git("rev-parse", `${context.sha}^{tree}`);
+ if (tree !== baseTree) {
+ const changes = (
+ await git(
+ "diff",
+ "--cached",
+ "--raw",
+ "--no-abbrev",
+ "--no-renames",
+ "-z",
+ )
+ ).split("\0");
+ const entries = [];
+ for (let index = 0; index < changes.length - 1; index += 2) {
+ const [, mode, , sha] = changes[index].split(" ");
+ const path = changes[index + 1];
+ if (mode === "000000") {
+ entries.push({ path, mode: "100644", type: "blob", sha: null });
+ continue;
+ }
+ if (!["100644", "100755", "120000"].includes(mode)) {
+ throw new Error(`Unsupported release file mode: ${mode}`);
+ }
+ const chunks = [];
+ await exec.exec("git", ["cat-file", "blob", sha], {
+ cwd: directory,
+ silent: true,
+ listeners: { stdout: (chunk) => chunks.push(chunk) },
+ });
+ const content = Buffer.concat(chunks).toString("base64");
+ const { data: blob } = await github.rest.git.createBlob({
+ ...context.repo,
+ content,
+ encoding: "base64",
+ });
+ if (blob.sha !== sha)
+ throw new Error("Release blob does not match staged bytes.");
+ entries.push({ path, mode, type: "blob", sha });
+ }
+ const { data: createdTree } = await github.rest.git.createTree({
+ ...context.repo,
+ base_tree: baseTree,
+ tree: entries,
+ });
+ if (createdTree.sha !== tree)
+ throw new Error("Release tree does not match staged files.");
+ // GitHub signs App/bot commits when no custom author, committer or signature is supplied.
+ const { data: createdCommit } = await github.rest.git.createCommit({
+ ...context.repo,
+ message: `chore(release): ${tags.join(", ")}`,
+ tree,
+ parents: [context.sha],
+ });
+ if (!createdCommit.verification?.verified) {
+ throw new Error(
+ "GitHub did not verify the release commit signature; use a GitHub App installation token.",
+ );
+ }
+ commit = createdCommit.sha;
+ await git("fetch", "--no-tags", "origin", commit);
+ if (
+ (await git("rev-parse", `${commit}^{tree}`)) !== tree ||
+ (await git("rev-parse", `${commit}^`)) !== context.sha
+ ) {
+ throw new Error(
+ "Signed release commit does not match the planned source and files.",
+ );
+ }
+ }
+ for (const tag of tags) await git("tag", tag, commit);
+ await git(
+ "push",
+ "--atomic",
+ `--force-with-lease=refs/heads/${branch}:${context.sha}`,
+ "origin",
+ `${commit}:refs/heads/${branch}`,
+ ...tags.map((tag) => `refs/tags/${tag}`),
+ );
+ return commit;
+}
+
+module.exports = {
+ versionFromTag,
+ parsePackages,
+ findRelease,
+ validateVersion,
+ pushRelease,
+};
diff --git a/actions/release/prepare/index.test.js b/actions/release/prepare/index.test.js
new file mode 100644
index 00000000..e5a03f8d
--- /dev/null
+++ b/actions/release/prepare/index.test.js
@@ -0,0 +1,305 @@
+const assert = require("node:assert/strict");
+const exec = require("@actions/exec");
+const { execFileSync } = require("node:child_process");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+const { test } = require("node:test");
+const releasePreparation = require("./index.js");
+const { validateVersion, pushRelease } = releasePreparation;
+
+function fixture(
+ t,
+ {
+ changed = true,
+ verified = true,
+ concurrent = false,
+ conflict = false,
+ } = {},
+) {
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), "release-push-"));
+ t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
+ const remote = path.join(directory, "origin.git");
+ const source = path.join(directory, "source");
+ fs.mkdirSync(source);
+ const git = (cwd, args, options = {}) =>
+ execFileSync("git", args, {
+ cwd,
+ encoding: "utf8",
+ stdio: ["pipe", "pipe", "pipe"],
+ env: {
+ ...process.env,
+ GIT_AUTHOR_NAME: "Test",
+ GIT_AUTHOR_EMAIL: "test@example.com",
+ GIT_COMMITTER_NAME: "Test",
+ GIT_COMMITTER_EMAIL: "test@example.com",
+ },
+ ...options,
+ }).trim();
+ git(directory, ["init", "--bare", remote]);
+ git(source, ["init", "-b", "main"]);
+ for (const repository of [remote, source]) {
+ git(repository, ["config", "tag.gpgsign", "false"]);
+ git(repository, ["config", "core.hooksPath", "/dev/null"]);
+ }
+ fs.writeFileSync(
+ path.join(source, "package.json"),
+ '{"name":"example","version":"1.0.0"}\n',
+ );
+ git(source, ["add", "."]);
+ git(source, ["-c", "commit.gpgsign=false", "commit", "-m", "feat: initial"]);
+ const sha = git(source, ["rev-parse", "HEAD"]);
+ git(source, ["remote", "add", "origin", remote]);
+ git(source, ["push", "origin", "main"]);
+ if (changed) {
+ fs.writeFileSync(
+ path.join(source, "package.json"),
+ '{"name":"example","version":"1.1.0"}\n',
+ );
+ git(source, ["add", "."]);
+ }
+ const requests = [];
+ const api = {
+ createBlob: async ({ content, encoding }) => {
+ assert.equal(encoding, "base64");
+ return {
+ data: {
+ sha: git(remote, ["hash-object", "-w", "--stdin"], {
+ input: Buffer.from(content, encoding),
+ }),
+ },
+ };
+ },
+ createTree: async ({ base_tree, tree }) => {
+ git(remote, ["read-tree", base_tree]);
+ for (const entry of tree)
+ git(remote, [
+ "update-index",
+ "--add",
+ "--cacheinfo",
+ `${entry.mode},${entry.sha},${entry.path}`,
+ ]);
+ return { data: { sha: git(remote, ["write-tree"]) } };
+ },
+ createCommit: async (request) => {
+ requests.push(request);
+ const commit = git(remote, [
+ "-c",
+ "commit.gpgsign=false",
+ "commit-tree",
+ request.tree,
+ "-p",
+ sha,
+ "-m",
+ request.message,
+ ]);
+ if (concurrent)
+ git(remote, [
+ "update-ref",
+ "refs/heads/main",
+ git(remote, [
+ "-c",
+ "commit.gpgsign=false",
+ "commit-tree",
+ request.tree,
+ "-p",
+ sha,
+ "-m",
+ "fix: concurrent update",
+ ]),
+ ]);
+ return { data: { sha: commit, verification: { verified } } };
+ },
+ };
+ if (conflict) git(remote, ["tag", "v1.1.0", sha]);
+ return {
+ run: () =>
+ pushRelease({
+ exec,
+ github: { rest: { git: api } },
+ context: {
+ repo: { owner: "example", repo: "project" },
+ sha,
+ payload: { repository: { default_branch: "main" } },
+ },
+ directory: source,
+ tags: ["v1.1.0"],
+ }),
+ ref: (ref) => git(remote, ["rev-parse", "--verify", ref]),
+ sha,
+ source,
+ git,
+ api,
+ requests,
+ };
+}
+
+test("allows planned newer and already-committed versions, but never downgrades", () => {
+ validateVersion("1.1.0", "1.0.9");
+ validateVersion("1.3.0", "1.3.0");
+ assert.throws(() => validateVersion("1.2.9", "1.3.0"), /older/);
+});
+
+test("pushes the API-signed staged tree and tag together without overriding bot identity", async (t) => {
+ const state = fixture(t);
+ const commit = await state.run();
+ assert.notEqual(commit, state.sha);
+ assert.equal(state.ref("refs/heads/main"), commit);
+ assert.equal(state.ref("refs/tags/v1.1.0"), commit);
+ assert.deepEqual(Object.keys(state.requests[0]).sort(), [
+ "message",
+ "owner",
+ "parents",
+ "repo",
+ "tree",
+ ]);
+ assert.deepEqual(state.requests[0].parents, [state.sha]);
+ assert.equal(
+ state.git(state.source, ["show", `${commit}:package.json`]),
+ '{"name":"example","version":"1.1.0"}',
+ );
+});
+
+test("tags an already-versioned commit without creating an empty commit", async (t) => {
+ const state = fixture(t, { changed: false });
+ assert.equal(await state.run(), state.sha);
+ assert.equal(state.ref("refs/tags/v1.1.0"), state.sha);
+ assert.equal(state.requests.length, 0);
+});
+
+test("an unverified signature stops before changing remote refs", async (t) => {
+ const state = fixture(t, { verified: false });
+ await assert.rejects(state.run(), /signature/);
+ assert.equal(state.ref("refs/heads/main"), state.sha);
+ assert.throws(() => state.ref("refs/tags/v1.1.0"));
+});
+
+test("concurrent branch updates reject the entire push and preserve the new branch head", async (t) => {
+ const state = fixture(t, { concurrent: true });
+ await assert.rejects(state.run(), /stale info|atomic push failed/);
+ assert.notEqual(state.ref("refs/heads/main"), state.sha);
+ assert.throws(() => state.ref("refs/tags/v1.1.0"));
+});
+
+test("existing remote tags reject the entire push without moving branch or tag", async (t) => {
+ const state = fixture(t, { conflict: true });
+ await assert.rejects(state.run(), /already exists|atomic push failed/);
+ assert.equal(state.ref("refs/heads/main"), state.sha);
+ assert.equal(state.ref("refs/tags/v1.1.0"), state.sha);
+});
+
+test("GitHub failures leave remote branch and tags untouched", async (t) => {
+ const state = fixture(t);
+ state.api.createCommit = async () => {
+ throw new Error("GitHub unavailable");
+ };
+ await assert.rejects(state.run(), /GitHub unavailable/);
+ assert.equal(state.ref("refs/heads/main"), state.sha);
+ assert.throws(() => state.ref("refs/tags/v1.1.0"));
+});
+
+test("preserves binary staged bytes and paths containing spaces when creating signed commits", async (t) => {
+ const state = fixture(t);
+ const file = "release data.bin";
+ const data = Buffer.from([0, 255, 254, 128, 13, 10, 0, 97]);
+ fs.writeFileSync(path.join(state.source, file), data);
+ state.git(state.source, ["add", "--", file]);
+ const commit = await state.run();
+ assert.deepEqual(
+ execFileSync("git", ["show", `${commit}:${file}`], { cwd: state.source }),
+ data,
+ );
+});
+
+const packageItem = {
+ name: "@example/cli",
+ slug: "example-cli",
+ version: "1.2.3",
+ directory: ".",
+ tag: "v1.2.3",
+};
+test("release accepts one prepared npm package without CI packaging metadata", () => {
+ assert.deepEqual(
+ releasePreparation.parsePackages(
+ JSON.stringify([packageItem]),
+ packageItem.version,
+ ),
+ [packageItem],
+ );
+});
+
+test("rejects ambiguous names, unsafe paths, mismatched versions and invalid package lists", () => {
+ for (const packages of [
+ [],
+ {},
+ [null],
+ [packageItem, packageItem],
+ [{ ...packageItem, directory: "packages/cli" }],
+ [{ ...packageItem, slug: "wrong" }],
+ [{ ...packageItem, name: "../bad" }],
+ [{ ...packageItem, directory: "../outside" }],
+ [{ ...packageItem, directory: "/tmp" }],
+ [{ ...packageItem, version: "1.2.3-beta" }],
+ [{ ...packageItem, version: "1.2.2" }],
+ ]) {
+ assert.throws(
+ () =>
+ releasePreparation.parsePackages(
+ JSON.stringify(packages),
+ packageItem.version,
+ ),
+ /Expected|unique|versions/,
+ );
+ }
+});
+
+test("finds drafts when the release-by-tag endpoint does not return them", async () => {
+ const draft = { id: 1, tag_name: "v1.2.0", draft: true };
+ const github = {
+ rest: {
+ repos: {
+ getReleaseByTag: async () => {
+ throw Object.assign(new Error("Not found"), { status: 404 });
+ },
+ },
+ },
+ paginate: async () => [draft],
+ };
+ assert.equal(
+ await releasePreparation.findRelease(github, {}, "v1.2.0"),
+ draft,
+ );
+ assert.equal(
+ await releasePreparation.findRelease(github, {}, "v1.3.0"),
+ undefined,
+ );
+ github.rest.repos.getReleaseByTag = async () => {
+ throw Object.assign(new Error("Forbidden"), { status: 403 });
+ };
+ await assert.rejects(
+ releasePreparation.findRelease(github, {}, "v1.2.0"),
+ /Forbidden/,
+ );
+});
+
+test("nested project tags normalize directory punctuation and preserve stable versions", () => {
+ const relativeDirectory = " ---My Package--- ";
+ const directory = `/project/${relativeDirectory}`;
+ for (const tag of ["1.2.3", "v1.2.3", "my-package-1.2.3"]) {
+ assert.equal(
+ releasePreparation.versionFromTag(tag, directory, relativeDirectory),
+ "1.2.3",
+ );
+ }
+ for (const tag of [
+ "my-package-01.2.3",
+ "my-package-1.2.3-beta",
+ "wrong-1.2.3",
+ ]) {
+ assert.throws(
+ () =>
+ releasePreparation.versionFromTag(tag, directory, relativeDirectory),
+ /stable version tag/,
+ );
+ }
+});
diff --git a/actions/release/prepare/package-lock.json b/actions/release/prepare/package-lock.json
new file mode 100644
index 00000000..1e496fda
--- /dev/null
+++ b/actions/release/prepare/package-lock.json
@@ -0,0 +1,30 @@
+{
+ "name": "release-prepare-tests",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "release-prepare-tests",
+ "devDependencies": {
+ "@actions/exec": "^1.1.1"
+ }
+ },
+ "node_modules/@actions/exec": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz",
+ "integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@actions/io": "^1.0.1"
+ }
+ },
+ "node_modules/@actions/io": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz",
+ "integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/actions/release/prepare/package.json b/actions/release/prepare/package.json
new file mode 100644
index 00000000..c5277706
--- /dev/null
+++ b/actions/release/prepare/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "release-prepare-tests",
+ "private": true,
+ "scripts": {
+ "test": "node --test",
+ "test:ci": "node --test"
+ },
+ "devDependencies": {
+ "@actions/exec": "^1.1.1"
+ }
+}
diff --git a/actions/release/verify/.node-version b/actions/release/verify/.node-version
new file mode 120000
index 00000000..7363229a
--- /dev/null
+++ b/actions/release/verify/.node-version
@@ -0,0 +1 @@
+../../../.node-version
\ No newline at end of file
diff --git a/actions/release/verify/README.md b/actions/release/verify/README.md
new file mode 100644
index 00000000..371d3a4d
--- /dev/null
+++ b/actions/release/verify/README.md
@@ -0,0 +1,78 @@
+
+
+#  GitHub Action: Verify release inputs
+
+
+

+
+
+---
+
+
+
+
+[](https://github.com/marketplace/actions/verify-release-inputs)
+[](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
+
+Verify the prepared npm package and package tarball artifact source before creating release state.
+
+
+
+
+## Inputs
+
+| **Input** | **Description** | **Required** | **Default** |
+| --------------------------------- | ----------------------------------------------------------------------------------------------- | ------------ | ----------- |
+| **`packages`** | JSON array containing one public root package from release preparation. | **true** | - |
+| **`package-tarball-artifact-id`** | Package tarball artifact ID from this run and prepared commit, regardless of the producing job. | **true** | - |
+| **`working-directory`** | Project directory relative to the repository root, matching release preparation. | **false** | `.` |
+
+
+
+
+
+
+## Outputs
+
+| **Output** | **Description** |
+| --------------- | -------------------------------------------------------- |
+| **`published`** | Whether this tag already has a published GitHub release. |
+
+
+
+
+## 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 © 2026 hoverkraft
+
+For more details, see the [license](http://choosealicense.com/licenses/mit/).
+
+
+
+
+---
+
+This documentation was automatically generated by [CI Dokumentor](https://github.com/hoverkraft-tech/ci-dokumentor).
+
+
diff --git a/actions/release/verify/action.yml b/actions/release/verify/action.yml
new file mode 100644
index 00000000..d93e6af7
--- /dev/null
+++ b/actions/release/verify/action.yml
@@ -0,0 +1,37 @@
+name: Verify release inputs
+description: Verify the prepared npm package and package tarball artifact source before creating release state.
+author: hoverkraft
+branding:
+ icon: check-circle
+ color: blue
+inputs:
+ packages:
+ description: JSON array containing one public root package from release preparation.
+ required: true
+ package-tarball-artifact-id:
+ description: Package tarball artifact ID from this run and prepared commit, regardless of the producing job.
+ required: true
+ working-directory:
+ description: Project directory relative to the repository root, matching release preparation.
+ default: .
+outputs:
+ published:
+ description: Whether this tag already has a published GitHub release.
+ value: ${{ steps.verify.outputs.published }}
+runs:
+ using: composite
+ steps:
+ - id: verify
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ ACTION_PATH: ${{ github.action_path }}
+ PACKAGES: ${{ inputs.packages }}
+ PACKAGE_ARTIFACT_ID: ${{ inputs.package-tarball-artifact-id }}
+ WORKING_DIRECTORY: ${{ inputs.working-directory }}
+ with:
+ script: |
+ const releaseVerification = require(`${process.env.ACTION_PATH}/index.js`);
+ const packages = releaseVerification.parsePackages(process.env.PACKAGES);
+ await releaseVerification.verifyArtifact({ github, context, artifactId: process.env.PACKAGE_ARTIFACT_ID });
+ const releases = await releaseVerification.verifyReleases({ github, context, packages, workingDirectory: process.env.WORKING_DIRECTORY });
+ core.setOutput('published', String(releases[0].published));
diff --git a/actions/release/verify/index.js b/actions/release/verify/index.js
new file mode 100644
index 00000000..deaea44e
--- /dev/null
+++ b/actions/release/verify/index.js
@@ -0,0 +1,116 @@
+const path = require("node:path");
+
+function versionFromTag(tag, directory, relativeDirectory) {
+ const slug = path
+ .basename(directory)
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-|-$/g, "");
+ const prefixes = ["", "v", ...(relativeDirectory ? [`${slug}-`] : [])];
+ const version = prefixes
+ .map((prefix) => (tag.startsWith(prefix) ? tag.slice(prefix.length) : ""))
+ .find((candidate) =>
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(candidate),
+ );
+ if (!version)
+ throw new Error(
+ "Expected a stable version tag, optionally prefixed with v or the project directory slug.",
+ );
+ return version;
+}
+
+function parsePackages(input, version) {
+ const packages = JSON.parse(input);
+ if (!Array.isArray(packages) || packages.length !== 1)
+ throw new Error("Expected exactly one public npm package.");
+ for (const item of packages) {
+ if (
+ !item ||
+ typeof item.name !== "string" ||
+ !/^(?:@[a-z0-9-]+\/)?[a-z0-9][a-z0-9._-]*$/.test(item.name) ||
+ item.slug !== item.name.replace(/^@/, "").replace("/", "-") ||
+ typeof item.version !== "string" ||
+ !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(item.version) ||
+ item.directory !== "."
+ )
+ throw new Error(
+ "Expected a valid package name, slug, stable version and root package directory.",
+ );
+ }
+ if (version && packages.some((item) => item.version !== version))
+ throw new Error("Package versions must match the prepared release tag.");
+ return packages;
+}
+
+async function findRelease(github, repo, tag) {
+ try {
+ const { data } = await github.rest.repos.getReleaseByTag({
+ ...repo,
+ tag,
+ });
+ return data;
+ } catch (error) {
+ if (error.status !== 404) throw error;
+ const releases = await github.paginate(github.rest.repos.listReleases, {
+ ...repo,
+ per_page: 100,
+ });
+ return releases.find((release) => release.tag_name === tag);
+ }
+}
+
+async function verifyReleases({
+ github,
+ context,
+ packages,
+ workingDirectory = ".",
+}) {
+ if (!context.ref.startsWith("refs/tags/"))
+ throw new Error("Publish from the prepared release tag.");
+ const sourceTag = context.ref.slice("refs/tags/".length);
+ const directory = path.resolve(
+ context.workspace || process.env.GITHUB_WORKSPACE,
+ workingDirectory,
+ );
+ const relativeDirectory = path.relative(
+ context.workspace || process.env.GITHUB_WORKSPACE,
+ directory,
+ );
+ if (relativeDirectory.startsWith("..") || path.isAbsolute(relativeDirectory))
+ throw new Error("Release directory must be inside the repository.");
+ const [item] = parsePackages(JSON.stringify(packages));
+ if (item.tag !== sourceTag)
+ throw new Error("Package tag must match the workflow source tag.");
+ if (versionFromTag(sourceTag, directory, relativeDirectory) !== item.version)
+ throw new Error("Package version must match the release tag.");
+ const { data: commit } = await github.rest.repos.getCommit({
+ ...context.repo,
+ ref: sourceTag,
+ });
+ if (commit.sha !== context.sha)
+ throw new Error("Release tag must identify the prepared source commit.");
+ const release = await findRelease(github, context.repo, sourceTag);
+ return [{ tag: sourceTag, published: Boolean(release && !release.draft) }];
+}
+
+async function verifyArtifact({ github, context, artifactId }) {
+ if (
+ !/^[1-9][0-9]*$/.test(artifactId) ||
+ !Number.isSafeInteger(Number(artifactId))
+ )
+ throw new Error("Expected one positive package tarball artifact ID.");
+ const { data: artifact } = await github.rest.actions.getArtifact({
+ ...context.repo,
+ artifact_id: Number(artifactId),
+ });
+ if (
+ artifact.expired ||
+ artifact.workflow_run?.id !== context.runId ||
+ artifact.workflow_run?.head_sha !== context.sha
+ )
+ throw new Error(
+ "Package tarball artifact must be unexpired and belong to this run and prepared commit.",
+ );
+}
+
+module.exports = { parsePackages, verifyReleases, verifyArtifact };
diff --git a/actions/release/verify/index.test.js b/actions/release/verify/index.test.js
new file mode 100644
index 00000000..6618b303
--- /dev/null
+++ b/actions/release/verify/index.test.js
@@ -0,0 +1,173 @@
+const assert = require("node:assert/strict");
+const { test } = require("node:test");
+const releaseVerification = require("./index.js");
+
+const context = {
+ repo: { owner: "example", repo: "project" },
+ runId: 42,
+ sha: "prepared-commit",
+};
+const item = {
+ name: "@example/cli",
+ slug: "example-cli",
+ version: "1.2.3",
+ directory: ".",
+ tag: "v1.2.3",
+};
+const artifact = {
+ id: 10,
+ expired: false,
+ workflow_run: { id: 42, head_sha: context.sha },
+};
+const verifyArtifact = (data = artifact, artifactId = "10") =>
+ releaseVerification.verifyArtifact({
+ context,
+ artifactId,
+ github: {
+ rest: {
+ actions: {
+ getArtifact: async ({ artifact_id }) => {
+ assert.equal(artifact_id, 10);
+ return { data };
+ },
+ },
+ },
+ },
+ });
+
+test("release verifies the source tag, committed version and publication state", async () => {
+ const github = {
+ rest: {
+ repos: {
+ getCommit: async () => ({ data: { sha: context.sha } }),
+ getReleaseByTag: async () => ({ data: { draft: false } }),
+ },
+ },
+ };
+ const verify = (
+ changes = {},
+ ref = `refs/tags/${item.tag}`,
+ workingDirectory = ".",
+ ) =>
+ releaseVerification.verifyReleases({
+ github,
+ context: { ...context, workspace: "/tmp/project", ref },
+ packages: [{ ...item, ...changes }],
+ workingDirectory,
+ });
+ assert.deepEqual(await verify(), [{ tag: item.tag, published: true }]);
+ github.rest.repos.getReleaseByTag = async () => ({ data: { draft: true } });
+ assert.equal((await verify())[0].published, false);
+ github.rest.repos.getReleaseByTag = async () => {
+ throw Object.assign(new Error("Not found"), { status: 404 });
+ };
+ github.paginate = async () => [{ tag_name: item.tag, draft: true }];
+ assert.equal((await verify())[0].published, false);
+ github.paginate = async () => [];
+ assert.equal((await verify())[0].published, false);
+ github.rest.repos.getReleaseByTag = async () => {
+ throw Object.assign(new Error("Forbidden"), { status: 403 });
+ };
+ await assert.rejects(verify(), /Forbidden/);
+
+ await assert.rejects(verify({}, "refs/heads/main"), /prepared release tag/);
+ await assert.rejects(verify({ tag: "v2.0.0" }), /source tag/);
+ await assert.rejects(verify({ version: "1.2.4" }), /match the release tag/);
+ await assert.rejects(
+ verify({}, `refs/tags/${item.tag}`, ".."),
+ /inside the repository/,
+ );
+ github.rest.repos.getCommit = async () => ({ data: { sha: "other-commit" } });
+ await assert.rejects(verify(), /prepared source commit/);
+});
+
+test("release accepts a package tarball artifact from this run and prepared commit, including reruns", async () => {
+ await assert.doesNotReject(verifyArtifact());
+});
+
+test("rejects expired artifacts, other runs, older commits and unavailable source identity", async () => {
+ for (const data of [
+ { ...artifact, expired: true },
+ { ...artifact, workflow_run: { id: 41, head_sha: context.sha } },
+ { ...artifact, workflow_run: { id: 42, head_sha: "old" } },
+ { ...artifact, workflow_run: undefined },
+ ]) {
+ await assert.rejects(verifyArtifact(data), /this run and prepared commit/);
+ }
+});
+
+test("rejects missing, multiple or invalid package tarball artifact IDs before downloading", async () => {
+ for (const id of ["", "0", "1,2", "NaN", "-1", "9007199254740992"]) {
+ await assert.rejects(
+ verifyArtifact(artifact, id),
+ /positive package tarball artifact ID/,
+ );
+ }
+});
+
+test("missing or inaccessible package tarball artifacts stop release", async () => {
+ const github = {
+ rest: {
+ actions: {
+ getArtifact: async () => {
+ throw new Error("Artifact not found");
+ },
+ },
+ },
+ };
+ await assert.rejects(
+ releaseVerification.verifyArtifact({ github, context, artifactId: "10" }),
+ /Artifact not found/,
+ );
+});
+
+test("release accepts one prepared npm package with its prepared identity", () => {
+ assert.deepEqual(
+ releaseVerification.parsePackages(JSON.stringify([item]), item.version),
+ [item],
+ );
+});
+
+test("rejects ambiguous names, unsafe paths, mismatched versions and invalid package lists", () => {
+ for (const packages of [
+ [],
+ {},
+ [null],
+ [item, item],
+ [{ ...item, directory: "packages/cli" }],
+ [{ ...item, slug: "wrong" }],
+ [{ ...item, name: "../bad" }],
+ [{ ...item, directory: "../outside" }],
+ [{ ...item, directory: "/tmp" }],
+ [{ ...item, version: "1.2.3-beta" }],
+ [{ ...item, version: "1.2.2" }],
+ ]) {
+ assert.throws(
+ () =>
+ releaseVerification.parsePackages(
+ JSON.stringify(packages),
+ item.version,
+ ),
+ /Expected|unique|versions/,
+ );
+ }
+});
+
+test("verifies tags derived from nested project directories containing punctuation", async () => {
+ const tag = "my-package-1.2.3";
+ const releases = await releaseVerification.verifyReleases({
+ context: { ...context, workspace: "/tmp/project", ref: `refs/tags/${tag}` },
+ packages: [{ ...item, tag }],
+ workingDirectory: " ---My Package--- ",
+ github: {
+ rest: {
+ repos: {
+ getCommit: async () => ({ data: { sha: context.sha } }),
+ getReleaseByTag: async () => ({ data: { draft: true } }),
+ },
+ },
+ },
+ });
+ assert.equal(releases[0].tag, tag);
+ assert.equal(releases[0].published, false);
+});
diff --git a/actions/release/verify/package-lock.json b/actions/release/verify/package-lock.json
new file mode 100644
index 00000000..1502f7a6
--- /dev/null
+++ b/actions/release/verify/package-lock.json
@@ -0,0 +1,10 @@
+{
+ "name": "release-verify-tests",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "release-verify-tests"
+ }
+ }
+}
diff --git a/actions/release/verify/package.json b/actions/release/verify/package.json
new file mode 100644
index 00000000..91134a23
--- /dev/null
+++ b/actions/release/verify/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "release-verify-tests",
+ "private": true,
+ "scripts": {
+ "test": "node --test",
+ "test:ci": "node --test"
+ }
+}
diff --git a/docs/README.md b/docs/README.md
index 50bdd3e4..87b9fd77 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -1,3 +1,4 @@
# Documentation
- [Maintain dependency updates](development/dependency-updates.md)
+- [Release orchestration decision](adr/0001-release-orchestration.md)
diff --git a/docs/adr/0001-release-orchestration.md b/docs/adr/0001-release-orchestration.md
new file mode 100644
index 00000000..0b67a780
--- /dev/null
+++ b/docs/adr/0001-release-orchestration.md
@@ -0,0 +1,299 @@
+# ADR 0001: Shared Node.js release orchestration
+
+- Status: accepted
+- Initial decision: 2026-10-29
+- Consolidated: 2026-11-03
+
+## Context
+
+The Package and Publish actions handle individual tarballs. Consumers still need
+version preparation, CI on the versioned source, release notes and recovery from
+partial publication. Reactionable initially supplied custom scripts for those
+responsibilities, while reactionable-cli used Release Please. HoverKraft already
+provides Conventional Commit release planning and GitHub publication actions.
+
+Consumers need one shared release composition with clear ownership: preparation
+changes versions, CI builds, packages and tests, and release publishes. Build
+output can embed a version, so CI must run after the version commit. Consumers
+should configure their existing commands and permissions without writing release
+orchestration scripts.
+
+## Decision
+
+### Composition and delivery scope
+
+Expose **prepare -> validate -> release** as separate stages connected by the caller
+through `needs`. Use the shared CI workflow as the recommended package validation producer,
+while accepting any job that supplies the required artifact. Consumers own manual and scheduled triggers, CI policy,
+credentials, environment rules and optional documentation deployment. Keep the
+individual actions available for custom composition.
+
+Release has no GitHub environment by default. Callers may pass `environment` to
+apply environment approvals, secrets and deployment restrictions. An npm trusted
+publisher's optional environment restriction must match that input when configured.
+
+The reusable preparation workflow owns GitHub App token creation and cleanup. It
+accepts a `github-app-client-id` input and `github-app-key` workflow secret,
+requesting only the current repository and required permissions. Preparation has
+no environment binding or implicit variable lookup; callers map their credential
+names explicitly. Without App credentials, use `GITHUB_TOKEN`. Consumers do not need
+a token-generation job or duplicated preparation steps.
+
+Preparation exposes only the changing `status` and `packages` needed by callers.
+Release uses the native job result for dependent jobs; it has no constant success
+output or unused tag/URL outputs. Verification validates the supplied package list
+without returning a second copy. Finalization uses `GITHUB_TOKEN` and the shared
+`RUNNER_TEMP/release-assets` directory rather than accepting unused overrides.
+
+[PR #562](https://github.com/hoverkraft-tech/ci-github-nodejs/pull/562) delivers the
+common case: one public npm package with its own lockfile and one sequential release
+job. Unsupported layouts fail early. Workspace selection, fixed/independent Lerna,
+per-package notes and matrix recovery are delivered separately in
+[PR #563](https://github.com/hoverkraft-tech/ci-github-nodejs/pull/563), extending this
+same record and workflow composition. The common release tests need only Node.js
+and npm; specialized tool dependencies belong to the extension.
+
+Name the source preparation workflow `prepare-package-release.yml` to distinguish
+it from `ci-github-publish/prepare-release.yml`, which manages pull request labels.
+Keep orchestration visible in action steps and short scripts inline. Each action
+owns its substantial JavaScript implementation and colocated tests: verification
+belongs to `release/verify`, npm recovery to `package/restore`, and asset handling to
+`release/finalize`. `package/publish` reuses the `package/restore` action. Share only package/tag
+rules and GitHub release lookup, which have multiple consumers. Specialized
+package preparation belongs to `prepare` in the workspace extension.
+
+### Version preparation and release notes
+
+Use the public `ci-github-publish/release/plan` action as the version authority for
+the common release. npm applies the exact planned stable version to the manifest
+and lockfile with lifecycle scripts disabled. Accept bare, `v`-prefixed and
+directory-scoped tags. Reject older versions, conflicting tags and inconsistent
+release history instead of calculating another version locally. Allow an already-
+committed planned version when its release tag does not exist.
+
+Create version commits through GitHub's API without custom author, committer or
+signature fields, so GitHub signs them for the authenticated App or bot. Require
+a verified signature, fetch the commit and atomically push it with the tag.
+Check the expected branch head with an explicit lease to reject concurrent changes.
+If version application leaves no changes, tag the existing source without an empty
+commit. Dispatch the caller at that tag.
+A no-change plan creates neither. The tag run starts building and publication without
+planning again. This gives checkout, compiled output, source archives and provenance
+the same committed identity. A concurrent branch update fails the atomic push.
+The preparation identity must satisfy branch and tag rules; preparation neither
+creates release PRs nor bypasses protections.
+
+Keep release notes on GitHub. Preparation never creates or modifies `CHANGELOG.md`,
+including existing files. Use `release/create` for the draft and optional AI summary,
+and `release/update` for missing assets and final publication. Preserve caller release
+configuration and published release notes. Do not use Release Please or the internal
+`release/prepare` helper for source changelog rendering.
+
+Expose upstream summary configuration through a `changelog-summary` workflow input
+and secret. A non-empty secret takes precedence; forward the complete value unchanged
+to `release/create`. Let that action own provider support, defaults and validation.
+An empty configuration disables summaries. Do not add provider-specific credentials
+or an enablement flag to the Node.js workflow.
+
+### Build artifacts and packaging
+
+Preparation commits versions before validation. CI builds, runs source tests,
+packs the distributable and optionally runs consumer checks against that tarball.
+The existing CI workflow adds an opt-in `package` job and a
+`package-tarball-artifact-id` output. General applications keep packaging disabled.
+`package-tests` belongs to CI and has no publication credentials or environment.
+It derives package identity from the tarball and rejects changes to its bytes.
+A publication dry run also runs in CI. The standard packaging job supports the
+non-container Ubuntu artifact layout; custom producers can compose the actions.
+
+Release consumes the resulting tarball artifact without inspecting the producing
+job's identity. Callers require successful validation through `needs`; artifact
+metadata alone cannot prove that checks passed. Release verifies that the artifact
+is unexpired and belongs to the same run and prepared source SHA, and checks its
+name and version against preparation. It never rebuilds, repacks or executes
+caller-provided tests. Optional AI summaries finish before npm publication.
+
+Package creation uses native manager commands with lifecycle scripts disabled.
+Files normally generated by `prepare` or `prepack` must be produced before packing.
+A retry reuses the validated artifact, or recovers the original npm bytes when the
+version is already published. Source version, tested package and published package
+remain aligned.
+
+### Publication and recovery
+
+Serialize the whole pipeline in the caller, with distinct reusable-workflow
+concurrency groups. Publication checks npm on every attempt. If a version is already
+published, restore its original registry tarball and verify its SHA-512 integrity.
+Verify SHA-256 digests for existing GitHub assets and stage only missing assets for
+`release/update`; conflicting assets or missing digests fail without overwrites.
+
+A failed publication-job rerun reuses the validated package artifact from the same run. A new
+dispatch at the original tag rebuilds and resumes that version. Expired artifacts
+fail explicitly; cross-run lookup and CI polling are excluded. Preparation success
+is separate from publication success, and a tag can remain after a later failure.
+Documentation deployment stays in a separate caller job after successful publication,
+using the build artifact.
+
+### Runtime maintenance
+
+Use the root `.node-version` as the fixture runtime authority. Fixture version files
+are symlinks; `.nvmrc` tests and runtime assertions read the same value. Keep a
+compatible explicit version rather than independent fixture pins.
+
+Adopt scheduled/manual Renovate with CI GitHub App authentication following the
+[ci-github-common workflow](https://github.com/hoverkraft-tech/ci-github-common/blob/fb0cdfb5418de834dcb8c3abba33182aa92062c5/.github/workflows/__renovate.yml).
+Dependabot remains the primary updater. Renovate handles `docker://` action
+references and the root `.node-version` through its native nodenv manager. Runtime
+updates require review and passing CI, without automerge. Configuration and operation
+are documented in the [dependency update guide](../development/dependency-updates.md).
+
+## Consequences
+
+Consumers need no custom release scripts, but must align committed versions, tags,
+GitHub releases and npm history, authorize preparation pushes, configure npm trusted
+publishing and allow release tags in their environments. The shared workflows cannot
+repair inconsistent history or grant permissions; see the
+[readiness guide](../../.github/workflows/release.md#establish-the-release-baseline).
+
+Support stable public npm packages on GitHub.com and npmjs.org with the default
+GitHub-hosted Ubuntu layout for the optional CI packaging job, without a build container. Custom registries,
+prereleases and other release products remain custom compositions. Absolute build
+paths constrain artifact restoration to that supported runner layout.
+
+The release workflow preserves successful publication when a later step fails.
+Review AI summaries for accuracy. The [release guide](../../.github/workflows/release.md) describes
+operation and recovery; the [validation guide](../../.github/workflows/release.md#contributor-validation)
+describes local tests and GitHub integration checks. These tests do not perform live
+npm/GitHub publication or AI generation.
+
+## Decision history and dated evidence
+
+This record consolidates the original release ADRs. The decisions above are current;
+the alternatives and observations below preserve their history, not new validation.
+
+- **2026-10-29:** The initial design coupled CI to release, used Release Please for
+ a single package and native fixed Lerna preparation for workspaces. The release
+ guide at commit `b7eadcff0bdbd690f4688e7ce3f31d7ba0e39865` established committing
+ versions before building and targeted recovery. The removed 0.25 publish workflow
+ was not restored: the new composition covers preparation and the release lifecycle.
+- **2026-10-31:** CI policy was separated from publication. Release Please's PR
+ lifecycle was replaced with HoverKraft planning and atomic version/tag pushes;
+ the upstream review used `ci-github-publish` 1.0.3 at commit
+ `ea6682de88070c254dac619a6623a242a6d57cd3`. An intermediate design kept compilation
+ in release, then moved compilation and packaging into CI to bind checked artifacts
+ to the prepared source. The artifact identity and retry contracts were retained.
+- **2026-11-02:** Packaging moved back to release while compilation stayed in CI,
+ removing release-specific package inputs from CI. The
+ [npm pack lifecycle documentation](https://docs.npmjs.com/cli/v11/commands/npm-pack/#ignore-scripts)
+ informed disabling packaging hooks; fixtures with failing hooks and unavailable
+ dependencies were used to detect accidental builds or installs.
+- **2026-11-03:** Delivery was split into the common package and workspace extension.
+ Source changelog generation was removed, eliminating the internal HoverKraft notes
+ renderer and retaining GitHub release notes. Per-fixture runtime pins introduced
+ for Gatsby compatibility were replaced with the shared root version and reviewed
+ Renovate updates. The compatibility finding at that time was Gatsby's exclusion
+ of Node 26; it is historical evidence, not a permanent runtime constraint.
+
+- **2026-11-03, consumer validation:** The CLI planned `1.3.0`, already committed
+ but unpublished, and required signed commits on its default branch. This
+ supersedes rejection of equal versions and local unsigned release commits:
+ accept equal plans without empty commits, and use verified GitHub API bot
+ commits before the existing atomic push. App installation and branch-rule
+ authorization remain consumer responsibilities. Local Git-remote tests cover
+ atomic failure behavior; live App authentication requires configured credentials.
+- **2026-11-03, consumer authentication:** Keep version commits on the default
+ branch so the manifest and release tag remain aligned. The consumer uses an
+ authorized GitHub App; token creation moves into the reusable preparation
+ workflow to keep the caller declarative. Tag-only version commits and automated
+ version-update PRs were considered but not adopted.
+- **2026-11-03, preparation interface:** Explicit `github-app-client-id` and
+ `github-app-key` parameters supersede the initial implicit `CI_BOT_APP_CLIENT_ID`
+ lookup and environment-bound preparation credentials. Use the same parameter
+ names as `ci-github-publish/release-actions.yml`. Consumers own credential storage;
+ publication retains its environment for npm trusted publishing.
+- **2026-11-03, summary configuration:** Pass upstream `changelog-summary` JSON
+ through unchanged, superseding the initial OpenAI-only `ai-summary` flag and
+ `OPENAI_API_KEY` workflow secret. Callers select their provider and its options;
+ the same-named workflow secret takes precedence over the input.
+- **2026-11-03, artifact producer contract:** Release accepts a build artifact from
+ any job meeting its run, commit, expiration and path requirements. This supersedes
+ wording that required a CI job. Shared CI remains a recommended producer; callers
+ choose their build job and checks. Artifact verification behavior is unchanged.
+- **2026-11-03, optional release environment:** An empty `environment` default
+ supersedes the automatic `release` binding. npm trusted publishing does not
+ require an environment; callers explicitly select one when they need its
+ protections or secrets. Existing callers with an explicit environment retain it.
+- **2026-11-03, interface audit:** Traced the changed manifests through their
+ implementation, workflow/action callers, authored examples and the CLI consumer.
+ Generated usage examples were excluded as evidence of a real caller. Removed
+ unconsumed preparation tag/published outputs, the unused preparation token
+ override, constant release status and unused tag/URL outputs, verification's
+ pass-through package/tag outputs, and finalization's fixed directory/token
+ parameters. The setup-node package-manager output belongs only to the workspace
+ extension, where packaging selects npm or pnpm.
+ `prebuilt`, `skip-existing`, installation/script flags and smoke commands have
+ distinct execution paths. The requested environment and summary interfaces are
+ retained. Existing standalone Package `version` and Publish `registry-url` and
+ `github-token` have no non-default caller in the inspected repository; this is
+ limited usage evidence, not proof that their published interfaces are unused
+ downstream. Project-directory options remain part of supported nested projects.
+- **2026-11-04, package action grouping:** Group tarball operations under
+ `actions/package/create`, `publish`, `restore` and `test`. This intentionally
+ replaces `actions/package`, `actions/publish`, `actions/release/restore-package`
+ and `actions/release/test-package` without compatibility aliases. Release
+ orchestration stays under `actions/release`. Move the existing private test
+ package to `actions/` so Node's test discovery covers both groups, with tests
+ remaining beside their implementations and the runtime linked to `.node-version`.
+- **2026-11-04, installation script policy:** Default `ignore-scripts` to true in
+ setup, build, lint, test, package creation and the reusable CI workflow. Consumers
+ can opt in to installation hooks with false, subject to package-manager approval
+ settings. This is an intentional breaking default for projects relying on those
+ hooks. Explicit commands and ordinary version/pack hooks retain their behavior;
+ preparation and prebuilt packaging keep scripts disabled regardless of the opt-in.
+- **2026-11-04, GitHub Script helpers:** Use the `core`, `exec`, `io` and `glob`
+ helpers supplied by `actions/github-script` for workflow logging, commands,
+ file operations and discovery. Pass the required helpers explicitly to action
+ modules. Preserve binary Git blobs through exec's buffer listeners and stage
+ the exact bytes verified for release assets. Node APIs remain for file contents,
+ hashing and filesystem checks without a toolkit equivalent. Direct tests use
+ the real exec/glob toolkit packages as development dependencies; the actions
+ continue using GitHub Script's bundled helpers without a runtime install.
+- **2026-11-04, standalone actions:** Each tested action owns its private test
+ manifest, lockfile and dependencies. This supersedes the shared `actions/` test
+ package introduced by package action grouping. Keep package validation and
+ release lookup inside each consuming action, accepting small local duplication
+ to avoid imports from parent or sibling action directories. Tests run separately
+ per action; workspace tools belong only to preparation in the workspace extension.
+ Per-action runtime symlinks still point directly to the root `.node-version`.
+- **2026-11-04, package creation responsibility:** Package prepared files with
+ lifecycle scripts disabled. Remove the `prebuilt`, `version` and `ignore-scripts`
+ inputs from `package/create`, along with checkout and runtime/dependency setup.
+ This supersedes its ordinary/prebuilt mode split and version override. Callers
+ check out the intended source and configure tooling before packaging; pnpm
+ workspace linking belongs to the release workflow. Artifact restoration remains
+ optional, so local build output is preserved and can be packed in the same job.
+- **2026-11-04, native packaging commands:** `get-package-manager` provides a
+ `pack-command` output for npm, pnpm and Yarn Classic, with lifecycle scripts
+ disabled. The Bash command receives its output directory through the quoted
+ `PACK_DESTINATION` environment variable. `package/create` runs that command
+ through GitHub Script's `exec` helper, discovers one tarball in an isolated
+ temporary folder and cleans the folder on success or failure. This replaces
+ npm-only packing and manager-specific console-output parsing without adding
+ package-manager selection inputs or a shared JavaScript module. Modern Yarn
+ packing is outside this script-free contract because its pack hooks cannot be
+ disabled through the Classic command's flags.
+
+- **2026-11-04, CI package validation:** Supersede the earlier decision to pack and
+ test inside publication. Package creation, consumer checks and publication dry
+ runs now belong to an optional CI job. Release accepts its tarball artifact and
+ retains source/identity checks and retry recovery. This avoids testing a
+ different package from the one published and removes consumer commands from
+ the job with publication credentials. The PR CI entry point remains unchanged.
+- **2026-11-04, recovery input naming:** Rename Publish's `skip-existing` input to
+ `recover-existing` to describe restoring original registry bytes when resuming a
+ release. Keep the default false for standalone publication and enable recovery
+ in release workflows. Registry and integrity failures remain errors.
+- **2026-11-04, explicit release asset handoff:** Pass the published or recovered
+ tarball path and its asset name directly to finalization. This supersedes the
+ implicit `RUNNER_TEMP/release-assets` contract and the workflow's extra copy.
+ Finalization stages missing bytes once, preserving digest checks on retries.
diff --git a/docs/development/dependency-updates.md b/docs/development/dependency-updates.md
index 04d1713a..01019f33 100644
--- a/docs/development/dependency-updates.md
+++ b/docs/development/dependency-updates.md
@@ -24,7 +24,7 @@ bot identity, allowing normal PR checks to run.
Renovate waits seven days for releases with a known publication timestamp. It
proposes Node and pnpm fallback updates for review without automerging them. Review compatibility
-and the package-manager integration checks before merging a runtime
+and the package-manager and release integration checks before merging a runtime
upgrade; a newer major version may exceed a fixture dependency's supported range.
When changing the configuration, validate it with the Renovate CLI:
diff --git a/scripts/test-packages.sh b/scripts/test-packages.sh
index 94a73006..35973ab2 100755
--- a/scripts/test-packages.sh
+++ b/scripts/test-packages.sh
@@ -134,7 +134,7 @@ run_audit_fix_for_pm() {
}
run_for_each_test_package() {
- packages="$(find tests -type f -name package.json -not -path '*/node_modules/*' -print | sort)"
+ packages="$(find tests actions -type f -name package.json -not -path '*/node_modules/*' -print | sort)"
for pkg in $packages; do
pkg_dir="$(dirname "$pkg")"
@@ -167,13 +167,13 @@ run_for_each_test_package() {
case "$action" in
install)
- echo "Installing dependencies for package.json files under tests/ ..."
+ echo "Installing dependencies for package.json files under tests/ and actions/ ..."
;;
update-interactive)
- echo "Running interactive dependency updates for package.json files under tests/ ..."
+ echo "Running interactive dependency updates for package.json files under tests/ and actions/ ..."
;;
audit-fix)
- echo "Running dependency audit/fix for package.json files under tests/ ..."
+ echo "Running dependency audit/fix for package.json files under tests/ and actions/ ..."
;;
*)
echo "Unsupported action: $action" >&2