From f5f28f754a33adfd4d79b237712ce9eb923e127e Mon Sep 17 00:00:00 2001 From: Chris Park Date: Tue, 29 Sep 2026 13:09:26 +0930 Subject: [PATCH] DO-2050: introduce workflow for publishing node package --- .github/workflows/node-publish.yml | 314 +++++++++++++++++++++++++++++ README.md | 1 + docs/node-publish.md | 117 +++++++++++ 3 files changed, 432 insertions(+) create mode 100644 .github/workflows/node-publish.yml create mode 100644 docs/node-publish.md diff --git a/.github/workflows/node-publish.yml b/.github/workflows/node-publish.yml new file mode 100644 index 0000000..9693c7a --- /dev/null +++ b/.github/workflows/node-publish.yml @@ -0,0 +1,314 @@ +name: 📦 Node Publish Package + +on: + workflow_call: + secrets: + NPM_TOKEN: + description: >- + NPM authentication token for installing from private registries. + Not used for publishing, which authenticates via OIDC trusted publishing. + required: false + inputs: + package-manager: + description: "Node package manager to use (npm, yarn or pnpm)" + default: yarn + type: string + is-yarn-classic: + description: "If Yarn (pre-Berry) should be used" + default: false + type: boolean + pre-install-commands: + description: "Commands to run before dependency installation (e.g., configure registries, auth tokens)" + default: "" + type: string + build-command: + description: "Command to override the build command" + default: build + type: string + test-command: + description: "Command to override the test command" + default: test + type: string + skip-build: + description: "If the build step should be skipped" + default: false + type: boolean + skip-test: + description: "If the test step should be skipped" + default: false + type: boolean + package-directory: + description: "Directory of the package to publish, relative to the repository root" + default: "." + type: string + registry-url: + description: "Registry to publish to" + default: "https://registry.npmjs.org" + type: string + dist-tag: + description: >- + npm dist-tag to publish under. Defaults to the prerelease identifier + of the package.json version (e.g., 1.2.0-beta.1 publishes as 'beta'), + or 'latest' for stable versions + default: "" + type: string + access: + description: "Package access level (public or restricted). Leave empty to use the registry default" + default: "" + type: string + skip-tag-version-check: + description: >- + If the check that the pushed tag (with any leading 'v' removed) + matches the package.json version should be skipped + default: false + type: boolean + dry-run: + description: "If the package should be packed and validated without publishing" + default: false + type: boolean + node-options: + description: "Value for NODE_OPTIONS environment variable (e.g., --max-old-space-size=4096)" + default: "" + type: string + +jobs: + publish: + name: 📦 Publish + runs-on: ubuntu-latest + permissions: + id-token: write # Required for npm OIDC trusted publishing + contents: read + env: + NODE_OPTIONS: ${{ inputs.node-options }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0 + with: + persist-credentials: false + + - name: Install Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e #v6.4.0 + with: + node-version-file: .nvmrc + registry-url: ${{ inputs.registry-url }} + package-manager-cache: false # never use caching in release builds + + - name: Validate inputs + run: | + case "${INPUTS_PACKAGE_MANAGER}" in + npm|yarn|pnpm) ;; + *) + echo "::error::Unsupported package-manager '${INPUTS_PACKAGE_MANAGER}'. Use npm, yarn or pnpm." + exit 1 + ;; + esac + case "${INPUTS_ACCESS}" in + ""|public|restricted) ;; + *) + echo "::error::Unsupported access '${INPUTS_ACCESS}'. Use public or restricted." + exit 1 + ;; + esac + if [ ! -f "${INPUTS_PACKAGE_DIRECTORY}/package.json" ]; then + echo "::error::No package.json found in package-directory '${INPUTS_PACKAGE_DIRECTORY}'." + exit 1 + fi + env: + INPUTS_ACCESS: ${{ inputs.access }} + INPUTS_PACKAGE_DIRECTORY: ${{ inputs.package-directory }} + INPUTS_PACKAGE_MANAGER: ${{ inputs.package-manager }} + + - name: Check trusted publishing requirements + run: | + # https://docs.npmjs.com/trusted-publishers + # Trusted publishing requires npm >= 11.5.1 and Node.js >= 22.14.0 + failed=false + check_version() { + local name="$1" required="$2" current="$3" lowest + lowest="$(printf '%s\n%s\n' "${required}" "${current}" | sort -V | head -n1)" + if [ "${lowest}" != "${required}" ]; then + echo "::error::${name} ${current} is too old for trusted publishing (requires >= ${required})." + failed=true + else + echo "${name} ${current} supports trusted publishing" + fi + } + node_version="$(node --version)" + check_version "Node.js" "22.14.0" "${node_version#v}" + check_version "npm" "11.5.1" "$(npm --version)" + if [ "${failed}" = "true" ]; then + echo "::error::Update .nvmrc to a Node.js release that bundles npm >= 11.5.1." + exit 1 + fi + + - name: Check tag matches package version + if: github.ref_type == 'tag' && inputs.skip-tag-version-check == false + run: | + package_version="$(jq -r '.version' "${INPUTS_PACKAGE_DIRECTORY}/package.json")" + tag_version="${GITHUB_REF_NAME#v}" + if [ "${tag_version}" != "${package_version}" ]; then + echo "::error::Tag '${GITHUB_REF_NAME}' does not match package.json version '${package_version}'." + exit 1 + fi + echo "Tag '${GITHUB_REF_NAME}' matches package.json version '${package_version}'" + env: + INPUTS_PACKAGE_DIRECTORY: ${{ inputs.package-directory }} + + - name: Resolve dist-tag + id: dist-tag + run: | + if [ -n "${INPUTS_DIST_TAG}" ]; then + dist_tag="${INPUTS_DIST_TAG}" + echo "Using explicit dist-tag '${dist_tag}'" + else + package_version="$(jq -r '.version' "${INPUTS_PACKAGE_DIRECTORY}/package.json")" + # Drop build metadata (+...), then take the first prerelease identifier + version_core="${package_version%%+*}" + if [ "${version_core}" = "${version_core#*-}" ]; then + dist_tag="latest" + else + prerelease="${version_core#*-}" + dist_tag="${prerelease%%.*}" + fi + echo "Derived dist-tag '${dist_tag}' from package.json version '${package_version}'" + fi + # npm rejects dist-tags that are valid semver ranges (e.g., '0', '1.x') + if [[ ! "${dist_tag}" =~ ^[A-Za-z][A-Za-z0-9._-]*$ ]]; then + echo "::error::'${dist_tag}' is not a usable dist-tag. Set the dist-tag input explicitly." + exit 1 + fi + echo "value=${dist_tag}" >> "$GITHUB_OUTPUT" + env: + INPUTS_DIST_TAG: ${{ inputs.dist-tag }} + INPUTS_PACKAGE_DIRECTORY: ${{ inputs.package-directory }} + + - name: Enable Corepack + if: hashFiles('package.json') != '' + run: | + # Enable corepack if packageManager is specified in package.json + if jq -e '.packageManager' package.json >/dev/null 2>&1; then + echo "packageManager field detected in package.json, enabling corepack" + corepack enable + fi + + - name: Install safe-chain + run: | + SAFE_CHAIN_URL="https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh" + curl -fsSL "$SAFE_CHAIN_URL" | sh -s -- --ci + + - name: Run pre-install commands + if: inputs.pre-install-commands != '' + run: | + # Execute pre-install commands line by line + echo "${INPUTS_PRE_INSTALL_COMMANDS}" | while IFS= read -r cmd; do + if [ -n "$cmd" ]; then + echo "Running: $cmd" + eval "$cmd" + fi + done + env: + INPUTS_PRE_INSTALL_COMMANDS: ${{ inputs.pre-install-commands }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Install dependencies + run: | + case "${INPUTS_PACKAGE_MANAGER}" in + yarn) yarn install ${FLAG_LOCK_DEPENDENCIES} ;; + pnpm) pnpm install --frozen-lockfile ;; + npm) npm ci ;; + esac + env: + FLAG_LOCK_DEPENDENCIES: ${{ case(inputs.is-yarn-classic == true, '--frozen-lockfile', '--immutable') }} + INPUTS_PACKAGE_MANAGER: ${{ inputs.package-manager }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Build + if: inputs.skip-build == false + run: ${INPUTS_PACKAGE_MANAGER} run ${INPUTS_BUILD_COMMAND} + env: + INPUTS_BUILD_COMMAND: ${{ inputs.build-command }} + INPUTS_PACKAGE_MANAGER: ${{ inputs.package-manager }} + + - name: Test + if: inputs.skip-test == false + run: ${INPUTS_PACKAGE_MANAGER} run ${INPUTS_TEST_COMMAND} + env: + INPUTS_PACKAGE_MANAGER: ${{ inputs.package-manager }} + INPUTS_TEST_COMMAND: ${{ inputs.test-command }} + + - name: Run prepublishOnly script + working-directory: ${{ inputs.package-directory }} + run: | + # Publishing a tarball skips prepublishOnly, so run it explicitly + # to match the behaviour of a regular publish. + if jq -e '.scripts.prepublishOnly' package.json >/dev/null 2>&1; then + ${INPUTS_PACKAGE_MANAGER} run prepublishOnly + else + echo "No prepublishOnly script defined, skipping" + fi + env: + INPUTS_PACKAGE_MANAGER: ${{ inputs.package-manager }} + + - name: Pack package + id: pack + working-directory: ${{ inputs.package-directory }} + run: | + # Pack with the project's package manager so workspace:/catalog: + # protocols are rewritten to real versions before publishing. + pack_dir="${RUNNER_TEMP}/package" + mkdir -p "${pack_dir}" + case "${INPUTS_PACKAGE_MANAGER}" in + yarn) + if [ "${INPUTS_IS_YARN_CLASSIC}" = "true" ]; then + yarn pack --filename "${pack_dir}/package.tgz" + else + yarn pack --out "${pack_dir}/package.tgz" + fi + ;; + pnpm) pnpm pack --pack-destination "${pack_dir}" ;; + npm) npm pack --pack-destination "${pack_dir}" ;; + esac + + tarballs=("${pack_dir}"/*.tgz) + if [ "${#tarballs[@]}" -ne 1 ] || [ ! -f "${tarballs[0]}" ]; then + echo "::error::Expected exactly one tarball in ${pack_dir}, found: ${tarballs[*]}" + exit 1 + fi + echo "Packed ${tarballs[0]}" + echo "tarball=${tarballs[0]}" >> "$GITHUB_OUTPUT" + env: + INPUTS_IS_YARN_CLASSIC: ${{ inputs.is-yarn-classic }} + INPUTS_PACKAGE_MANAGER: ${{ inputs.package-manager }} + + - name: Publish + run: | + # Always publish with the npm CLI: it performs the OIDC token exchange + # for trusted publishing and attaches provenance automatically. + args=(--tag "${STEPS_DIST_TAG_OUTPUTS_VALUE}") + if [ -n "${INPUTS_ACCESS}" ]; then + args+=(--access "${INPUTS_ACCESS}") + fi + if [ "${INPUTS_DRY_RUN}" = "true" ]; then + args+=(--dry-run) + fi + npm publish "${STEPS_PACK_OUTPUTS_TARBALL}" "${args[@]}" + env: + INPUTS_ACCESS: ${{ inputs.access }} + INPUTS_DRY_RUN: ${{ inputs.dry-run }} + STEPS_DIST_TAG_OUTPUTS_VALUE: ${{ steps.dist-tag.outputs.value }} + STEPS_PACK_OUTPUTS_TARBALL: ${{ steps.pack.outputs.tarball }} + + - name: Run publish and postpublish scripts + if: inputs.dry-run == false + working-directory: ${{ inputs.package-directory }} + run: | + # Publishing a tarball skips these scripts, so run them explicitly + # to match the behaviour of a regular publish. + for script in publish postpublish; do + if jq -e --arg s "${script}" '.scripts[$s]' package.json >/dev/null 2>&1; then + ${INPUTS_PACKAGE_MANAGER} run "${script}" + else + echo "No ${script} script defined, skipping" + fi + done + env: + INPUTS_PACKAGE_MANAGER: ${{ inputs.package-manager }} diff --git a/README.md b/README.md index dcc0ff0..bbf9db1 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ A collection of GitHub action workflows. Built using the [reusable workflows](ht | [Gadget App Deployment](docs/gadget-deploy.md) | Gadget app deployment with push, test, and production deployment stages | | [Magento Cloud Deployment](docs/magento-cloud-deploy.md) | Magento Cloud deployment with optional NewRelic monitoring and CST reporting | | [Node Pull Request Checks](docs/node-pr.md) | Pull request quality checks for Node.js projects | +| [Node Publish Package](docs/node-publish.md) | Build, test and publish Node.js packages to npm via OIDC trusted publishing | | [Nx Serverless Deployment](docs/nx-serverless-deployment.md) | Serverless deployment workflow for Nx monorepos | | [PWA Deployment](docs/pwa-deployment.md) | Progressive Web Application deployment with S3 hosting, CloudFront CDN, multi-environment and multi-brand support | | [PHP Quality Checks](docs/php-quality-checks.md) | Static analysis, coding standards validation, and testing with coverage reporting | diff --git a/docs/node-publish.md b/docs/node-publish.md new file mode 100644 index 0000000..546c490 --- /dev/null +++ b/docs/node-publish.md @@ -0,0 +1,117 @@ +# Node Publish Package + +Builds, tests and publishes a Node.js package to npm using [OIDC trusted publishing](https://docs.npmjs.com/trusted-publishers). No long-lived npm publish token is required. + +#### **How it works** + +1. Checks the runner meets the trusted publishing requirements (npm >= 11.5.1, Node.js >= 22.14.0) and fails fast otherwise. +2. When triggered by a tag, checks the tag (with any leading `v` removed) matches the `package.json` version. +3. Installs dependencies with a frozen lockfile, then builds and tests using the configured package manager. +4. Runs the `prepublishOnly` script (if defined) and packs the package with the configured package manager, so `workspace:` / `catalog:` protocols and `publishConfig` overrides are applied. +5. Publishes the packed tarball with the npm CLI, which performs the OIDC token exchange and attaches provenance automatically. +6. Runs the `publish` and `postpublish` scripts (if defined), except on dry runs. + +Dependency caching is disabled, as release builds should never use caches. + +#### **Inputs** +| Name | Required | Type | Default | Description | +|---------------|----------|---------|--------------------|------------------------------------| +| package-manager | ❌ | string | yarn | Node package manager to use (`npm`, `yarn` or `pnpm`) | +| is-yarn-classic | ❌ | boolean | false | When `package-manager` is `yarn`, indicates the project uses a pre-Berry version of Yarn | +| pre-install-commands | ❌ | string | | Commands to run before dependency installation (e.g., configure registries, auth tokens) | +| build-command | ❌ | string | build | Command to override the build command | +| test-command | ❌ | string | test | Command to override the test command | +| skip-build | ❌ | boolean | false | If the build step should be skipped | +| skip-test | ❌ | boolean | false | If the test step should be skipped | +| package-directory | ❌ | string | . | Directory of the package to publish, relative to the repository root | +| registry-url | ❌ | string | https://registry.npmjs.org | Registry to publish to | +| dist-tag | ❌ | string | | npm dist-tag to publish under. When empty, derived from the `package.json` version (see below) | +| access | ❌ | string | | Package access level (`public` or `restricted`). Leave empty to use the registry default | +| skip-tag-version-check | ❌ | boolean | false | If the check that the pushed tag matches the `package.json` version should be skipped | +| dry-run | ❌ | boolean | false | If the package should be packed and validated without publishing | +| node-options | ❌ | string | | Value for `NODE_OPTIONS` env var (e.g., `--max-old-space-size=4096`) | + +#### **Dist-tag resolution** + +Unless `dist-tag` is set explicitly, it is derived from the `package.json` version so prereleases never move the `latest` tag: + +| package.json version | Published version | Dist-tag | +|----------------------|-------------------|----------| +| `1.1.1` | `1.1.1` | `latest` | +| `1.2.0-beta.1` | `1.2.0-beta.1` | `beta` | +| `2.0.0-rc.0+build.7` | `2.0.0-rc.0` (npm drops build metadata) | `rc` | +| `1.0.0-0` | — | fails: set `dist-tag` explicitly | + +The workflow fails before publishing if the resolved dist-tag does not start with a letter, as npm rejects dist-tags that are valid semver ranges. + +#### **Secrets** +| Name | Required | Description | +|---------------|----------|-------------------------------------| +| NPM_TOKEN | ❌ | NPM authentication token for installing from private registries. Not used for publishing | + +#### **Setting up trusted publishing** + +1. Ensure `.nvmrc` uses a Node.js release that bundles npm >= 11.5.1 (e.g., a current Node.js 24 release). +2. On npmjs.com, open the package's **Settings → Trusted Publisher** and add a GitHub Actions publisher. +3. For **Workflow filename**, enter the consumer repository's **calling** workflow (e.g., `release.yml`), **not** `node-publish.yml`. npm validates against the workflow that invokes this reusable workflow. +4. Grant `id-token: write` in the calling workflow, as shown below. + +The package must already exist on npm before a trusted publisher can be configured. + +#### Example Usage + +**Basic usage (publish on version tag):** +```yaml +# .github/workflows/release.yml +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + id-token: write + contents: read + +jobs: + publish: + uses: aligent/workflows/.github/workflows/node-publish.yml@main + with: + package-manager: npm +``` + +**pnpm monorepo package:** +```yaml +jobs: + publish: + uses: aligent/workflows/.github/workflows/node-publish.yml@main + with: + package-manager: pnpm + package-directory: packages/my-package + skip-tag-version-check: true +``` + +**With dependencies from a private registry:** +```yaml +jobs: + publish: + uses: aligent/workflows/.github/workflows/node-publish.yml@main + with: + package-manager: yarn + pre-install-commands: | + yarn config set npmScopes.aligent.npmRegistryServer "https://npm.corp.aligent.consulting" + yarn config set npmScopes.aligent.npmAuthToken "$NPM_TOKEN" + secrets: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} +``` + +**Pre-release under a custom dist-tag** (overrides the derived `beta`/`rc` tag): +```yaml +jobs: + publish: + uses: aligent/workflows/.github/workflows/node-publish.yml@main + with: + package-manager: npm + dist-tag: next +```