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 + +
+ Prepare Node.js Package 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 + +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. +
+ Node.js Release +
-## 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. + + + +[![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 + +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. | + + +# ![Icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJmZWF0aGVyIGZlYXRoZXItcGFja2FnZSIgY29sb3I9ImJsdWUiPjxsaW5lIHgxPSIxNi41IiB5MT0iOS40IiB4Mj0iNy41IiB5Mj0iNC4yMSI+PC9saW5lPjxwYXRoIGQ9Ik0yMSAxNlY4YTIgMiAwIDAgMC0xLTEuNzNsLTctNGEyIDIgMCAwIDAtMiAwbC03IDRBMiAyIDAgMCAwIDMgOHY4YTIgMiAwIDAgMCAxIDEuNzNsNyA0YTIgMiAwIDAgMCAyIDBsNy00QTIgMiAwIDAgMCAyMSAxNnoiPjwvcGF0aD48cG9seWxpbmUgcG9pbnRzPSIzLjI3IDYuOTYgMTIgMTIuMDEgMjAuNzMgNi45NiI+PC9wb2x5bGluZT48bGluZSB4MT0iMTIiIHkxPSIyMi4wOCIgeDI9IjEyIiB5Mj0iMTIiPjwvbGluZT48L3N2Zz4=) GitHub Action: Restore published Node.js package + +
+ Restore published Node.js package +
+ +--- + + + + +[![Marketplace](https://img.shields.io/badge/Marketplace-restore--published--node.js--package-blue?logo=github-actions)](https://github.com/marketplace/actions/restore-published-node.js-package) +[![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 + +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 @@ + + +# ![Icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJmZWF0aGVyIGZlYXRoZXItY2hlY2stY2lyY2xlIiBjb2xvcj0iYmx1ZSI+PHBhdGggZD0iTTIyIDExLjA4VjEyYTEwIDEwIDAgMSAxLTUuOTMtOS4xNCI+PC9wYXRoPjxwb2x5bGluZSBwb2ludHM9IjIyIDQgMTIgMTQuMDEgOSAxMS4wMSI+PC9wb2x5bGluZT48L3N2Zz4=) GitHub Action: Test release package + +
+ Test release package +
+ +--- + + + + +[![Marketplace](https://img.shields.io/badge/Marketplace-test--release--package-blue?logo=github-actions)](https://github.com/marketplace/actions/test-release-package) +[![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 + +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 @@ + + +# ![Icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJmZWF0aGVyIGZlYXRoZXItdGFnIiBjb2xvcj0iYmx1ZSI+PHBhdGggZD0iTTIwLjU5IDEzLjQxbC03LjE3IDcuMTdhMiAyIDAgMCAxLTIuODMgMEwyIDEyVjJoMTBsOC41OSA4LjU5YTIgMiAwIDAgMSAwIDIuODJ6Ij48L3BhdGg+PGxpbmUgeDE9IjciIHkxPSI3IiB4Mj0iNy4wMSIgeTI9IjciPjwvbGluZT48L3N2Zz4=) GitHub Action: Finalize Node.js release + +
+ Finalize Node.js release +
+ +--- + + + + +[![Marketplace](https://img.shields.io/badge/Marketplace-finalize--node.js--release-blue?logo=github-actions)](https://github.com/marketplace/actions/finalize-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 + +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 @@ + + +# ![Icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJmZWF0aGVyIGZlYXRoZXItZ2l0LWNvbW1pdCIgY29sb3I9ImJsdWUiPjxjaXJjbGUgY3g9IjEyIiBjeT0iMTIiIHI9IjQiPjwvY2lyY2xlPjxsaW5lIHgxPSIxLjA1IiB5MT0iMTIiIHgyPSI3IiB5Mj0iMTIiPjwvbGluZT48bGluZSB4MT0iMTcuMDEiIHkxPSIxMiIgeDI9IjIyLjk2IiB5Mj0iMTIiPjwvbGluZT48L3N2Zz4=) GitHub Action: Prepare Node.js package release + +
+ Prepare Node.js package release +
+ +--- + + + + +[![Marketplace](https://img.shields.io/badge/Marketplace-prepare--node.js--package--release-blue?logo=github-actions)](https://github.com/marketplace/actions/prepare-node.js-package-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 + +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 @@ + + +# ![Icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJmZWF0aGVyIGZlYXRoZXItY2hlY2stY2lyY2xlIiBjb2xvcj0iYmx1ZSI+PHBhdGggZD0iTTIyIDExLjA4VjEyYTEwIDEwIDAgMSAxLTUuOTMtOS4xNCI+PC9wYXRoPjxwb2x5bGluZSBwb2ludHM9IjIyIDQgMTIgMTQuMDEgOSAxMS4wMSI+PC9wb2x5bGluZT48L3N2Zz4=) GitHub Action: Verify release inputs + +
+ Verify release inputs +
+ +--- + + + + +[![Marketplace](https://img.shields.io/badge/Marketplace-verify--release--inputs-blue?logo=github-actions)](https://github.com/marketplace/actions/verify-release-inputs) +[![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 + +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