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