From 07ae5bd415f23fdabcc55da547f16de9aaaaa8df Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 4 Dec 2025 19:25:35 +0000
Subject: [PATCH 1/6] Initial plan
From e773dc8a3cc3ab50a5411d0a48e34f7f9c9ea134 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 4 Dec 2025 19:42:26 +0000
Subject: [PATCH 2/6] feat(workflows): add release.yml reusable workflow and
documentation
Co-authored-by: neilime <314088+neilime@users.noreply.github.com>
---
.github/workflows/release.md | 384 +++++++++++++++++++++++++
.github/workflows/release.yml | 517 ++++++++++++++++++++++++++++++++++
README.md | 4 +
3 files changed, 905 insertions(+)
create mode 100644 .github/workflows/release.md
create mode 100644 .github/workflows/release.yml
diff --git a/.github/workflows/release.md b/.github/workflows/release.md
new file mode 100644
index 0000000..82b423f
--- /dev/null
+++ b/.github/workflows/release.md
@@ -0,0 +1,384 @@
+
+
+# GitHub Reusable Workflow: Node.js Release
+
+
+

+
+
+---
+
+
+
+
+
+[](https://github.com/hoverkraft-tech/ci-github-nodejs/releases)
+[](http://choosealicense.com/licenses/mit/)
+[](https://img.shields.io/github/stars/hoverkraft-tech/ci-github-nodejs?style=social)
+[](https://github.com/hoverkraft-tech/ci-github-nodejs/blob/main/CONTRIBUTING.md)
+
+
+
+
+
+## Overview
+
+Workflow to release Node.js packages with support for:
+
+- Building the package before publishing
+- Generating documentation (optional)
+- Publishing to various registries (npm, GitHub Packages)
+- Provenance attestation for npm packages
+- Distribution tags for versioning
+
+### Permissions
+
+- **`contents`**: `read`
+- **`id-token`**: `write` (required for provenance)
+- **`packages`**: `write`
+
+
+
+
+
+## Usage
+
+```yaml
+name: Release
+
+on:
+ release:
+ types: [published]
+
+permissions: {}
+
+jobs:
+ release:
+ uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/release.yml@main
+ permissions:
+ contents: read
+ packages: write
+ id-token: write
+ secrets:
+ # Authentication token for the registry.
+ # For npm: Use an npm access token with publish permissions.
+ # For GitHub Packages: Use `GITHUB_TOKEN` or a PAT with `packages:write` permission.
+ registry-token: ${{ secrets.NPM_TOKEN }}
+
+ # Secrets to be used during the build step (optional).
+ build-secrets: ""
+ with:
+ # JSON array of runner(s) to use.
+ # Default: `["ubuntu-latest"]`
+ runs-on: '["ubuntu-latest"]'
+
+ # Build parameters. Set to empty string to disable.
+ # Default: `build`
+ build: build
+
+ # Documentation generation parameters.
+ # Set to empty string or `false` to disable.
+ docs: ""
+
+ # Registry configuration.
+ # Use `npm`, `github`, or a URL/JSON object.
+ # Default: `npm`
+ registry: npm
+
+ # Command to run for publishing.
+ # Default: `publish`
+ publish-command: publish
+
+ # npm distribution tag.
+ # Default: `latest`
+ tag: latest
+
+ # Whether to perform a dry run.
+ # Default: `false`
+ dry-run: false
+
+ # Whether to generate provenance attestation.
+ # Default: `true`
+ provenance: true
+
+ # Working directory where the package is located.
+ # Default: `.`
+ working-directory: .
+```
+
+
+
+
+
+
+
+## Inputs
+
+### Workflow Call Inputs
+
+| **Input** | **Description** | **Required** | **Type** | **Default** |
+| ----------------------- | ---------------------------------------------------------------------------------- | ------------ | ----------- | ------------------- |
+| **`runs-on`** | JSON array of runner(s) to use. | **false** | **string** | `["ubuntu-latest"]` |
+| | See . | | | |
+| **`build`** | Build parameters. Must be a string or a JSON object. | **false** | **string** | `build` |
+| | Set to empty string to disable build step. | | | |
+| | For string, provide a list of commands to run during the build step, one per line. | | | |
+| | For JSON object, provide `commands` array and optional `env` object. | | | |
+| **`docs`** | Documentation generation parameters. | **false** | **string** | - |
+| | Set to empty string or `false` to disable. | | | |
+| | Set to `true` for default command (`docs`). | | | |
+| | Accepts JSON object with `command`, `output`, and `artifact` properties. | | | |
+| **`registry`** | Registry configuration for package publishing. | **false** | **string** | `npm` |
+| | Supported values: `npm`, `github`, URL, or JSON object with `url` and `scope`. | | | |
+| **`publish-command`** | Command to run for publishing the package. | **false** | **string** | `publish` |
+| | Defaults to `publish` which runs `npm publish` or equivalent. | | | |
+| **`tag`** | npm distribution tag for the published package. | **false** | **string** | `latest` |
+| | Common values: `latest`, `next`, `canary`. | | | |
+| **`dry-run`** | Whether to perform a dry run (no actual publish). | **false** | **boolean** | `false` |
+| **`provenance`** | Whether to generate provenance attestation for the published package. | **false** | **boolean** | `true` |
+| | Requires npm 9.5.0+ and appropriate permissions. | | | |
+| **`working-directory`** | Working directory where the package is located. | **false** | **string** | `.` |
+
+
+
+
+
+
+
+## Secrets
+
+| **Secret** | **Description** | **Required** |
+| -------------------- | ---------------------------------------------------------------------------------- | ------------ |
+| **`registry-token`** | Authentication token for the registry. | **true** |
+| | For npm: Use an npm access token with publish permissions. | |
+| | For GitHub Packages: Use `GITHUB_TOKEN` or a PAT with `packages:write` permission. | |
+| **`build-secrets`** | Secrets to be used during the build step. | **false** |
+| | Must be a multi-line env formatted string. | |
+
+
+
+
+
+## Outputs
+
+| **Output** | **Description** |
+| ---------------------- | ----------------------------------------------- |
+| **`version`** | The version of the published package. |
+| **`package-name`** | The name of the published package. |
+| **`docs-artifact-id`** | ID of the documentation artifact (if uploaded). |
+
+
+
+
+
+## Examples
+
+### Basic Release to npm
+
+```yaml
+name: Release to npm
+
+on:
+ release:
+ types: [published]
+
+permissions: {}
+
+jobs:
+ release:
+ uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/release.yml@main
+ permissions:
+ contents: read
+ packages: write
+ id-token: write
+ secrets:
+ registry-token: ${{ secrets.NPM_TOKEN }}
+```
+
+### Release to GitHub Packages
+
+```yaml
+name: Release to GitHub Packages
+
+on:
+ release:
+ types: [published]
+
+permissions: {}
+
+jobs:
+ release:
+ uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/release.yml@main
+ permissions:
+ contents: read
+ packages: write
+ id-token: write
+ secrets:
+ registry-token: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ registry: github
+ provenance: false
+```
+
+### Release with Documentation
+
+```yaml
+name: Release with Documentation
+
+on:
+ release:
+ types: [published]
+
+permissions: {}
+
+jobs:
+ release:
+ uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/release.yml@main
+ permissions:
+ contents: read
+ packages: write
+ id-token: write
+ secrets:
+ registry-token: ${{ secrets.NPM_TOKEN }}
+ with:
+ docs: |
+ {
+ "command": "build:docs",
+ "output": "docs-dist",
+ "artifact": true
+ }
+```
+
+### Prerelease with Next Tag
+
+```yaml
+name: Pre-release
+
+on:
+ release:
+ types: [prereleased]
+
+permissions: {}
+
+jobs:
+ release:
+ uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/release.yml@main
+ permissions:
+ contents: read
+ packages: write
+ id-token: write
+ secrets:
+ registry-token: ${{ secrets.NPM_TOKEN }}
+ with:
+ tag: next
+```
+
+### Custom Registry
+
+```yaml
+name: Release to Custom Registry
+
+on:
+ release:
+ types: [published]
+
+permissions: {}
+
+jobs:
+ release:
+ uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/release.yml@main
+ permissions:
+ contents: read
+ packages: write
+ id-token: write
+ secrets:
+ registry-token: ${{ secrets.CUSTOM_REGISTRY_TOKEN }}
+ with:
+ registry: |
+ {
+ "url": "https://my-registry.example.com",
+ "scope": "@myorg"
+ }
+ provenance: false
+```
+
+### Dry Run for Testing
+
+```yaml
+name: Test Release
+
+on:
+ workflow_dispatch:
+
+permissions: {}
+
+jobs:
+ test-release:
+ uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/release.yml@main
+ permissions:
+ contents: read
+ packages: write
+ id-token: write
+ secrets:
+ registry-token: ${{ secrets.NPM_TOKEN }}
+ with:
+ dry-run: true
+```
+
+### Monorepo Package Release
+
+```yaml
+name: Release Package
+
+on:
+ release:
+ types: [published]
+
+permissions: {}
+
+jobs:
+ release:
+ uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/release.yml@main
+ permissions:
+ contents: read
+ packages: write
+ id-token: write
+ secrets:
+ registry-token: ${{ secrets.NPM_TOKEN }}
+ with:
+ working-directory: packages/my-package
+ build: |
+ {
+ "commands": ["build"],
+ "env": {
+ "NODE_ENV": "production"
+ }
+ }
+```
+
+
+
+
+
+## Contributing
+
+Contributions are welcome! Please see the [contributing guidelines](https://github.com/hoverkraft-tech/ci-github-nodejs/blob/main/CONTRIBUTING.md) for more details.
+
+
+
+
+
+
+
+
+## License
+
+This project is licensed under the MIT License.
+
+SPDX-License-Identifier: MIT
+
+Copyright © 2025 hoverkraft-tech
+
+For more details, see the [license](http://choosealicense.com/licenses/mit/).
+
+
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..12b2273
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,517 @@
+# Workflow to release Node.js packages:
+#
+# - Build (optional)
+# - Generate documentation (optional)
+# - Publish to registry (npm, GitHub Packages)
+
+name: Node.js Release
+
+on:
+ workflow_call:
+ inputs:
+ runs-on:
+ description: |
+ JSON array of runner(s) to use.
+ See https://docs.github.com/en/actions/using-jobs/choosing-the-runner-for-a-job.
+ type: string
+ default: '["ubuntu-latest"]'
+ required: false
+ build:
+ description: |
+ Build parameters. Must be a string or a JSON object.
+ Set to empty string to disable build step.
+ For string, provide a list of commands to run during the build step, one per line.
+ For JSON object, provide the following properties:
+
+ - `commands`: Array of commands to run during the build step.
+ - `env`: Object of environment variables to set during the build step.
+
+ Example:
+ ```json
+ {
+ "commands": [
+ "build",
+ "generate-artifacts"
+ ],
+ "env": {
+ "NODE_ENV": "production"
+ }
+ }
+ ```
+ type: string
+ required: false
+ default: "build"
+ docs:
+ description: |
+ Documentation generation parameters.
+ Set to empty string or `false` to disable documentation generation.
+ Set to `true` or a string to enable documentation generation with the default command `docs`.
+ Accepts a JSON object for advanced options:
+
+ - `command`: Command to run for documentation generation (default: `docs`).
+ - `output`: Output directory for documentation (default: `docs`).
+ - `artifact`: Whether to upload documentation as an artifact (default: `false`).
+
+ Example:
+ ```json
+ {
+ "command": "build:docs",
+ "output": "docs-dist",
+ "artifact": true
+ }
+ ```
+ type: string
+ required: false
+ default: ""
+ registry:
+ description: |
+ Registry configuration for package publishing.
+ Accepts a string for the registry URL or a JSON object for advanced options.
+
+ Supported registries:
+ - `npm` or `https://registry.npmjs.org` — npm public registry (default)
+ - `github` or `https://npm.pkg.github.com` — GitHub Packages
+
+ JSON object format:
+ - `url`: Registry URL
+ - `scope`: Package scope (e.g., `@myorg`). Defaults to repository owner for GitHub Packages.
+
+ Example:
+ ```json
+ {
+ "url": "https://npm.pkg.github.com",
+ "scope": "@myorg"
+ }
+ ```
+ type: string
+ required: false
+ default: "npm"
+ publish-command:
+ description: |
+ Command to run for publishing the package.
+ Defaults to `publish` which runs `npm publish` or equivalent for the detected package manager.
+ Can be customized for monorepo setups or specific publish requirements.
+
+ Examples:
+ - `publish` — Default npm/pnpm/yarn publish
+ - `release` — Custom publish script in package.json
+ - `publish --access public` — Publish with specific npm flags
+ type: string
+ required: false
+ default: "publish"
+ tag:
+ description: |
+ npm distribution tag for the published package.
+ Common values:
+ - `latest` — Default tag for stable releases
+ - `next` — Pre-release or beta versions
+ - `canary` — Canary/nightly builds
+
+ See https://docs.npmjs.com/adding-dist-tags-to-packages.
+ type: string
+ required: false
+ default: "latest"
+ dry-run:
+ description: |
+ Whether to perform a dry run (no actual publish).
+ Useful for testing the release workflow without publishing.
+ type: boolean
+ required: false
+ default: false
+ provenance:
+ description: |
+ Whether to generate provenance attestation for the published package.
+ Requires npm 9.5.0+ and appropriate permissions.
+ See https://docs.npmjs.com/generating-provenance-statements.
+ type: boolean
+ required: false
+ default: true
+ working-directory:
+ description: "Working directory where the package is located."
+ type: string
+ required: false
+ default: "."
+ secrets:
+ registry-token:
+ description: |
+ Authentication token for the registry.
+ For npm: Use an npm access token with publish permissions.
+ For GitHub Packages: Use `GITHUB_TOKEN` or a PAT with `packages:write` permission.
+ required: true
+ build-secrets:
+ description: |
+ Secrets to be used during the build step.
+ Must be a multi-line env formatted string.
+ Example:
+ ```txt
+ SECRET_EXAMPLE=$\{{ secrets.SECRET_EXAMPLE }}
+ ```
+ required: false
+ outputs:
+ version:
+ description: "The version of the published package."
+ value: ${{ jobs.release.outputs.version }}
+ package-name:
+ description: "The name of the published package."
+ value: ${{ jobs.release.outputs.package-name }}
+ docs-artifact-id:
+ description: "ID of the documentation artifact (if uploaded)."
+ value: ${{ jobs.release.outputs.docs-artifact-id }}
+
+permissions: {}
+
+jobs:
+ prepare:
+ name: 📦 Prepare configuration
+ runs-on: ${{ inputs.runs-on && fromJson(inputs.runs-on) || 'ubuntu-latest' }}
+ permissions: {}
+ outputs:
+ registry-url: ${{ steps.parse-registry.outputs.registry-url }}
+ registry-scope: ${{ steps.parse-registry.outputs.registry-scope }}
+ build-commands: ${{ steps.parse-build.outputs.commands }}
+ build-env: ${{ steps.parse-build.outputs.env }}
+ docs-command: ${{ steps.parse-docs.outputs.command }}
+ docs-output: ${{ steps.parse-docs.outputs.output }}
+ docs-artifact: ${{ steps.parse-docs.outputs.artifact }}
+ steps:
+ - id: parse-registry
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ env:
+ REGISTRY_INPUT: ${{ inputs.registry }}
+ REPO_OWNER: ${{ github.repository_owner }}
+ with:
+ script: |
+ const registryInput = process.env.REGISTRY_INPUT.trim();
+ const repoOwner = process.env.REPO_OWNER;
+
+ let registryUrl = 'https://registry.npmjs.org';
+ let registryScope = '';
+
+ // Handle shorthand values
+ if (registryInput === 'npm' || registryInput === '') {
+ registryUrl = 'https://registry.npmjs.org';
+ } else if (registryInput === 'github') {
+ registryUrl = 'https://npm.pkg.github.com';
+ registryScope = `@${repoOwner}`;
+ } else if (registryInput.startsWith('https://') || registryInput.startsWith('http://')) {
+ registryUrl = registryInput;
+ // Auto-detect GitHub Packages scope
+ if (registryInput.includes('npm.pkg.github.com')) {
+ registryScope = `@${repoOwner}`;
+ }
+ } else if (registryInput.startsWith('{')) {
+ // Parse JSON object
+ try {
+ const config = JSON.parse(registryInput);
+ registryUrl = config.url || 'https://registry.npmjs.org';
+ registryScope = config.scope || '';
+ // Auto-detect GitHub Packages scope if not specified
+ if (registryUrl.includes('npm.pkg.github.com') && !registryScope) {
+ registryScope = `@${repoOwner}`;
+ }
+ } catch (error) {
+ return core.setFailed(`Failed to parse registry input as JSON: ${error.message}`);
+ }
+ } else {
+ return core.setFailed(`Invalid registry input: ${registryInput}`);
+ }
+
+ core.info(`Registry URL: ${registryUrl}`);
+ core.info(`Registry scope: ${registryScope || '(none)'}`);
+
+ core.setOutput('registry-url', registryUrl);
+ core.setOutput('registry-scope', registryScope);
+
+ - id: parse-build
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ env:
+ BUILD_INPUT: ${{ inputs.build }}
+ with:
+ script: |
+ const buildInput = process.env.BUILD_INPUT.trim();
+
+ if (!buildInput) {
+ core.info('Build step disabled');
+ return;
+ }
+
+ let commands = [];
+ let env = {};
+
+ // Build commands is a list of command(s), one per line
+ const buildCommandsIsList = !buildInput.startsWith('{') && !buildInput.startsWith('[');
+ if (buildCommandsIsList) {
+ commands = buildInput.split('\n').map(command => command.trim()).filter(Boolean);
+ } else {
+ let build;
+ try {
+ build = JSON.parse(buildInput);
+ } catch (error) {
+ return core.setFailed(`Failed to parse build input as JSON: ${error.message}`);
+ }
+
+ // Build commands is a JSON array of commands
+ if (Array.isArray(build)) {
+ commands = build;
+ }
+ // Build commands is a JSON object
+ else {
+ commands = build.commands ?? ['build'];
+ env = build.env ?? {};
+ }
+ }
+
+ if (commands.some(command => typeof command !== 'string')) {
+ return core.setFailed('Build commands array must only contain strings');
+ }
+
+ const sanitizedCommands = commands.map(command => command.trim()).filter(Boolean);
+ if (!sanitizedCommands.length) {
+ core.info('No build commands specified');
+ return;
+ }
+
+ core.setOutput('commands', sanitizedCommands.join('\n'));
+ core.setOutput('env', JSON.stringify(env));
+
+ - id: parse-docs
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ env:
+ DOCS_INPUT: ${{ inputs.docs }}
+ with:
+ script: |
+ const docsInput = process.env.DOCS_INPUT.trim();
+
+ if (!docsInput || docsInput === 'false') {
+ core.info('Documentation generation disabled');
+ return;
+ }
+
+ let command = 'docs';
+ let output = 'docs';
+ let artifact = false;
+
+ if (docsInput === 'true') {
+ // Use defaults
+ } else if (docsInput.startsWith('{')) {
+ try {
+ const config = JSON.parse(docsInput);
+ command = config.command || 'docs';
+ output = config.output || 'docs';
+ artifact = config.artifact === true;
+ } catch (error) {
+ return core.setFailed(`Failed to parse docs input as JSON: ${error.message}`);
+ }
+ } else {
+ // Treat as custom command
+ command = docsInput;
+ }
+
+ core.info(`Docs command: ${command}`);
+ core.info(`Docs output: ${output}`);
+ core.info(`Docs artifact: ${artifact}`);
+
+ core.setOutput('command', command);
+ core.setOutput('output', output);
+ core.setOutput('artifact', artifact ? 'true' : '');
+
+ release:
+ name: 🚀 Release
+ runs-on: ${{ inputs.runs-on && fromJson(inputs.runs-on) || 'ubuntu-latest' }}
+ needs:
+ - prepare
+ permissions:
+ contents: read
+ packages: write
+ id-token: write # Required for provenance
+ outputs:
+ version: ${{ steps.package-info.outputs.version }}
+ package-name: ${{ steps.package-info.outputs.name }}
+ docs-artifact-id: ${{ steps.upload-docs.outputs.artifact-id }}
+ steps:
+ - uses: hoverkraft-tech/ci-github-common/actions/checkout@5ac504609f6ef35c5ac94bd8199063aa32104721 # 0.31.3
+
+ - id: local-workflow-actions
+ uses: hoverkraft-tech/ci-github-common/actions/local-workflow-actions@5ac504609f6ef35c5ac94bd8199063aa32104721 # 0.31.3
+ with:
+ actions-path: actions
+
+ - id: setup-node
+ uses: ./self-workflow/actions/setup-node
+ with:
+ working-directory: ${{ inputs.working-directory }}
+ registry-url: ${{ needs.prepare.outputs.registry-url }}
+
+ - id: package-info
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ env:
+ WORKING_DIRECTORY: ${{ inputs.working-directory }}
+ with:
+ script: |
+ const fs = require('node:fs');
+ const path = require('node:path');
+
+ let workingDirectory = process.env.WORKING_DIRECTORY || '.';
+ if (!path.isAbsolute(workingDirectory)) {
+ workingDirectory = path.join(process.env.GITHUB_WORKSPACE, workingDirectory);
+ }
+
+ const packageJsonPath = path.join(workingDirectory, 'package.json');
+ if (!fs.existsSync(packageJsonPath)) {
+ return core.setFailed(`package.json not found at ${packageJsonPath}`);
+ }
+
+ let packageJson;
+ try {
+ packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
+ } catch (error) {
+ return core.setFailed(`Failed to parse package.json: ${error.message}`);
+ }
+
+ const name = packageJson.name;
+ const version = packageJson.version;
+
+ if (!name) {
+ return core.setFailed('Package name is not defined in package.json');
+ }
+ if (!version) {
+ return core.setFailed('Package version is not defined in package.json');
+ }
+
+ core.info(`Package: ${name}@${version}`);
+ core.setOutput('name', name);
+ core.setOutput('version', version);
+
+ - name: Build
+ if: needs.prepare.outputs.build-commands != ''
+ uses: ./self-workflow/actions/build
+ with:
+ working-directory: ${{ inputs.working-directory }}
+ build-commands: ${{ needs.prepare.outputs.build-commands }}
+ build-env: ${{ needs.prepare.outputs.build-env || '{}' }}
+ build-secrets: ${{ secrets.build-secrets }}
+
+ - name: Generate documentation
+ if: needs.prepare.outputs.docs-command != ''
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ env:
+ DOCS_COMMAND: ${{ needs.prepare.outputs.docs-command }}
+ RUN_SCRIPT_COMMAND: ${{ steps.setup-node.outputs.run-script-command }}
+ WORKING_DIRECTORY: ${{ inputs.working-directory }}
+ with:
+ script: |
+ const fs = require('node:fs');
+ const path = require('node:path');
+
+ let workingDirectory = process.env.WORKING_DIRECTORY || '.';
+ if (!path.isAbsolute(workingDirectory)) {
+ workingDirectory = path.join(process.env.GITHUB_WORKSPACE, workingDirectory);
+ }
+
+ if (!fs.existsSync(workingDirectory)) {
+ return core.setFailed(`Working directory does not exist: ${workingDirectory}`);
+ }
+
+ const docsCommand = process.env.DOCS_COMMAND;
+ const runScriptCommand = process.env.RUN_SCRIPT_COMMAND;
+
+ core.info(`Running documentation command: ${docsCommand}`);
+
+ try {
+ const exitCode = await exec.exec(runScriptCommand, [docsCommand], {
+ cwd: workingDirectory,
+ ignoreReturnCode: true
+ });
+
+ if (exitCode !== 0) {
+ return core.setFailed(`Documentation generation failed with exit code ${exitCode}`);
+ }
+ } catch (error) {
+ return core.setFailed(`Documentation generation failed: ${error.message}`);
+ }
+
+ - id: upload-docs
+ if: needs.prepare.outputs.docs-artifact == 'true'
+ uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
+ with:
+ name: documentation-${{ github.run_id }}
+ path: ${{ inputs.working-directory }}/${{ needs.prepare.outputs.docs-output }}
+ if-no-files-found: error
+
+ - name: Publish package
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.registry-token }}
+ PUBLISH_COMMAND: ${{ inputs.publish-command }}
+ TAG: ${{ inputs.tag }}
+ DRY_RUN: ${{ inputs.dry-run }}
+ PROVENANCE: ${{ inputs.provenance }}
+ RUN_SCRIPT_COMMAND: ${{ steps.setup-node.outputs.run-script-command }}
+ WORKING_DIRECTORY: ${{ inputs.working-directory }}
+ REGISTRY_URL: ${{ needs.prepare.outputs.registry-url }}
+ with:
+ script: |
+ const fs = require('node:fs');
+ const path = require('node:path');
+
+ let workingDirectory = process.env.WORKING_DIRECTORY || '.';
+ if (!path.isAbsolute(workingDirectory)) {
+ workingDirectory = path.join(process.env.GITHUB_WORKSPACE, workingDirectory);
+ }
+
+ if (!fs.existsSync(workingDirectory)) {
+ return core.setFailed(`Working directory does not exist: ${workingDirectory}`);
+ }
+
+ const publishCommand = process.env.PUBLISH_COMMAND;
+ const tag = process.env.TAG;
+ const dryRun = process.env.DRY_RUN === 'true';
+ const provenance = process.env.PROVENANCE === 'true';
+ const runScriptCommand = process.env.RUN_SCRIPT_COMMAND;
+ const registryUrl = process.env.REGISTRY_URL;
+
+ // Build publish arguments
+ const args = [publishCommand];
+
+ // Add tag
+ if (tag) {
+ args.push('--tag', tag);
+ }
+
+ // Add dry run flag
+ if (dryRun) {
+ args.push('--dry-run');
+ core.info('Dry run mode enabled - package will not be published');
+ }
+
+ // Add provenance flag (npm only, requires npm 9.5.0+)
+ if (provenance && registryUrl.includes('registry.npmjs.org')) {
+ args.push('--provenance');
+ core.info('Provenance attestation enabled');
+ }
+
+ core.info(`Publishing with command: ${runScriptCommand} ${args.join(' ')}`);
+
+ try {
+ const exitCode = await exec.exec(runScriptCommand, args, {
+ cwd: workingDirectory,
+ ignoreReturnCode: true
+ });
+
+ if (exitCode !== 0) {
+ return core.setFailed(`Package publish failed with exit code ${exitCode}`);
+ }
+
+ core.info('Package published successfully!');
+ } catch (error) {
+ return core.setFailed(`Package publish failed: ${error.message}`);
+ }
+
+ # jscpd:ignore-start
+ - uses: hoverkraft-tech/ci-github-common/actions/local-workflow-actions@5ac504609f6ef35c5ac94bd8199063aa32104721 # 0.31.3
+ if: always() && steps.local-workflow-actions.outputs.repository
+ with:
+ actions-path: actions
+ repository: ${{ steps.local-workflow-actions.outputs.repository }}
+ ref: ${{ steps.local-workflow-actions.outputs.ref }}
+ # jscpd:ignore-end
diff --git a/README.md b/README.md
index 6b773f8..e8bcf26 100644
--- a/README.md
+++ b/README.md
@@ -52,6 +52,10 @@ _Actions focused on discovering and preparing the Node.js environment._
- [Continuous Integration](.github/workflows/continuous-integration.md) — documentation for the reusable Node.js CI workflow.
+### Release
+
+- [Release](.github/workflows/release.md) — documentation for the reusable Node.js release workflow with support for multiple registries and documentation generation.
+
## Contributing
Contributions are welcome! Please review the [contributing guidelines](CONTRIBUTING.md) before opening a PR.
From e711e6714304fc1f5c69e702e1d8cd2471f91f69 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 5 Dec 2025 18:14:45 +0000
Subject: [PATCH 3/6] feat(workflows): add build artifact ID output support to
release workflow
Co-authored-by: neilime <314088+neilime@users.noreply.github.com>
---
.github/workflows/release.md | 13 +++++----
.github/workflows/release.yml | 54 ++++++++++++++++++++++++++++++++++-
2 files changed, 60 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/release.md b/.github/workflows/release.md
index 82b423f..412f97a 100644
--- a/.github/workflows/release.md
+++ b/.github/workflows/release.md
@@ -123,7 +123,7 @@ jobs:
| **`build`** | Build parameters. Must be a string or a JSON object. | **false** | **string** | `build` |
| | Set to empty string to disable build step. | | | |
| | For string, provide a list of commands to run during the build step, one per line. | | | |
-| | For JSON object, provide `commands` array and optional `env` object. | | | |
+| | For JSON object, provide `commands` array, optional `env` object, and `artifact`. | | | |
| **`docs`** | Documentation generation parameters. | **false** | **string** | - |
| | Set to empty string or `false` to disable. | | | |
| | Set to `true` for default command (`docs`). | | | |
@@ -161,11 +161,12 @@ jobs:
## Outputs
-| **Output** | **Description** |
-| ---------------------- | ----------------------------------------------- |
-| **`version`** | The version of the published package. |
-| **`package-name`** | The name of the published package. |
-| **`docs-artifact-id`** | ID of the documentation artifact (if uploaded). |
+| **Output** | **Description** |
+| ----------------------- | ----------------------------------------------- |
+| **`version`** | The version of the published package. |
+| **`package-name`** | The name of the published package. |
+| **`build-artifact-id`** | ID of the build artifact (if uploaded). |
+| **`docs-artifact-id`** | ID of the documentation artifact (if uploaded). |
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 12b2273..e1da3f7 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -25,6 +25,7 @@ on:
- `commands`: Array of commands to run during the build step.
- `env`: Object of environment variables to set during the build step.
+ - `artifact`: String or array of strings specifying paths to artifacts to upload after the build.
Example:
```json
@@ -35,7 +36,11 @@ on:
],
"env": {
"NODE_ENV": "production"
- }
+ },
+ "artifact": [
+ "dist/",
+ "packages/package-a/build/"
+ ]
}
```
type: string
@@ -154,6 +159,9 @@ on:
package-name:
description: "The name of the published package."
value: ${{ jobs.release.outputs.package-name }}
+ build-artifact-id:
+ description: "ID of the build artifact (if uploaded)."
+ value: ${{ jobs.release.outputs.build-artifact-id }}
docs-artifact-id:
description: "ID of the documentation artifact (if uploaded)."
value: ${{ jobs.release.outputs.docs-artifact-id }}
@@ -170,6 +178,7 @@ jobs:
registry-scope: ${{ steps.parse-registry.outputs.registry-scope }}
build-commands: ${{ steps.parse-build.outputs.commands }}
build-env: ${{ steps.parse-build.outputs.env }}
+ build-artifact: ${{ steps.parse-build.outputs.artifact }}
docs-command: ${{ steps.parse-docs.outputs.command }}
docs-output: ${{ steps.parse-docs.outputs.output }}
docs-artifact: ${{ steps.parse-docs.outputs.artifact }}
@@ -226,9 +235,13 @@ jobs:
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
BUILD_INPUT: ${{ inputs.build }}
+ WORKING_DIRECTORY: ${{ inputs.working-directory }}
with:
script: |
+ const path = require('node:path');
+
const buildInput = process.env.BUILD_INPUT.trim();
+ const workingDirectory = process.env.WORKING_DIRECTORY || '.';
if (!buildInput) {
core.info('Build step disabled');
@@ -258,6 +271,42 @@ jobs:
else {
commands = build.commands ?? ['build'];
env = build.env ?? {};
+
+ if (build.artifact) {
+ let buildArtifact = build.artifact;
+
+ if (typeof buildArtifact === 'string' || Array.isArray(buildArtifact)) {
+ buildArtifact = {
+ paths: buildArtifact
+ };
+ }
+
+ if (typeof buildArtifact.paths === 'string') {
+ buildArtifact.paths = buildArtifact.paths.split('\n');
+ } else if (!Array.isArray(buildArtifact.paths)) {
+ return core.setFailed('Build artifact paths must be a string or an array of strings');
+ }
+
+ buildArtifact.paths = buildArtifact.paths
+ .map(artifact => artifact.trim())
+ .filter(Boolean)
+ .map(artifact => {
+ // FIXME: Workaround to preserve full path to artifact
+ const fullpath = artifact.startsWith('/') ? artifact : path.join(workingDirectory, artifact);
+
+ // Add a wildcard to the first folder of the path
+ return fullpath.replace(/\/([^/]+)/, '/*$1');
+ }).join('\n');
+
+ if (!buildArtifact.paths) {
+ return core.setFailed('No valid build artifact paths found');
+ }
+
+ // Generate a unique name for the artifact
+ buildArtifact.name = `${process.env.GITHUB_JOB}-build-${process.env.GITHUB_RUN_ID}-${Math.random().toString(36).substring(2, 8)}`;
+
+ core.setOutput('artifact', JSON.stringify(buildArtifact));
+ }
}
}
@@ -327,6 +376,7 @@ jobs:
outputs:
version: ${{ steps.package-info.outputs.version }}
package-name: ${{ steps.package-info.outputs.name }}
+ build-artifact-id: ${{ steps.build.outputs.artifact-id }}
docs-artifact-id: ${{ steps.upload-docs.outputs.artifact-id }}
steps:
- uses: hoverkraft-tech/ci-github-common/actions/checkout@5ac504609f6ef35c5ac94bd8199063aa32104721 # 0.31.3
@@ -383,6 +433,7 @@ jobs:
core.setOutput('version', version);
- name: Build
+ id: build
if: needs.prepare.outputs.build-commands != ''
uses: ./self-workflow/actions/build
with:
@@ -390,6 +441,7 @@ jobs:
build-commands: ${{ needs.prepare.outputs.build-commands }}
build-env: ${{ needs.prepare.outputs.build-env || '{}' }}
build-secrets: ${{ secrets.build-secrets }}
+ build-artifact: ${{ needs.prepare.outputs.build-artifact }}
- name: Generate documentation
if: needs.prepare.outputs.docs-command != ''
From c498eccff3f37c387e0e42584a4b1aba5162014c Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 5 Dec 2025 19:03:51 +0000
Subject: [PATCH 4/6] refactor(release): remove build functionality, build is
done in CI workflow
Co-authored-by: neilime <314088+neilime@users.noreply.github.com>
---
.github/workflows/release.md | 32 ++------
.github/workflows/release.yml | 150 ----------------------------------
2 files changed, 5 insertions(+), 177 deletions(-)
diff --git a/.github/workflows/release.md b/.github/workflows/release.md
index 412f97a..7c04a51 100644
--- a/.github/workflows/release.md
+++ b/.github/workflows/release.md
@@ -25,7 +25,6 @@
Workflow to release Node.js packages with support for:
-- Building the package before publishing
- Generating documentation (optional)
- Publishing to various registries (npm, GitHub Packages)
- Provenance attestation for npm packages
@@ -64,18 +63,11 @@ jobs:
# For npm: Use an npm access token with publish permissions.
# For GitHub Packages: Use `GITHUB_TOKEN` or a PAT with `packages:write` permission.
registry-token: ${{ secrets.NPM_TOKEN }}
-
- # Secrets to be used during the build step (optional).
- build-secrets: ""
with:
# JSON array of runner(s) to use.
# Default: `["ubuntu-latest"]`
runs-on: '["ubuntu-latest"]'
- # Build parameters. Set to empty string to disable.
- # Default: `build`
- build: build
-
# Documentation generation parameters.
# Set to empty string or `false` to disable.
docs: ""
@@ -120,10 +112,6 @@ jobs:
| ----------------------- | ---------------------------------------------------------------------------------- | ------------ | ----------- | ------------------- |
| **`runs-on`** | JSON array of runner(s) to use. | **false** | **string** | `["ubuntu-latest"]` |
| | See . | | | |
-| **`build`** | Build parameters. Must be a string or a JSON object. | **false** | **string** | `build` |
-| | Set to empty string to disable build step. | | | |
-| | For string, provide a list of commands to run during the build step, one per line. | | | |
-| | For JSON object, provide `commands` array, optional `env` object, and `artifact`. | | | |
| **`docs`** | Documentation generation parameters. | **false** | **string** | - |
| | Set to empty string or `false` to disable. | | | |
| | Set to `true` for default command (`docs`). | | | |
@@ -152,8 +140,6 @@ jobs:
| **`registry-token`** | Authentication token for the registry. | **true** |
| | For npm: Use an npm access token with publish permissions. | |
| | For GitHub Packages: Use `GITHUB_TOKEN` or a PAT with `packages:write` permission. | |
-| **`build-secrets`** | Secrets to be used during the build step. | **false** |
-| | Must be a multi-line env formatted string. | |
@@ -161,12 +147,11 @@ jobs:
## Outputs
-| **Output** | **Description** |
-| ----------------------- | ----------------------------------------------- |
-| **`version`** | The version of the published package. |
-| **`package-name`** | The name of the published package. |
-| **`build-artifact-id`** | ID of the build artifact (if uploaded). |
-| **`docs-artifact-id`** | ID of the documentation artifact (if uploaded). |
+| **Output** | **Description** |
+| ---------------------- | ----------------------------------------------- |
+| **`version`** | The version of the published package. |
+| **`package-name`** | The name of the published package. |
+| **`docs-artifact-id`** | ID of the documentation artifact (if uploaded). |
@@ -348,13 +333,6 @@ jobs:
registry-token: ${{ secrets.NPM_TOKEN }}
with:
working-directory: packages/my-package
- build: |
- {
- "commands": ["build"],
- "env": {
- "NODE_ENV": "production"
- }
- }
```
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index e1da3f7..5ff4d86 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,6 +1,5 @@
# Workflow to release Node.js packages:
#
-# - Build (optional)
# - Generate documentation (optional)
# - Publish to registry (npm, GitHub Packages)
@@ -16,36 +15,6 @@ on:
type: string
default: '["ubuntu-latest"]'
required: false
- build:
- description: |
- Build parameters. Must be a string or a JSON object.
- Set to empty string to disable build step.
- For string, provide a list of commands to run during the build step, one per line.
- For JSON object, provide the following properties:
-
- - `commands`: Array of commands to run during the build step.
- - `env`: Object of environment variables to set during the build step.
- - `artifact`: String or array of strings specifying paths to artifacts to upload after the build.
-
- Example:
- ```json
- {
- "commands": [
- "build",
- "generate-artifacts"
- ],
- "env": {
- "NODE_ENV": "production"
- },
- "artifact": [
- "dist/",
- "packages/package-a/build/"
- ]
- }
- ```
- type: string
- required: false
- default: "build"
docs:
description: |
Documentation generation parameters.
@@ -143,15 +112,6 @@ on:
For npm: Use an npm access token with publish permissions.
For GitHub Packages: Use `GITHUB_TOKEN` or a PAT with `packages:write` permission.
required: true
- build-secrets:
- description: |
- Secrets to be used during the build step.
- Must be a multi-line env formatted string.
- Example:
- ```txt
- SECRET_EXAMPLE=$\{{ secrets.SECRET_EXAMPLE }}
- ```
- required: false
outputs:
version:
description: "The version of the published package."
@@ -159,9 +119,6 @@ on:
package-name:
description: "The name of the published package."
value: ${{ jobs.release.outputs.package-name }}
- build-artifact-id:
- description: "ID of the build artifact (if uploaded)."
- value: ${{ jobs.release.outputs.build-artifact-id }}
docs-artifact-id:
description: "ID of the documentation artifact (if uploaded)."
value: ${{ jobs.release.outputs.docs-artifact-id }}
@@ -176,9 +133,6 @@ jobs:
outputs:
registry-url: ${{ steps.parse-registry.outputs.registry-url }}
registry-scope: ${{ steps.parse-registry.outputs.registry-scope }}
- build-commands: ${{ steps.parse-build.outputs.commands }}
- build-env: ${{ steps.parse-build.outputs.env }}
- build-artifact: ${{ steps.parse-build.outputs.artifact }}
docs-command: ${{ steps.parse-docs.outputs.command }}
docs-output: ${{ steps.parse-docs.outputs.output }}
docs-artifact: ${{ steps.parse-docs.outputs.artifact }}
@@ -231,98 +185,6 @@ jobs:
core.setOutput('registry-url', registryUrl);
core.setOutput('registry-scope', registryScope);
- - id: parse-build
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
- env:
- BUILD_INPUT: ${{ inputs.build }}
- WORKING_DIRECTORY: ${{ inputs.working-directory }}
- with:
- script: |
- const path = require('node:path');
-
- const buildInput = process.env.BUILD_INPUT.trim();
- const workingDirectory = process.env.WORKING_DIRECTORY || '.';
-
- if (!buildInput) {
- core.info('Build step disabled');
- return;
- }
-
- let commands = [];
- let env = {};
-
- // Build commands is a list of command(s), one per line
- const buildCommandsIsList = !buildInput.startsWith('{') && !buildInput.startsWith('[');
- if (buildCommandsIsList) {
- commands = buildInput.split('\n').map(command => command.trim()).filter(Boolean);
- } else {
- let build;
- try {
- build = JSON.parse(buildInput);
- } catch (error) {
- return core.setFailed(`Failed to parse build input as JSON: ${error.message}`);
- }
-
- // Build commands is a JSON array of commands
- if (Array.isArray(build)) {
- commands = build;
- }
- // Build commands is a JSON object
- else {
- commands = build.commands ?? ['build'];
- env = build.env ?? {};
-
- if (build.artifact) {
- let buildArtifact = build.artifact;
-
- if (typeof buildArtifact === 'string' || Array.isArray(buildArtifact)) {
- buildArtifact = {
- paths: buildArtifact
- };
- }
-
- if (typeof buildArtifact.paths === 'string') {
- buildArtifact.paths = buildArtifact.paths.split('\n');
- } else if (!Array.isArray(buildArtifact.paths)) {
- return core.setFailed('Build artifact paths must be a string or an array of strings');
- }
-
- buildArtifact.paths = buildArtifact.paths
- .map(artifact => artifact.trim())
- .filter(Boolean)
- .map(artifact => {
- // FIXME: Workaround to preserve full path to artifact
- const fullpath = artifact.startsWith('/') ? artifact : path.join(workingDirectory, artifact);
-
- // Add a wildcard to the first folder of the path
- return fullpath.replace(/\/([^/]+)/, '/*$1');
- }).join('\n');
-
- if (!buildArtifact.paths) {
- return core.setFailed('No valid build artifact paths found');
- }
-
- // Generate a unique name for the artifact
- buildArtifact.name = `${process.env.GITHUB_JOB}-build-${process.env.GITHUB_RUN_ID}-${Math.random().toString(36).substring(2, 8)}`;
-
- core.setOutput('artifact', JSON.stringify(buildArtifact));
- }
- }
- }
-
- if (commands.some(command => typeof command !== 'string')) {
- return core.setFailed('Build commands array must only contain strings');
- }
-
- const sanitizedCommands = commands.map(command => command.trim()).filter(Boolean);
- if (!sanitizedCommands.length) {
- core.info('No build commands specified');
- return;
- }
-
- core.setOutput('commands', sanitizedCommands.join('\n'));
- core.setOutput('env', JSON.stringify(env));
-
- id: parse-docs
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
@@ -376,7 +238,6 @@ jobs:
outputs:
version: ${{ steps.package-info.outputs.version }}
package-name: ${{ steps.package-info.outputs.name }}
- build-artifact-id: ${{ steps.build.outputs.artifact-id }}
docs-artifact-id: ${{ steps.upload-docs.outputs.artifact-id }}
steps:
- uses: hoverkraft-tech/ci-github-common/actions/checkout@5ac504609f6ef35c5ac94bd8199063aa32104721 # 0.31.3
@@ -432,17 +293,6 @@ jobs:
core.setOutput('name', name);
core.setOutput('version', version);
- - name: Build
- id: build
- if: needs.prepare.outputs.build-commands != ''
- uses: ./self-workflow/actions/build
- with:
- working-directory: ${{ inputs.working-directory }}
- build-commands: ${{ needs.prepare.outputs.build-commands }}
- build-env: ${{ needs.prepare.outputs.build-env || '{}' }}
- build-secrets: ${{ secrets.build-secrets }}
- build-artifact: ${{ needs.prepare.outputs.build-artifact }}
-
- name: Generate documentation
if: needs.prepare.outputs.docs-command != ''
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
From 56b60f9fd2f3b8af2d25e6e1a107d6cb4bdc07aa Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 24 Dec 2025 07:35:37 +0000
Subject: [PATCH 5/6] feat(release): add support for build artifacts, tarballs,
and access control
Co-authored-by: neilime <314088+neilime@users.noreply.github.com>
---
.github/workflows/release.md | 103 ++++++++++++++++++++++++----------
.github/workflows/release.yml | 70 +++++++++++++++++++++++
2 files changed, 144 insertions(+), 29 deletions(-)
diff --git a/.github/workflows/release.md b/.github/workflows/release.md
index 7c04a51..0357987 100644
--- a/.github/workflows/release.md
+++ b/.github/workflows/release.md
@@ -25,10 +25,13 @@
Workflow to release Node.js packages with support for:
-- Generating documentation (optional)
- Publishing to various registries (npm, GitHub Packages)
+- Publishing from build artifacts or source code
+- Publishing pre-built package tarballs
+- Generating documentation (optional)
- Provenance attestation for npm packages
- Distribution tags for versioning
+- Scoped package access control
### Permissions
@@ -42,6 +45,8 @@ Workflow to release Node.js packages with support for:
## Usage
+### Basic Release from Source
+
```yaml
name: Release
@@ -59,43 +64,77 @@ jobs:
packages: write
id-token: write
secrets:
- # Authentication token for the registry.
- # For npm: Use an npm access token with publish permissions.
- # For GitHub Packages: Use `GITHUB_TOKEN` or a PAT with `packages:write` permission.
+ registry-token: ${{ secrets.NPM_TOKEN }}
+```
+
+### Release with Build Artifacts from CI
+
+```yaml
+name: Release
+
+on:
+ push:
+ tags: ["*"]
+
+permissions: {}
+
+jobs:
+ ci:
+ uses: ./.github/workflows/__shared-ci.yml
+ permissions:
+ contents: read
+ id-token: write
+ packages: read
+ secrets: inherit
+
+ release:
+ needs: ci
+ uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/release.yml@main
+ permissions:
+ contents: read
+ packages: write
+ id-token: write
+ secrets:
registry-token: ${{ secrets.NPM_TOKEN }}
with:
- # JSON array of runner(s) to use.
- # Default: `["ubuntu-latest"]`
- runs-on: '["ubuntu-latest"]'
+ # Download build artifacts from CI job
+ build-artifact-id: ${{ needs.ci.outputs.build-artifact-id }}
+ access: public
+```
- # Documentation generation parameters.
- # Set to empty string or `false` to disable.
- docs: ""
+### Release Pre-built Tarball
- # Registry configuration.
- # Use `npm`, `github`, or a URL/JSON object.
- # Default: `npm`
- registry: npm
+```yaml
+name: Release
- # Command to run for publishing.
- # Default: `publish`
- publish-command: publish
+on:
+ push:
+ tags: ["*"]
- # npm distribution tag.
- # Default: `latest`
- tag: latest
+permissions: {}
- # Whether to perform a dry run.
- # Default: `false`
- dry-run: false
+jobs:
+ ci:
+ uses: ./.github/workflows/__shared-ci.yml
+ permissions:
+ contents: read
+ id-token: write
+ secrets: inherit
- # Whether to generate provenance attestation.
- # Default: `true`
+ release:
+ needs: ci
+ uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/release.yml@main
+ permissions:
+ contents: read
+ packages: write
+ id-token: write
+ secrets:
+ registry-token: ${{ secrets.NPM_TOKEN }}
+ with:
+ build-artifact-id: ${{ needs.ci.outputs.package-tarball-artifact-id }}
+ package-tarball: "*.tgz"
+ access: public
provenance: true
-
- # Working directory where the package is located.
- # Default: `.`
- working-directory: .
```
@@ -112,6 +151,12 @@ jobs:
| ----------------------- | ---------------------------------------------------------------------------------- | ------------ | ----------- | ------------------- |
| **`runs-on`** | JSON array of runner(s) to use. | **false** | **string** | `["ubuntu-latest"]` |
| | See . | | | |
+| **`build-artifact-id`** | Build artifact ID from CI to download before publishing. | **false** | **string** | - |
+| | Contains built package or tarball from a previous job. | | | |
+| **`package-tarball`** | Path/pattern to pre-built tarball to publish (e.g., `*.tgz`). | **false** | **string** | - |
+| | Use when publishing a specific tarball instead of from source. | | | |
+| **`access`** | Package access level: `public` or `restricted`. | **false** | **string** | - |
+| | Leave empty to use package.json default. | | | |
| **`docs`** | Documentation generation parameters. | **false** | **string** | - |
| | Set to empty string or `false` to disable. | | | |
| | Set to `true` for default command (`docs`). | | | |
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5ff4d86..9cb4ea2 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -15,6 +15,31 @@ on:
type: string
default: '["ubuntu-latest"]'
required: false
+ build-artifact-id:
+ description: |
+ Build artifact ID from a previous CI job to download before publishing.
+ This artifact typically contains the built package or tarball.
+ If not provided, the workflow will publish from source.
+ type: string
+ required: false
+ default: ""
+ package-tarball:
+ description: |
+ Path to a pre-built package tarball to publish (e.g., `my-package-1.0.0.tgz`).
+ Use this when publishing a specific tarball file instead of running npm publish from source.
+ Supports glob patterns to match tarball files.
+ type: string
+ required: false
+ default: ""
+ access:
+ description: |
+ Package access level for npm publish.
+ - `public` — Publicly accessible package
+ - `restricted` — Scoped package with restricted access
+ Leave empty to use package.json default.
+ type: string
+ required: false
+ default: ""
docs:
description: |
Documentation generation parameters.
@@ -253,6 +278,13 @@ jobs:
working-directory: ${{ inputs.working-directory }}
registry-url: ${{ needs.prepare.outputs.registry-url }}
+ - name: Download build artifacts
+ if: inputs.build-artifact-id != ''
+ uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
+ with:
+ artifact-ids: ${{ inputs.build-artifact-id }}
+ path: /
+
- id: package-info
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
@@ -345,6 +377,8 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.registry-token }}
PUBLISH_COMMAND: ${{ inputs.publish-command }}
+ PACKAGE_TARBALL: ${{ inputs.package-tarball }}
+ ACCESS: ${{ inputs.access }}
TAG: ${{ inputs.tag }}
DRY_RUN: ${{ inputs.dry-run }}
PROVENANCE: ${{ inputs.provenance }}
@@ -355,6 +389,7 @@ jobs:
script: |
const fs = require('node:fs');
const path = require('node:path');
+ const { glob } = require('glob');
let workingDirectory = process.env.WORKING_DIRECTORY || '.';
if (!path.isAbsolute(workingDirectory)) {
@@ -366,15 +401,50 @@ jobs:
}
const publishCommand = process.env.PUBLISH_COMMAND;
+ const packageTarball = process.env.PACKAGE_TARBALL;
+ const access = process.env.ACCESS;
const tag = process.env.TAG;
const dryRun = process.env.DRY_RUN === 'true';
const provenance = process.env.PROVENANCE === 'true';
const runScriptCommand = process.env.RUN_SCRIPT_COMMAND;
const registryUrl = process.env.REGISTRY_URL;
+ // Determine what to publish
+ let publishTarget = null;
+
+ if (packageTarball) {
+ // Publishing a specific tarball file
+ const tarballPattern = path.isAbsolute(packageTarball)
+ ? packageTarball
+ : path.join(workingDirectory, packageTarball);
+
+ const matches = await glob(tarballPattern, { nodir: true });
+
+ if (matches.length === 0) {
+ return core.setFailed(`No tarball found matching pattern: ${packageTarball}`);
+ }
+
+ if (matches.length > 1) {
+ core.warning(`Multiple tarballs found: ${matches.join(', ')}. Using first match.`);
+ }
+
+ publishTarget = matches[0];
+ core.info(`Publishing tarball: ${publishTarget}`);
+ }
+
// Build publish arguments
const args = [publishCommand];
+ // Add tarball target if specified
+ if (publishTarget) {
+ args.push(publishTarget);
+ }
+
+ // Add access flag
+ if (access) {
+ args.push('--access', access);
+ }
+
// Add tag
if (tag) {
args.push('--tag', tag);
From 1bba5314717d4d4333f027baf7f324be65eedae6 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 24 Dec 2025 07:40:04 +0000
Subject: [PATCH 6/6] fix(release): use fs instead of glob for tarball
matching, fix artifact download path
Co-authored-by: neilime <314088+neilime@users.noreply.github.com>
---
.github/workflows/release.yml | 48 ++++++++++++++++++++++++++---------
1 file changed, 36 insertions(+), 12 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 9cb4ea2..7cfe115 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -282,8 +282,8 @@ jobs:
if: inputs.build-artifact-id != ''
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
with:
- artifact-ids: ${{ inputs.build-artifact-id }}
- path: /
+ name: ${{ inputs.build-artifact-id }}
+ path: ${{ github.workspace }}
- id: package-info
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
@@ -389,7 +389,6 @@ jobs:
script: |
const fs = require('node:fs');
const path = require('node:path');
- const { glob } = require('glob');
let workingDirectory = process.env.WORKING_DIRECTORY || '.';
if (!path.isAbsolute(workingDirectory)) {
@@ -409,25 +408,50 @@ jobs:
const runScriptCommand = process.env.RUN_SCRIPT_COMMAND;
const registryUrl = process.env.REGISTRY_URL;
+ // Simple glob matching function
+ function matchGlob(pattern, filename) {
+ const regexPattern = pattern
+ .replace(/\./g, '\\.')
+ .replace(/\*/g, '.*')
+ .replace(/\?/g, '.');
+ return new RegExp(`^${regexPattern}$`).test(filename);
+ }
+
// Determine what to publish
let publishTarget = null;
if (packageTarball) {
// Publishing a specific tarball file
- const tarballPattern = path.isAbsolute(packageTarball)
- ? packageTarball
- : path.join(workingDirectory, packageTarball);
-
- const matches = await glob(tarballPattern, { nodir: true });
-
+ let searchDir = workingDirectory;
+ let filePattern = packageTarball;
+
+ if (path.isAbsolute(packageTarball)) {
+ searchDir = path.dirname(packageTarball);
+ filePattern = path.basename(packageTarball);
+ } else if (packageTarball.includes('/')) {
+ searchDir = path.join(workingDirectory, path.dirname(packageTarball));
+ filePattern = path.basename(packageTarball);
+ }
+
+ if (!fs.existsSync(searchDir)) {
+ return core.setFailed(`Search directory does not exist: ${searchDir}`);
+ }
+
+ // Find matching files
+ const allFiles = fs.readdirSync(searchDir);
+ const matches = allFiles
+ .filter(file => matchGlob(filePattern, file))
+ .map(file => path.join(searchDir, file))
+ .filter(file => fs.statSync(file).isFile());
+
if (matches.length === 0) {
- return core.setFailed(`No tarball found matching pattern: ${packageTarball}`);
+ return core.setFailed(`No tarball found matching pattern: ${packageTarball} in ${searchDir}`);
}
-
+
if (matches.length > 1) {
core.warning(`Multiple tarballs found: ${matches.join(', ')}. Using first match.`);
}
-
+
publishTarget = matches[0];
core.info(`Publishing tarball: ${publishTarget}`);
}