From 7e9524c8d33b605d6e1ec49f56b5de2a193e7c48 Mon Sep 17 00:00:00 2001 From: Emilien Escalle Date: Wed, 4 Nov 2026 15:28:33 +0100 Subject: [PATCH 1/5] feat(release): automate single-package npm releases with HoverKraft Prepare committed versions, consume an existing build, publish npm packages and GitHub release notes, and recover partial publication. Keep release validation and the consolidated decision record with the release workflow. --- .github/dependabot.yml | 1 + .github/workflows/__shared-ci.yml | 11 + .github/workflows/__test-release.yml | 185 ++++++ ...__test-workflow-continuous-integration.yml | 18 + .github/workflows/prepare-package-release.md | 94 +++ .github/workflows/prepare-package-release.yml | 64 +++ .github/workflows/release.md | 535 ++++++++++++++++-- .github/workflows/release.yml | 126 +++++ .gitignore | 4 +- Makefile | 4 +- README.md | 10 +- actions/package/publish/README.md | 30 +- actions/package/publish/action.yml | 38 +- actions/package/restore/.node-version | 1 + actions/package/restore/PackageRecovery.js | 72 +++ .../package/restore/PackageRecovery.test.js | 159 ++++++ actions/package/restore/README.md | 101 ++++ actions/package/restore/action.yml | 37 ++ actions/package/restore/package-lock.json | 30 + actions/package/restore/package.json | 11 + actions/package/test/README.md | 103 ++++ actions/package/test/action.yml | 47 ++ actions/release/finalize/.node-version | 1 + actions/release/finalize/README.md | 69 +++ actions/release/finalize/action.yml | 40 ++ actions/release/finalize/index.js | 63 +++ actions/release/finalize/index.test.js | 136 +++++ actions/release/finalize/package-lock.json | 134 +++++ actions/release/finalize/package.json | 11 + actions/release/prepare/.node-version | 1 + actions/release/prepare/README.md | 78 +++ actions/release/prepare/action.yml | 159 ++++++ actions/release/prepare/index.js | 191 +++++++ actions/release/prepare/index.test.js | 305 ++++++++++ actions/release/prepare/package-lock.json | 30 + actions/release/prepare/package.json | 11 + actions/release/verify/.node-version | 1 + actions/release/verify/README.md | 78 +++ actions/release/verify/action.yml | 37 ++ actions/release/verify/index.js | 116 ++++ actions/release/verify/index.test.js | 173 ++++++ actions/release/verify/package-lock.json | 10 + actions/release/verify/package.json | 8 + docs/README.md | 1 + docs/adr/0001-release-orchestration.md | 281 +++++++++ docs/development/dependency-updates.md | 2 +- scripts/test-packages.sh | 8 +- 47 files changed, 3565 insertions(+), 60 deletions(-) create mode 100644 .github/workflows/__test-release.yml create mode 100644 .github/workflows/prepare-package-release.md create mode 100644 .github/workflows/prepare-package-release.yml create mode 100644 .github/workflows/release.yml create mode 120000 actions/package/restore/.node-version create mode 100644 actions/package/restore/PackageRecovery.js create mode 100644 actions/package/restore/PackageRecovery.test.js create mode 100644 actions/package/restore/README.md create mode 100644 actions/package/restore/action.yml create mode 100644 actions/package/restore/package-lock.json create mode 100644 actions/package/restore/package.json create mode 100644 actions/package/test/README.md create mode 100644 actions/package/test/action.yml create mode 120000 actions/release/finalize/.node-version create mode 100644 actions/release/finalize/README.md create mode 100644 actions/release/finalize/action.yml create mode 100644 actions/release/finalize/index.js create mode 100644 actions/release/finalize/index.test.js create mode 100644 actions/release/finalize/package-lock.json create mode 100644 actions/release/finalize/package.json create mode 120000 actions/release/prepare/.node-version create mode 100644 actions/release/prepare/README.md create mode 100644 actions/release/prepare/action.yml create mode 100644 actions/release/prepare/index.js create mode 100644 actions/release/prepare/index.test.js create mode 100644 actions/release/prepare/package-lock.json create mode 100644 actions/release/prepare/package.json create mode 120000 actions/release/verify/.node-version create mode 100644 actions/release/verify/README.md create mode 100644 actions/release/verify/action.yml create mode 100644 actions/release/verify/index.js create mode 100644 actions/release/verify/index.test.js create mode 100644 actions/release/verify/package-lock.json create mode 100644 actions/release/verify/package.json create mode 100644 docs/adr/0001-release-orchestration.md 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-release.yml b/.github/workflows/__test-release.yml new file mode 100644 index 00000000..97dbbd53 --- /dev/null +++ b/.github/workflows/__test-release.yml @@ -0,0 +1,185 @@ +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 CI artifact ID + id: invalid-artifact + continue-on-error: true + uses: $/actions/release/verify + with: + packages: ${{ steps.fixture.outputs.packages }} + build-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 }} + skip-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 }} + name: ${{ steps.fixture.outputs.name }} + version: 1.2.3 + 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 }} + name: ${{ steps.fixture.outputs.name }} + version: 1.2.3 + 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 }} + name: ${{ steps.fixture.outputs.name }} + version: 1.2.3 + 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..e1cc54b5 100644 --- a/.github/workflows/__test-workflow-continuous-integration.yml +++ b/.github/workflows/__test-workflow-continuous-integration.yml @@ -82,6 +82,24 @@ 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 }} + name: prepared-fixture + version: 1.2.3 + 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: 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..d7024d55 100644 --- a/.github/workflows/release.md +++ b/.github/workflows/release.md @@ -1,49 +1,502 @@ -# 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 + +Package an existing build artifact and publish a Node.js release without rebuilding. +Call at the prepared tag with a build artifact from the same workflow run and commit. + +### Permissions + +- **`actions`**: `read` +- **`contents`**: `write` +- **`id-token`**: `write` +- **`pull-requests`**: `read` + + + +## Release guide + +Connect **prepare → build → release**. The caller owns manual and scheduled triggers +and chooses the job that produces the build artifact. The examples use the shared +CI workflow for building and testing; any job meeting the artifact requirements can +replace it. HoverKraft selects the version from Conventional Commits and generates +GitHub release notes. The release workflow packages the build output, tests the +tarball, publishes to npm and finalizes the GitHub release, with an optional AI summary. Consumers need no custom release scripts. + +[`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: 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 }} +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 }} + + build: + 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"]}' + + release: + needs: [prepare, build] + 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 }} + build-artifact-id: ${{ needs.build.outputs.build-artifact-id }} +``` + +The caller concurrency group serializes the whole pipeline. Keep it distinct from +those used by the reusable workflows. Any build producer can replace the example's +shared CI job if it meets the [artifact requirements](#build-once-and-test-the-package). +Shared CI wrappers can expose `build-artifact-id`; CI has no release package inputs. +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 +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 from the prepared tag because artifacts built earlier can embed an old +version. Include every generated package file in the artifact and preserve absolute +paths for the **default Ubuntu runner without a build container**. The shared CI +workflow already produces this layout; custom build jobs must provide the same +layout. Release verifies the artifact's run, source SHA and expiration, without +inspecting the producing job's name or workflow. Wire `needs` to the artifact +producer and any checks required by your project. Cross-run artifact reuse is unsupported. +Include generated declarations, static files copied into package output and any +needed documentation output. Remove npm publication from push CI when enabling +this manual and scheduled release caller. + +Dependency installation skips project and dependency lifecycle scripts by default. +If your build needs installation hooks, set `ignore-scripts: false` on the build +workflow or action and configure any script approvals required by your package +manager. Explicit build, lint and test commands still run. Preparation and +release packaging always disable lifecycle scripts; this opt-in cannot enable them. + +Release configures Node.js and calls [Package](../../actions/package/create/README.md) +to restore the build output and run `npm pack` without installing dependencies, +changing versions or running lifecycle hooks. Include files normally generated by +`prepare` or `prepack` in the build artifact. The tarball manifest must match the +expected public package and version. + +Use `package-tests` for installed-package checks. Commands run from +`working-directory` with `PACKAGE_TARBALL_PATH`, `PACKAGE_NAME` and `PACKAGE_VERSION`: + +```yaml +with: + package-tests: | + test_project=$(mktemp -d) + npm install --prefix "$test_project" --ignore-scripts "$PACKAGE_TARBALL_PATH" + "$test_project/node_modules/.bin/my-cli" --help +``` + +Install test dependencies explicitly. Tests may build a fixture consuming the package, +but must not change the release tarball. Package tests, `npm publish --dry-run` and +optional AI summaries all finish before npm publication. A dry run does not validate +npm authentication. + +## 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 existing build artifact and repeat validation. +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 +build 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 ``` -[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. +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 release 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 release workflow requires absolute artifact paths and restores them with +`build-artifact-path: /` on the default Ubuntu runner layout. The shared CI workflow +produces that layout; a custom producer must preserve the same paths. Standalone +Package calls can choose a different restoration path or omit the artifact inputs. + +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 release 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 `skip-existing: "true"` for the public npm registry. +It checks the exact version on every attempt, restores the original registry tarball +when present, and returns its local path for assets. + +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 +``` + +`release/verify/index.test.js` checks release tags and build 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 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 `skip-existing` enabled checks that `publish` +correctly composes `package/restore` and preserves unpublished package bytes. + +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** | - | +| **`build-artifact-id`** | Build artifact ID from this workflow run and the prepared tag's commit. The artifact must be unexpired and contain every generated package file with absolute paths preserved for the default Ubuntu runner layout. Any job can produce it. | **true** | **string** | - | +| **`working-directory`** | Project directory relative to the caller repository root, matching release preparation and the build artifact's source directory. | **false** | **string** | `.` | +| **`package-tests`** | Shell commands testing the tarball before publication. Runs from working-directory with PACKAGE_TARBALL_PATH, PACKAGE_NAME and PACKAGE_VERSION. | **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..d92bed5f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,126 @@ +# Package an existing build artifact and publish a Node.js release without rebuilding. +# Call at the prepared tag with a build artifact from the same workflow 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 + build-artifact-id: + description: Build artifact ID from this workflow run and the prepared tag's commit. The artifact must be unexpired and contain every generated package file with absolute paths preserved for the default Ubuntu runner layout. Any job can produce it. + type: string + required: true + working-directory: + description: Project directory relative to the caller repository root, matching release preparation and the build artifact's source directory. + type: string + default: . + package-tests: + description: Shell commands testing the tarball before publication. Runs from working-directory with PACKAGE_TARBALL_PATH, PACKAGE_NAME and PACKAGE_VERSION. + 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 }} + build-artifact-id: ${{ inputs.build-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: Configure the package runtime + id: setup-node + uses: $/actions/setup-node + with: + working-directory: ${{ inputs.working-directory }} + install-dependencies: "false" + - name: Package the build artifact + id: package + uses: $/actions/package/create + with: + working-directory: ${{ inputs.working-directory }} + build-artifact-id: ${{ inputs.build-artifact-id }} + build-artifact-path: / + artifact-name: npm-${{ fromJSON(inputs.packages)[0].slug }}-${{ github.run_id }}-${{ github.run_attempt }} + - uses: $/actions/package/restore + with: + name: ${{ fromJSON(inputs.packages)[0].name }} + version: ${{ fromJSON(inputs.packages)[0].version }} + tarball-path: ${{ steps.package.outputs.package-tarball-path }} + - name: Test installed package behavior + uses: $/actions/package/test + with: + commands: ${{ inputs.package-tests }} + working-directory: ${{ inputs.working-directory }} + tarball-path: ${{ steps.package.outputs.package-tarball-path }} + name: ${{ fromJSON(inputs.packages)[0].name }} + version: ${{ fromJSON(inputs.packages)[0].version }} + - name: Validate publication + uses: $/actions/package/publish + with: + package-tarball-artifact-id: ${{ steps.package.outputs.package-tarball-artifact-id }} + dry-run: "true" + provenance: "false" + - name: Publish missing npm version + id: publish + uses: $/actions/package/publish + with: + package-tarball-artifact-id: ${{ steps.package.outputs.package-tarball-artifact-id }} + skip-existing: "true" + tag: latest + - name: Prepare release asset + env: + PACKAGE_TARBALL: ${{ steps.publish.outputs.package-tarball-path }} + ASSET_NAME: ${{ fromJSON(inputs.packages)[0].slug }}-${{ fromJSON(inputs.packages)[0].version }}.tgz + run: | + mkdir -p "$RUNNER_TEMP/release-assets" + cp "$PACKAGE_TARBALL" "$RUNNER_TEMP/release-assets/$ASSET_NAME" + - uses: $/actions/release/finalize + with: + tag: ${{ github.ref_name }} 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..10715d0c 100644 --- a/actions/package/publish/README.md +++ b/actions/package/publish/README.md @@ -27,6 +27,10 @@ Publish a CI-produced Node.js package tarball to an npm-compatible registry ```yaml - uses: hoverkraft-tech/ci-github-nodejs/actions/package/publish@ with: + # Skip existing npmjs.org versions and restore their original tarball for release assets. Checks the registry again on every retry. + # Default: `false` + skip-existing: "false" + # Artifact ID of one package tarball uploaded by the package action # This input is required. package-tarball-artifact-id: "" @@ -60,15 +64,16 @@ 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** | +| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------ | ---------------------------- | +| **`skip-existing`** | Skip existing npmjs.org versions and restore their original tarball for release assets. Checks the registry again on every retry. | **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 +153,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 skip-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..f11d92cd --- /dev/null +++ b/actions/package/test/README.md @@ -0,0 +1,103 @@ + + +# ![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 release 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 release tarball. | **true** | - | +| **`name`** | Expected package name. | **true** | - | +| **`version`** | Expected package version. | **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 release tarball. + # This input is required. + tarball-path: "" + + # Expected package name. + # This input is required. + name: "" + + # Expected package version. + # This input is required. + version: "" +``` + + + + diff --git a/actions/package/test/action.yml b/actions/package/test/action.yml new file mode 100644 index 00000000..c43ac97a --- /dev/null +++ b/actions/package/test/action.yml @@ -0,0 +1,47 @@ +name: Test release package +description: Run consumer smoke tests against the release 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 release tarball. + required: true + name: + description: Expected package name. + required: true + version: + description: Expected package version. + 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 }} + PACKAGE_NAME: ${{ inputs.name }} + PACKAGE_VERSION: ${{ inputs.version }} + 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(); + 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 }, + }); + if (digest() !== expected) throw new Error('Package tests must not modify the release 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..85d41ea1 --- /dev/null +++ b/actions/release/finalize/README.md @@ -0,0 +1,69 @@ + + +# ![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 published npm tarballs staged in RUNNER_TEMP/release-assets against existing assets, then upload missing assets and publish the draft with HoverKraft release/update using GITHUB_TOKEN. + + + + +## Inputs + +| **Input** | **Description** | **Required** | **Default** | +| --------- | ---------------------- | ------------ | ----------- | +| **`tag`** | Committed release tag. | **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..df784dab --- /dev/null +++ b/actions/release/finalize/action.yml @@ -0,0 +1,40 @@ +name: Finalize Node.js release +description: Verify published npm tarballs staged in RUNNER_TEMP/release-assets against existing assets, then upload missing assets 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 +runs: + using: composite + steps: + - id: assets + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + ACTION_PATH: ${{ github.action_path }} + RELEASE_TAG: ${{ inputs.tag }} + ASSET_DIRECTORY: ${{ runner.temp }}/release-assets + with: + script: | + const releaseAssets = require(`${process.env.ACTION_PATH}/index.js`); + const { release, pendingDirectory, hasAssets } = await releaseAssets.prepareAssets({ github, glob, repo: context.repo, tag: process.env.RELEASE_TAG, directory: process.env.ASSET_DIRECTORY }); + 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..9796b65e --- /dev/null +++ b/actions/release/finalize/index.js @@ -0,0 +1,63 @@ +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, glob, repo, tag, directory }) { + 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 globber = await glob.create(path.join(directory, "*.tgz"), { + followSymbolicLinks: false, + matchDirectories: false, + implicitDescendants: false, + }); + const files = (await globber.glob()).filter((file) => + fs.lstatSync(file).isFile(), + ); + if (!files.length) + throw new Error("No package tarballs available to attach."); + const missing = []; + for (const file of files) { + const name = path.basename(file); + const data = fs.readFileSync(file); + const digest = `sha256:${createHash("sha256").update(data).digest("hex")}`; + const existing = assets.find((asset) => asset.name === name); + if (existing) { + if (existing.digest !== digest) + throw new Error( + `Release asset ${name} has a different or unavailable digest.`, + ); + continue; + } + if (!release.draft) + throw new Error(`Published release is missing expected asset ${name}.`); + missing.push({ name, data }); + } + const pendingDirectory = fs.mkdtempSync(path.join(directory, "pending-")); + for (const { name, data } of missing) + fs.writeFileSync(path.join(pendingDirectory, name), data); + return { release, pendingDirectory, hasAssets: missing.length > 0 }; +} + +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..48cc7e26 --- /dev/null +++ b/actions/release/finalize/index.test.js @@ -0,0 +1,136 @@ +const assert = require("node:assert/strict"); +const glob = require("@actions/glob"); +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"); + fs.writeFileSync(path.join(root, "example-core-1.2.0.tgz"), 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, + glob, + repo: { owner: "example", repo: "project" }, + tag: "v1.2.0", + directory: root, + }, + }; +} + +test("stages only missing package bytes for HoverKraft upload 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, + ); + 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.deepEqual(fs.readdirSync(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("partial asset recovery passes only missing tarballs to the uploader", async (t) => { + const f = assetsFixture(t); + const name = "example-cli-1.2.0.tgz"; + fs.writeFileSync(path.join(f.args.directory, name), "second package"); + f.assets.push({ + name: "example-core-1.2.0.tgz", + digest: `sha256:${createHash("sha256").update(f.data).digest("hex")}`, + }); + const result = await releaseAssets.prepareAssets(f.args); + assert.equal(result.hasAssets, true); + assert.deepEqual(fs.readdirSync(result.pendingDirectory), [name]); + assert.equal( + fs.readFileSync(path.join(result.pendingDirectory, name), "utf8"), + "second package", + ); +}); + +test("asset discovery excludes nested tarballs, directories and symbolic links", async (t) => { + const f = assetsFixture(t); + const nested = path.join(f.args.directory, "previous-attempt"); + fs.mkdirSync(nested); + fs.writeFileSync(path.join(nested, "ignored.tgz"), "previous attempt"); + fs.mkdirSync(path.join(f.args.directory, "directory.tgz")); + fs.symlinkSync( + path.join(nested, "ignored.tgz"), + path.join(f.args.directory, "link.tgz"), + ); + const result = await releaseAssets.prepareAssets(f.args); + assert.deepEqual(fs.readdirSync(result.pendingDirectory), [ + "example-core-1.2.0.tgz", + ]); + fs.unlinkSync(path.join(f.args.directory, "example-core-1.2.0.tgz")); + await assert.rejects( + releaseAssets.prepareAssets(f.args), + /No package tarballs/, + ); +}); + +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..471f9154 --- /dev/null +++ b/actions/release/finalize/package-lock.json @@ -0,0 +1,134 @@ +{ + "name": "release-finalize-tests", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "release-finalize-tests", + "devDependencies": { + "@actions/glob": "^0.4.0" + } + }, + "node_modules/@actions/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz", + "integrity": "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/exec": "^1.1.1", + "@actions/http-client": "^2.0.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/glob": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@actions/glob/-/glob-0.4.0.tgz", + "integrity": "sha512-+eKIGFhsFa4EBwaf/GMyzCdWrXWymGXfFmZU3FHQvYS8mPcHtTtZONbkcqqUMzw9mJ/pImEBFET1JNifhqGsAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/core": "^1.9.1", + "minimatch": "^3.0.4" + } + }, + "node_modules/@actions/http-client": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.3.tgz", + "integrity": "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "^0.0.6", + "undici": "^5.25.4" + } + }, + "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" + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + } + } +} diff --git a/actions/release/finalize/package.json b/actions/release/finalize/package.json new file mode 100644 index 00000000..7d158e4c --- /dev/null +++ b/actions/release/finalize/package.json @@ -0,0 +1,11 @@ +{ + "name": "release-finalize-tests", + "private": true, + "scripts": { + "test": "node --test", + "test:ci": "node --test" + }, + "devDependencies": { + "@actions/glob": "^0.4.0" + } +} 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..e599b836 --- /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 build artifact source before creating release state. + + + + +## Inputs + +| **Input** | **Description** | **Required** | **Default** | +| ----------------------- | ------------------------------------------------------------------------------------- | ------------ | ----------- | +| **`packages`** | JSON array containing one public root package from release preparation. | **true** | - | +| **`build-artifact-id`** | Build 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..7f88dd0f --- /dev/null +++ b/actions/release/verify/action.yml @@ -0,0 +1,37 @@ +name: Verify release inputs +description: Verify the prepared npm package and build 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 + build-artifact-id: + description: Build 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 }} + BUILD_ARTIFACT_ID: ${{ inputs.build-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.verifyBuild({ github, context, buildArtifactId: process.env.BUILD_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..2553a7da --- /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 verifyBuild({ github, context, buildArtifactId }) { + if ( + !/^[1-9][0-9]*$/.test(buildArtifactId) || + !Number.isSafeInteger(Number(buildArtifactId)) + ) + throw new Error("Expected one positive build artifact ID."); + const { data: artifact } = await github.rest.actions.getArtifact({ + ...context.repo, + artifact_id: Number(buildArtifactId), + }); + if ( + artifact.expired || + artifact.workflow_run?.id !== context.runId || + artifact.workflow_run?.head_sha !== context.sha + ) + throw new Error( + "Build artifact must be unexpired and belong to this run and prepared commit.", + ); +} + +module.exports = { parsePackages, verifyReleases, verifyBuild }; diff --git a/actions/release/verify/index.test.js b/actions/release/verify/index.test.js new file mode 100644 index 00000000..e69872c4 --- /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 verifyBuild = (data = artifact, buildArtifactId = "10") => + releaseVerification.verifyBuild({ + context, + buildArtifactId, + 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 build artifact from this run and prepared commit, including reruns", async () => { + await assert.doesNotReject(verifyBuild()); +}); + +test("rejects expired builds, 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(verifyBuild(data), /this run and prepared commit/); + } +}); + +test("rejects missing, multiple or invalid build artifact IDs before downloading", async () => { + for (const id of ["", "0", "1,2", "NaN", "-1", "9007199254740992"]) { + await assert.rejects( + verifyBuild(artifact, id), + /positive build artifact ID/, + ); + } +}); + +test("missing or inaccessible build artifacts stop release", async () => { + const github = { + rest: { + actions: { + getArtifact: async () => { + throw new Error("Artifact not found"); + }, + }, + }, + }; + await assert.rejects( + releaseVerification.verifyBuild({ github, context, buildArtifactId: "10" }), + /Artifact not found/, + ); +}); + +test("release accepts one prepared npm package without CI packaging metadata", () => { + 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..289b33c0 --- /dev/null +++ b/docs/adr/0001-release-orchestration.md @@ -0,0 +1,281 @@ +# 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 and tests, and release packages and 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 → build → release** as separate stages connected by the caller +through `needs`. Use the shared CI workflow as the recommended build 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 never builds. A caller-selected job produces the build artifact from +the prepared commit. Shared CI remains one producer, with its existing +`build-artifact-id` output and no release package inputs or tarball outputs. +Release consumes that artifact without inspecting the producing job's identity. +Callers wire `needs` to the producer and whichever checks their project requires; +artifact metadata alone cannot prove that checks passed. + +Release verifies that the build artifact is unexpired and belongs to the same run +and source SHA, then restores the generated files. Use the existing Package action +with `prebuilt: true`: npm packaging skips dependency installation, forbids version +overrides and disables lifecycle hooks. Generation normally performed by `prepare` +or `prepack` must run before packaging and be included in the artifact. Release never rebuilds. + +Verify the tarball's public package identity and version. Run consumer `package-tests` +with the tarball path and package identity in environment variables, then check that +the tarball bytes are unchanged. Package checks, publication dry runs and optional +AI summaries must finish before npm publication. Smoke tests may build a fixture +that consumes the tarball; that does not rebuild the released package. + +### 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-job rerun can reuse existing build artifacts 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 build artifact layout, 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. 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 From b2836937a319f2942dcb810fc43c1c67874820c1 Mon Sep 17 00:00:00 2001 From: Emilien Escalle Date: Wed, 4 Nov 2026 16:59:02 +0100 Subject: [PATCH 2/5] refactor(release): publish package artifacts validated by CI --- .../__test-action-package-publish.yml | 14 +- .github/workflows/__test-release.yml | 10 +- ...__test-workflow-continuous-integration.yml | 36 ++++- .github/workflows/continuous-integration.md | 23 ++- .github/workflows/continuous-integration.yml | 63 ++++++++ .github/workflows/release.md | 134 ++++++++++-------- .github/workflows/release.yml | 54 ++----- actions/package/publish/README.md | 10 +- actions/package/publish/action.yml | 33 ++++- actions/package/test/README.md | 16 +-- actions/package/test/action.yml | 23 ++- actions/release/verify/README.md | 12 +- actions/release/verify/action.yml | 10 +- actions/release/verify/index.js | 14 +- actions/release/verify/index.test.js | 26 ++-- docs/adr/0001-release-orchestration.md | 54 ++++--- 16 files changed, 330 insertions(+), 202 deletions(-) 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 index 97dbbd53..bba679f7 100644 --- a/.github/workflows/__test-release.yml +++ b/.github/workflows/__test-release.yml @@ -75,13 +75,13 @@ jobs: 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 CI artifact ID + - 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 }} - build-artifact-id: "0" + package-tarball-artifact-id: "0" - name: Act - Reject a mismatched tarball version id: mismatched-version continue-on-error: true @@ -145,8 +145,6 @@ jobs: with: working-directory: actions/package/test tarball-path: ${{ steps.fixture.outputs.tarball }} - name: ${{ steps.fixture.outputs.name }} - version: 1.2.3 commands: | test "$PWD" = "$GITHUB_WORKSPACE/actions/package/test" test "$PACKAGE_NAME" = "$EXPECTED_PACKAGE_NAME" @@ -161,8 +159,6 @@ jobs: uses: $/actions/package/test with: tarball-path: ${{ steps.fixture.outputs.tarball }} - name: ${{ steps.fixture.outputs.name }} - version: 1.2.3 commands: exit 7 - name: Act - Reject modified tarball bytes id: modified-tarball @@ -170,8 +166,6 @@ jobs: uses: $/actions/package/test with: tarball-path: ${{ steps.fixture.outputs.tarball }} - name: ${{ steps.fixture.outputs.name }} - version: 1.2.3 commands: printf changed >> "$PACKAGE_TARBALL_PATH" - name: Assert - Smoke checks ran and rejected both failures env: diff --git a/.github/workflows/__test-workflow-continuous-integration.yml b/.github/workflows/__test-workflow-continuous-integration.yml index e1cc54b5..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: @@ -87,8 +111,6 @@ jobs: with: working-directory: tests/npm tarball-path: ${{ steps.package.outputs.package-tarball-path }} - name: prepared-fixture - version: 1.2.3 commands: | node <<'NODE' const assert = require('node:assert/strict'); @@ -129,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/release.md b/.github/workflows/release.md index d7024d55..07853e69 100644 --- a/.github/workflows/release.md +++ b/.github/workflows/release.md @@ -21,8 +21,8 @@ ## Overview -Package an existing build artifact and publish a Node.js release without rebuilding. -Call at the prepared tag with a build artifact from the same workflow run and commit. +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 @@ -35,12 +35,13 @@ Call at the prepared tag with a build artifact from the same workflow run and co ## Release guide -Connect **prepare → build → release**. The caller owns manual and scheduled triggers -and chooses the job that produces the build artifact. The examples use the shared -CI workflow for building and testing; any job meeting the artifact requirements can -replace it. HoverKraft selects the version from Conventional Commits and generates -GitHub release notes. The release workflow packages the build output, tests the -tarball, publishes to npm and finalizes the GitHub release, with an optional AI summary. Consumers need no custom release scripts. +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) @@ -105,7 +106,7 @@ jobs: secrets: github-app-key: ${{ secrets.CI_BOT_APP_PRIVATE_KEY }} - build: + ci: needs: prepare if: needs.prepare.outputs.status == 'ready' uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/continuous-integration.yml@ @@ -117,9 +118,10 @@ jobs: id-token: write with: build: '{"commands":["build"],"artifact":["dist"]}' + package: true release: - needs: [prepare, build] + needs: [prepare, ci] uses: hoverkraft-tech/ci-github-nodejs/.github/workflows/release.yml@ permissions: actions: read @@ -128,13 +130,15 @@ jobs: id-token: write with: packages: ${{ needs.prepare.outputs.packages }} - build-artifact-id: ${{ needs.build.outputs.build-artifact-id }} + 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 build producer can replace the example's +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 can expose `build-artifact-id`; CI has no release package inputs. +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. @@ -233,44 +237,50 @@ or summary generation failures stop publication. Already published notes are pre ## Build once and test the package -Build from the prepared tag because artifacts built earlier can embed an old -version. Include every generated package file in the artifact and preserve absolute -paths for the **default Ubuntu runner without a build container**. The shared CI -workflow already produces this layout; custom build jobs must provide the same -layout. Release verifies the artifact's run, source SHA and expiration, without -inspecting the producing job's name or workflow. Wire `needs` to the artifact -producer and any checks required by your project. Cross-run artifact reuse is unsupported. -Include generated declarations, static files copied into package output and any -needed documentation output. Remove npm publication from push CI when enabling -this manual and scheduled release caller. +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` on the build -workflow or action and configure any script approvals required by your package -manager. Explicit build, lint and test commands still run. Preparation and -release packaging always disable lifecycle scripts; this opt-in cannot enable them. +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. -Release configures Node.js and calls [Package](../../actions/package/create/README.md) -to restore the build output and run `npm pack` without installing dependencies, -changing versions or running lifecycle hooks. Include files normally generated by -`prepare` or `prepack` in the build artifact. The tarball manifest must match the -expected public package and version. - -Use `package-tests` for installed-package checks. Commands run from -`working-directory` with `PACKAGE_TARBALL_PATH`, `PACKAGE_NAME` and `PACKAGE_VERSION`: +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 ``` -Install test dependencies explicitly. Tests may build a fixture consuming the package, -but must not change the release tarball. Package tests, `npm publish --dry-run` and -optional AI summaries all finish before npm publication. A dry run does not validate -npm authentication. +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 @@ -287,11 +297,12 @@ 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 existing build artifact and repeat validation. +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 -build artifacts fail rather than overwriting data or selecting another run. +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: @@ -324,7 +335,7 @@ handles this for one public npm package and dispatches the caller at the tag. ### Packaging and publication -For a custom release job, check out the prepared source tag and configure Node.js +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. @@ -337,10 +348,11 @@ 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 release workflow requires absolute artifact paths and restores them with -`build-artifact-path: /` on the default Ubuntu runner layout. The shared CI workflow -produces that layout; a custom producer must preserve the same paths. Standalone -Package calls can choose a different restoration path or omit the artifact inputs. +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 @@ -363,7 +375,7 @@ publishing an npm library. Keep these consumers on the appropriate 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 release package](../../actions/package/test/README.md) +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. @@ -394,7 +406,7 @@ npm ci --ignore-scripts --prefix actions/release/prepare npm test --prefix actions/release/prepare ``` -`release/verify/index.test.js` checks release tags and build artifact +`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 @@ -417,11 +429,14 @@ 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 artifact IDs, mismatched tarball versions, the unpublished registry output, +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 `skip-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 @@ -447,17 +462,16 @@ serves as its template; regeneration preserves that authored content. ### Workflow Call Inputs -| **Input** | **Description** | **Required** | **Type** | **Default** | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ---------- | ----------- | -| **`packages`** | JSON array containing one public npm package from prepare-package-release. | **true** | **string** | - | -| **`build-artifact-id`** | Build artifact ID from this workflow run and the prepared tag's commit. The artifact must be unexpired and contain every generated package file with absolute paths preserved for the default Ubuntu runner layout. Any job can produce it. | **true** | **string** | - | -| **`working-directory`** | Project directory relative to the caller repository root, matching release preparation and the build artifact's source directory. | **false** | **string** | `.` | -| **`package-tests`** | Shell commands testing the tarball before publication. Runs from working-directory with PACKAGE_TARBALL_PATH, PACKAGE_NAME and PACKAGE_VERSION. | **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** | - | +| **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** | - | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d92bed5f..c890772c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,5 @@ -# Package an existing build artifact and publish a Node.js release without rebuilding. -# Call at the prepared tag with a build artifact from the same workflow run and commit. +# 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: @@ -9,18 +9,15 @@ on: description: JSON array containing one public npm package from prepare-package-release. type: string required: true - build-artifact-id: - description: Build artifact ID from this workflow run and the prepared tag's commit. The artifact must be unexpired and contain every generated package file with absolute paths preserved for the default Ubuntu runner layout. Any job can produce it. + 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 and the build artifact's source directory. + description: Project directory relative to the caller repository root, matching release preparation. type: string default: . - package-tests: - description: Shell commands testing the tarball before publication. Runs from working-directory with PACKAGE_TARBALL_PATH, PACKAGE_NAME and PACKAGE_VERSION. - type: string - default: "" changelog-summary: description: | Optional JSON configuration forwarded unchanged to ci-github-publish/actions/release/create. @@ -60,7 +57,7 @@ jobs: uses: $/actions/release/verify with: packages: ${{ inputs.packages }} - build-artifact-id: ${{ inputs.build-artifact-id }} + package-tarball-artifact-id: ${{ inputs.package-tarball-artifact-id }} working-directory: ${{ inputs.working-directory }} - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -74,44 +71,13 @@ jobs: changelog-summary: ${{ secrets.changelog-summary || inputs.changelog-summary }} name: Create draft and optional AI summary if: steps.verify.outputs.published != 'true' - - name: Configure the package runtime - id: setup-node - uses: $/actions/setup-node - with: - working-directory: ${{ inputs.working-directory }} - install-dependencies: "false" - - name: Package the build artifact - id: package - uses: $/actions/package/create - with: - working-directory: ${{ inputs.working-directory }} - build-artifact-id: ${{ inputs.build-artifact-id }} - build-artifact-path: / - artifact-name: npm-${{ fromJSON(inputs.packages)[0].slug }}-${{ github.run_id }}-${{ github.run_attempt }} - - uses: $/actions/package/restore - with: - name: ${{ fromJSON(inputs.packages)[0].name }} - version: ${{ fromJSON(inputs.packages)[0].version }} - tarball-path: ${{ steps.package.outputs.package-tarball-path }} - - name: Test installed package behavior - uses: $/actions/package/test - with: - commands: ${{ inputs.package-tests }} - working-directory: ${{ inputs.working-directory }} - tarball-path: ${{ steps.package.outputs.package-tarball-path }} - name: ${{ fromJSON(inputs.packages)[0].name }} - version: ${{ fromJSON(inputs.packages)[0].version }} - - name: Validate publication - uses: $/actions/package/publish - with: - package-tarball-artifact-id: ${{ steps.package.outputs.package-tarball-artifact-id }} - dry-run: "true" - provenance: "false" - name: Publish missing npm version id: publish uses: $/actions/package/publish with: - package-tarball-artifact-id: ${{ steps.package.outputs.package-tarball-artifact-id }} + package-tarball-artifact-id: ${{ inputs.package-tarball-artifact-id }} + name: ${{ fromJSON(inputs.packages)[0].name }} + version: ${{ fromJSON(inputs.packages)[0].version }} skip-existing: "true" tag: latest - name: Prepare release asset diff --git a/actions/package/publish/README.md b/actions/package/publish/README.md index 10715d0c..047c6733 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,12 @@ 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: "" + # Skip existing npmjs.org versions and restore their original tarball for release assets. Checks the registry again on every retry. # Default: `false` skip-existing: "false" @@ -66,6 +72,8 @@ Publish a CI-produced Node.js package tarball to an npm-compatible registry | **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** | - | | **`skip-existing`** | Skip existing npmjs.org versions and restore their original tarball for release assets. Checks the registry again on every retry. | **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` | diff --git a/actions/package/publish/action.yml b/actions/package/publish/action.yml index 569cf0c9..1785df2d 100644 --- a/actions/package/publish/action.yml +++ b/actions/package/publish/action.yml @@ -1,11 +1,17 @@ name: "Publish" -description: "Publish a CI-produced Node.js package tarball to an npm-compatible registry" +description: "Publish an existing Node.js package tarball to an npm-compatible registry" author: hoverkraft branding: icon: upload-cloud color: blue inputs: + name: + description: Expected package name, paired with version to reject an unintended release tarball before publication. + default: "" + version: + description: Expected package version, paired with name to reject an unintended release tarball before publication. + default: "" skip-existing: description: Skip existing npmjs.org versions and restore their original tarball for release assets. Checks the registry again on every retry. default: "false" @@ -56,11 +62,17 @@ runs: SKIP_EXISTING: ${{ inputs.skip-existing }} REGISTRY_URL: ${{ inputs.registry-url }} RUNNER_TEMP: ${{ runner.temp }} + EXPECTED_NAME: ${{ inputs.name }} + EXPECTED_VERSION: ${{ inputs.version }} with: script: | const fs = require('node:fs'); const path = require('node:path'); + if (Boolean(process.env.EXPECTED_NAME) !== Boolean(process.env.EXPECTED_VERSION)) { + return core.setFailed('name and version must be provided together.'); + } + if (!/^[1-9][0-9]*$/.test(process.env.PACKAGE_TARBALL_ARTIFACT_ID || '')) { return core.setFailed('package-tarball-artifact-id must be a single positive artifact ID'); } @@ -141,16 +153,29 @@ runs: core.setOutput('path', packageTarballPaths[0]); - id: package-identity - name: Read package identity for recovery - if: inputs.skip-existing == 'true' + name: Verify package identity uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: PACKAGE_TARBALL_PATH: ${{ steps.package-tarball.outputs.path }} + EXPECTED_NAME: ${{ inputs.name }} + EXPECTED_VERSION: ${{ inputs.version }} with: script: | const tarballPath = process.env.PACKAGE_TARBALL_PATH; const { stdout } = await exec.getExecOutput('tar', ['-xOf', tarballPath, 'package/package.json'], { silent: true }); - const { name, version } = JSON.parse(stdout); + const manifest = JSON.parse(stdout); + const { name, version } = manifest; + if (typeof name !== 'string' || !name || typeof version !== 'string' || !version || manifest.private) { + return core.setFailed('Publication requires a named, versioned, non-private package.'); + } + if (process.env.EXPECTED_NAME && (name !== process.env.EXPECTED_NAME || version !== process.env.EXPECTED_VERSION)) { + return core.setFailed('Package tarball must match the expected release name and version.'); + } + for (const field of ['dependencies', 'optionalDependencies', 'peerDependencies']) { + if (Object.values(manifest[field] || {}).some(value => typeof value === 'string' && value.startsWith('workspace:'))) { + return core.setFailed('Package tarball contains unresolved workspace dependency references.'); + } + } core.setOutput('name', name); core.setOutput('version', version); diff --git a/actions/package/test/README.md b/actions/package/test/README.md index f11d92cd..549b9265 100644 --- a/actions/package/test/README.md +++ b/actions/package/test/README.md @@ -22,7 +22,7 @@ ## Overview -Run consumer smoke tests against the release tarball and reject changes to its bytes. +Run consumer smoke tests against the package tarball and reject changes to its bytes. @@ -33,9 +33,7 @@ Run consumer smoke tests against the release tarball and reject changes to its b | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ----------- | | **`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 release tarball. | **true** | - | -| **`name`** | Expected package name. | **true** | - | -| **`version`** | Expected package version. | **true** | - | +| **`tarball-path`** | Absolute path to the package tarball. | **true** | - | @@ -85,17 +83,9 @@ This documentation was automatically generated by [CI Dokumentor](https://github # Default: `.` working-directory: . - # Absolute path to the release tarball. + # Absolute path to the package tarball. # This input is required. tarball-path: "" - - # Expected package name. - # This input is required. - name: "" - - # Expected package version. - # This input is required. - version: "" ``` diff --git a/actions/package/test/action.yml b/actions/package/test/action.yml index c43ac97a..1bc807d5 100644 --- a/actions/package/test/action.yml +++ b/actions/package/test/action.yml @@ -1,5 +1,5 @@ -name: Test release package -description: Run consumer smoke tests against the release tarball and reject changes to its bytes. +name: Test package +description: Run consumer smoke tests against the package tarball and reject changes to its bytes. author: hoverkraft branding: icon: check-circle @@ -12,13 +12,7 @@ inputs: description: Project or workspace root used by package tests. default: . tarball-path: - description: Absolute path to the release tarball. - required: true - name: - description: Expected package name. - required: true - version: - description: Expected package version. + description: Absolute path to the package tarball. required: true runs: using: composite @@ -29,8 +23,6 @@ runs: PACKAGE_TEST_COMMANDS: ${{ inputs.commands }} WORKING_DIRECTORY: ${{ inputs.working-directory }} PACKAGE_TARBALL_PATH: ${{ inputs.tarball-path }} - PACKAGE_NAME: ${{ inputs.name }} - PACKAGE_VERSION: ${{ inputs.version }} with: script: | const fs = require('node:fs'); @@ -40,8 +32,13 @@ runs: 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 }, + 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 release tarball.'); + if (digest() !== expected) throw new Error('Package tests must not modify the package tarball.'); diff --git a/actions/release/verify/README.md b/actions/release/verify/README.md index e599b836..371d3a4d 100644 --- a/actions/release/verify/README.md +++ b/actions/release/verify/README.md @@ -22,18 +22,18 @@ ## Overview -Verify the prepared npm package and build artifact source before creating release state. +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** | - | -| **`build-artifact-id`** | Build 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** | `.` | +| **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** | `.` | diff --git a/actions/release/verify/action.yml b/actions/release/verify/action.yml index 7f88dd0f..d93e6af7 100644 --- a/actions/release/verify/action.yml +++ b/actions/release/verify/action.yml @@ -1,5 +1,5 @@ name: Verify release inputs -description: Verify the prepared npm package and build artifact source before creating release state. +description: Verify the prepared npm package and package tarball artifact source before creating release state. author: hoverkraft branding: icon: check-circle @@ -8,8 +8,8 @@ inputs: packages: description: JSON array containing one public root package from release preparation. required: true - build-artifact-id: - description: Build artifact ID from this run and prepared commit, regardless of the producing job. + 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. @@ -26,12 +26,12 @@ runs: env: ACTION_PATH: ${{ github.action_path }} PACKAGES: ${{ inputs.packages }} - BUILD_ARTIFACT_ID: ${{ inputs.build-artifact-id }} + 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.verifyBuild({ github, context, buildArtifactId: process.env.BUILD_ARTIFACT_ID }); + 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 index 2553a7da..deaea44e 100644 --- a/actions/release/verify/index.js +++ b/actions/release/verify/index.js @@ -93,15 +93,15 @@ async function verifyReleases({ return [{ tag: sourceTag, published: Boolean(release && !release.draft) }]; } -async function verifyBuild({ github, context, buildArtifactId }) { +async function verifyArtifact({ github, context, artifactId }) { if ( - !/^[1-9][0-9]*$/.test(buildArtifactId) || - !Number.isSafeInteger(Number(buildArtifactId)) + !/^[1-9][0-9]*$/.test(artifactId) || + !Number.isSafeInteger(Number(artifactId)) ) - throw new Error("Expected one positive build artifact ID."); + throw new Error("Expected one positive package tarball artifact ID."); const { data: artifact } = await github.rest.actions.getArtifact({ ...context.repo, - artifact_id: Number(buildArtifactId), + artifact_id: Number(artifactId), }); if ( artifact.expired || @@ -109,8 +109,8 @@ async function verifyBuild({ github, context, buildArtifactId }) { artifact.workflow_run?.head_sha !== context.sha ) throw new Error( - "Build artifact must be unexpired and belong to this run and prepared commit.", + "Package tarball artifact must be unexpired and belong to this run and prepared commit.", ); } -module.exports = { parsePackages, verifyReleases, verifyBuild }; +module.exports = { parsePackages, verifyReleases, verifyArtifact }; diff --git a/actions/release/verify/index.test.js b/actions/release/verify/index.test.js index e69872c4..6618b303 100644 --- a/actions/release/verify/index.test.js +++ b/actions/release/verify/index.test.js @@ -19,10 +19,10 @@ const artifact = { expired: false, workflow_run: { id: 42, head_sha: context.sha }, }; -const verifyBuild = (data = artifact, buildArtifactId = "10") => - releaseVerification.verifyBuild({ +const verifyArtifact = (data = artifact, artifactId = "10") => + releaseVerification.verifyArtifact({ context, - buildArtifactId, + artifactId, github: { rest: { actions: { @@ -81,31 +81,31 @@ test("release verifies the source tag, committed version and publication state", await assert.rejects(verify(), /prepared source commit/); }); -test("release accepts a build artifact from this run and prepared commit, including reruns", async () => { - await assert.doesNotReject(verifyBuild()); +test("release accepts a package tarball artifact from this run and prepared commit, including reruns", async () => { + await assert.doesNotReject(verifyArtifact()); }); -test("rejects expired builds, other runs, older commits and unavailable source identity", async () => { +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(verifyBuild(data), /this run and prepared commit/); + await assert.rejects(verifyArtifact(data), /this run and prepared commit/); } }); -test("rejects missing, multiple or invalid build artifact IDs before downloading", async () => { +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( - verifyBuild(artifact, id), - /positive build artifact ID/, + verifyArtifact(artifact, id), + /positive package tarball artifact ID/, ); } }); -test("missing or inaccessible build artifacts stop release", async () => { +test("missing or inaccessible package tarball artifacts stop release", async () => { const github = { rest: { actions: { @@ -116,12 +116,12 @@ test("missing or inaccessible build artifacts stop release", async () => { }, }; await assert.rejects( - releaseVerification.verifyBuild({ github, context, buildArtifactId: "10" }), + releaseVerification.verifyArtifact({ github, context, artifactId: "10" }), /Artifact not found/, ); }); -test("release accepts one prepared npm package without CI packaging metadata", () => { +test("release accepts one prepared npm package with its prepared identity", () => { assert.deepEqual( releaseVerification.parsePackages(JSON.stringify([item]), item.version), [item], diff --git a/docs/adr/0001-release-orchestration.md b/docs/adr/0001-release-orchestration.md index 289b33c0..a981e312 100644 --- a/docs/adr/0001-release-orchestration.md +++ b/docs/adr/0001-release-orchestration.md @@ -22,8 +22,8 @@ orchestration scripts. ### Composition and delivery scope -Expose **prepare → build → release** as separate stages connected by the caller -through `needs`. Use the shared CI workflow as the recommended build producer, +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. @@ -97,24 +97,27 @@ or an enablement flag to the Node.js workflow. ### Build artifacts and packaging -Preparation never builds. A caller-selected job produces the build artifact from -the prepared commit. Shared CI remains one producer, with its existing -`build-artifact-id` output and no release package inputs or tarball outputs. -Release consumes that artifact without inspecting the producing job's identity. -Callers wire `needs` to the producer and whichever checks their project requires; -artifact metadata alone cannot prove that checks passed. - -Release verifies that the build artifact is unexpired and belongs to the same run -and source SHA, then restores the generated files. Use the existing Package action -with `prebuilt: true`: npm packaging skips dependency installation, forbids version -overrides and disables lifecycle hooks. Generation normally performed by `prepare` -or `prepack` must run before packaging and be included in the artifact. Release never rebuilds. - -Verify the tarball's public package identity and version. Run consumer `package-tests` -with the tarball path and package identity in environment variables, then check that -the tarball bytes are unchanged. Package checks, publication dry runs and optional -AI summaries must finish before npm publication. Smoke tests may build a fixture -that consumes the tarball; that does not rebuild the released package. +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 @@ -124,7 +127,7 @@ published, restore its original registry tarball and verify its SHA-512 integrit 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-job rerun can reuse existing build artifacts from the same run. A new +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. @@ -153,7 +156,7 @@ 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 build artifact layout, without a build container. Custom registries, +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. @@ -279,3 +282,10 @@ the alternatives and observations below preserve their history, not new validati 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. From ecdd4704770dbf8c4a796a1841ec5fa673d0a975 Mon Sep 17 00:00:00 2001 From: Emilien Escalle Date: Wed, 4 Nov 2026 17:10:52 +0100 Subject: [PATCH 3/5] docs: align release ownership in the decision record --- docs/adr/0001-release-orchestration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/0001-release-orchestration.md b/docs/adr/0001-release-orchestration.md index a981e312..72e5ab33 100644 --- a/docs/adr/0001-release-orchestration.md +++ b/docs/adr/0001-release-orchestration.md @@ -13,7 +13,7 @@ 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 and tests, and release packages and publishes. Build +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. From 36225f8a21727f1e332f6ea277aa2cd37511ba8e Mon Sep 17 00:00:00 2001 From: Emilien Escalle Date: Wed, 4 Nov 2026 18:20:53 +0100 Subject: [PATCH 4/5] refactor(package): rename publication recovery input --- .github/workflows/__test-release.yml | 2 +- .github/workflows/release.md | 7 +++--- .github/workflows/release.yml | 2 +- actions/package/publish/README.md | 34 +++++++++++++------------- actions/package/publish/action.yml | 16 ++++++------ docs/adr/0001-release-orchestration.md | 4 +++ 6 files changed, 35 insertions(+), 30 deletions(-) diff --git a/.github/workflows/__test-release.yml b/.github/workflows/__test-release.yml index bba679f7..aa800ad4 100644 --- a/.github/workflows/__test-release.yml +++ b/.github/workflows/__test-release.yml @@ -128,7 +128,7 @@ jobs: uses: $/actions/package/publish with: package-tarball-artifact-id: ${{ steps.unpublished-package.outputs.artifact-id }} - skip-existing: "true" + recover-existing: "true" dry-run: "true" provenance: "false" - name: Assert - Publish preserved the unpublished tarball diff --git a/.github/workflows/release.md b/.github/workflows/release.md index 07853e69..213e583e 100644 --- a/.github/workflows/release.md +++ b/.github/workflows/release.md @@ -381,9 +381,10 @@ checks before publication. ### Recovery -The Publish action supports `skip-existing: "true"` for the public npm registry. +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, and returns its local path for assets. +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. npm publication across several packages cannot be atomic. Check exact package versions before retrying; published versions cannot be overwritten. Restore original @@ -432,7 +433,7 @@ Arrange, Act and Assert steps. Integration checks cover private-package rejectio 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 `skip-existing` enabled checks that `publish` +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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c890772c..5fc27679 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,7 +78,7 @@ jobs: package-tarball-artifact-id: ${{ inputs.package-tarball-artifact-id }} name: ${{ fromJSON(inputs.packages)[0].name }} version: ${{ fromJSON(inputs.packages)[0].version }} - skip-existing: "true" + recover-existing: "true" tag: latest - name: Prepare release asset env: diff --git a/actions/package/publish/README.md b/actions/package/publish/README.md index 047c6733..22bab655 100644 --- a/actions/package/publish/README.md +++ b/actions/package/publish/README.md @@ -33,9 +33,9 @@ Publish an existing Node.js package tarball to an npm-compatible registry # Expected package version, paired with name to reject an unintended release tarball before publication. version: "" - # Skip existing npmjs.org versions and restore their original tarball for release assets. Checks the registry again on every retry. + # Recover an existing npmjs.org version by restoring its original tarball instead of publishing again. Checks the registry on every attempt. # Default: `false` - skip-existing: "false" + recover-existing: "false" # Artifact ID of one package tarball uploaded by the package action # This input is required. @@ -70,18 +70,18 @@ Publish an existing Node.js package tarball to an npm-compatible registry ## Inputs -| **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** | - | -| **`skip-existing`** | Skip existing npmjs.org versions and restore their original tarball for release assets. Checks the registry again on every retry. | **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 }}` | +| **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 }}` | @@ -164,9 +164,9 @@ 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 skip-existing finds an existing version. | +| **Output** | **Description** | +| -------------------------- | ------------------------------------------------------------------------------------------------------- | +| **`package-tarball-path`** | Local path to the published tarball, restored from npm when recover-existing finds an existing version. | diff --git a/actions/package/publish/action.yml b/actions/package/publish/action.yml index 1785df2d..3d4bf5dc 100644 --- a/actions/package/publish/action.yml +++ b/actions/package/publish/action.yml @@ -12,8 +12,8 @@ inputs: version: description: Expected package version, paired with name to reject an unintended release tarball before publication. default: "" - skip-existing: - description: Skip existing npmjs.org versions and restore their original tarball for release assets. Checks the registry again on every retry. + recover-existing: + description: Recover an existing npmjs.org version by restoring its original tarball instead of publishing again. Checks the registry on every attempt. default: "false" package-tarball-artifact-id: description: "Artifact ID of one package tarball uploaded by the package action" @@ -45,7 +45,7 @@ inputs: outputs: package-tarball-path: - description: Local path to the published tarball, restored from npm when skip-existing finds an existing version. + description: Local path to the published tarball, restored from npm when recover-existing finds an existing version. value: ${{ steps.package-tarball.outputs.path }} runs: @@ -59,7 +59,7 @@ runs: ACCESS: ${{ inputs.access }} PROVENANCE: ${{ inputs.provenance }} DRY_RUN: ${{ inputs.dry-run }} - SKIP_EXISTING: ${{ inputs.skip-existing }} + RECOVER_EXISTING: ${{ inputs.recover-existing }} REGISTRY_URL: ${{ inputs.registry-url }} RUNNER_TEMP: ${{ runner.temp }} EXPECTED_NAME: ${{ inputs.name }} @@ -81,7 +81,7 @@ runs: return core.setFailed('access must be public, restricted, or empty'); } - for (const input of ['PROVENANCE', 'DRY_RUN', 'SKIP_EXISTING']) { + for (const input of ['PROVENANCE', 'DRY_RUN', 'RECOVER_EXISTING']) { if (!['true', 'false'].includes(process.env[input])) { return core.setFailed(`${input.toLowerCase().replaceAll('_', '-')} must be true or false`); } @@ -89,8 +89,8 @@ runs: try { const registry = new URL(process.env.REGISTRY_URL); - if (process.env.SKIP_EXISTING === 'true' && registry.origin !== 'https://registry.npmjs.org') { - return core.setFailed('skip-existing requires the public npm registry.'); + if (process.env.RECOVER_EXISTING === 'true' && registry.origin !== 'https://registry.npmjs.org') { + return core.setFailed('recover-existing requires the public npm registry.'); } if (!['https:', 'http:'].includes(registry.protocol)) { return core.setFailed('registry-url must use HTTP or HTTPS'); @@ -181,7 +181,7 @@ runs: - id: existing name: Recover an existing npm version - if: inputs.skip-existing == 'true' + if: inputs.recover-existing == 'true' uses: $/actions/package/restore with: name: ${{ steps.package-identity.outputs.name }} diff --git a/docs/adr/0001-release-orchestration.md b/docs/adr/0001-release-orchestration.md index 72e5ab33..faa721ec 100644 --- a/docs/adr/0001-release-orchestration.md +++ b/docs/adr/0001-release-orchestration.md @@ -289,3 +289,7 @@ the alternatives and observations below preserve their history, not new validati 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. From 8082c2864aa3f4d0cdc8057f60602694e5c2666e Mon Sep 17 00:00:00 2001 From: Emilien Escalle Date: Wed, 4 Nov 2026 18:42:20 +0100 Subject: [PATCH 5/5] refactor(release): pass tarball directly to finalization --- .github/workflows/release.md | 5 + .github/workflows/release.yml | 9 +- actions/release/finalize/README.md | 10 +- actions/release/finalize/action.yml | 18 ++- actions/release/finalize/index.js | 64 ++++++----- actions/release/finalize/index.test.js | 83 ++++++++------ actions/release/finalize/package-lock.json | 126 +-------------------- actions/release/finalize/package.json | 3 - docs/adr/0001-release-orchestration.md | 4 + 9 files changed, 113 insertions(+), 209 deletions(-) diff --git a/.github/workflows/release.md b/.github/workflows/release.md index 213e583e..07ce6d93 100644 --- a/.github/workflows/release.md +++ b/.github/workflows/release.md @@ -386,6 +386,11 @@ It checks the exact version on every attempt, restores the original registry tar 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5fc27679..88ce863d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -80,13 +80,8 @@ jobs: version: ${{ fromJSON(inputs.packages)[0].version }} recover-existing: "true" tag: latest - - name: Prepare release asset - env: - PACKAGE_TARBALL: ${{ steps.publish.outputs.package-tarball-path }} - ASSET_NAME: ${{ fromJSON(inputs.packages)[0].slug }}-${{ fromJSON(inputs.packages)[0].version }}.tgz - run: | - mkdir -p "$RUNNER_TEMP/release-assets" - cp "$PACKAGE_TARBALL" "$RUNNER_TEMP/release-assets/$ASSET_NAME" - 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/actions/release/finalize/README.md b/actions/release/finalize/README.md index 85d41ea1..bf33082e 100644 --- a/actions/release/finalize/README.md +++ b/actions/release/finalize/README.md @@ -22,16 +22,18 @@ ## Overview -Verify published npm tarballs staged in RUNNER_TEMP/release-assets against existing assets, then upload missing assets and publish the draft with HoverKraft release/update using GITHUB_TOKEN. +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** | - | +| **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** | - | diff --git a/actions/release/finalize/action.yml b/actions/release/finalize/action.yml index df784dab..64d5dfbf 100644 --- a/actions/release/finalize/action.yml +++ b/actions/release/finalize/action.yml @@ -1,5 +1,5 @@ name: Finalize Node.js release -description: Verify published npm tarballs staged in RUNNER_TEMP/release-assets against existing assets, then upload missing assets and publish the draft with HoverKraft release/update using GITHUB_TOKEN. +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 @@ -8,6 +8,12 @@ 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: @@ -16,11 +22,17 @@ runs: env: ACTION_PATH: ${{ github.action_path }} RELEASE_TAG: ${{ inputs.tag }} - ASSET_DIRECTORY: ${{ runner.temp }}/release-assets + 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, glob, repo: context.repo, tag: process.env.RELEASE_TAG, directory: process.env.ASSET_DIRECTORY }); + 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); diff --git a/actions/release/finalize/index.js b/actions/release/finalize/index.js index 9796b65e..65a3e0bd 100644 --- a/actions/release/finalize/index.js +++ b/actions/release/finalize/index.js @@ -19,7 +19,21 @@ async function findRelease(github, repo, tag) { } } -async function prepareAssets({ github, glob, repo, tag, directory }) { +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, { @@ -27,37 +41,25 @@ async function prepareAssets({ github, glob, repo, tag, directory }) { release_id: release.id, per_page: 100, }); - const globber = await glob.create(path.join(directory, "*.tgz"), { - followSymbolicLinks: false, - matchDirectories: false, - implicitDescendants: false, - }); - const files = (await globber.glob()).filter((file) => - fs.lstatSync(file).isFile(), - ); - if (!files.length) - throw new Error("No package tarballs available to attach."); - const missing = []; - for (const file of files) { - const name = path.basename(file); - const data = fs.readFileSync(file); - const digest = `sha256:${createHash("sha256").update(data).digest("hex")}`; - const existing = assets.find((asset) => asset.name === name); - if (existing) { - if (existing.digest !== digest) - throw new Error( - `Release asset ${name} has a different or unavailable digest.`, - ); - continue; - } - if (!release.draft) - throw new Error(`Published release is missing expected asset ${name}.`); - missing.push({ name, data }); + 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 }; } - const pendingDirectory = fs.mkdtempSync(path.join(directory, "pending-")); - for (const { name, data } of missing) - fs.writeFileSync(path.join(pendingDirectory, name), data); - return { release, pendingDirectory, hasAssets: missing.length > 0 }; + 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 index 48cc7e26..39228193 100644 --- a/actions/release/finalize/index.test.js +++ b/actions/release/finalize/index.test.js @@ -1,5 +1,4 @@ const assert = require("node:assert/strict"); -const glob = require("@actions/glob"); const { test } = require("node:test"); const { createHash } = require("node:crypto"); const fs = require("node:fs"); @@ -15,7 +14,8 @@ function directory(t) { function assetsFixture(t) { const root = directory(t); const data = Buffer.from("published package"); - fs.writeFileSync(path.join(root, "example-core-1.2.0.tgz"), data); + const tarballPath = path.join(root, "downloaded-artifact.tgz"); + fs.writeFileSync(tarballPath, data); const release = { id: 1, draft: true }; const assets = []; const github = { @@ -32,15 +32,16 @@ function assetsFixture(t) { data, args: { github, - glob, repo: { owner: "example", repo: "project" }, tag: "v1.2.0", - directory: root, + tarballPath, + assetName: "example-core-1.2.0.tgz", + tempDirectory: root, }, }; } -test("stages only missing package bytes for HoverKraft upload and skips identical assets", async (t) => { +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); @@ -50,13 +51,15 @@ test("stages only missing package bytes for HoverKraft upload and skips identica ), 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.deepEqual(fs.readdirSync(retry.pendingDirectory), []); + assert.equal(retry.pendingDirectory, ""); f.release.draft = false; assert.equal((await releaseAssets.prepareAssets(f.args)).hasAssets, false); }); @@ -76,42 +79,50 @@ test("does not overwrite mismatched assets or modify an incomplete published rel ); }); -test("partial asset recovery passes only missing tarballs to the uploader", async (t) => { +test("attaches only the requested file even when other tarballs are present", async (t) => { const f = assetsFixture(t); - const name = "example-cli-1.2.0.tgz"; - fs.writeFileSync(path.join(f.args.directory, name), "second package"); - f.assets.push({ - name: "example-core-1.2.0.tgz", - digest: `sha256:${createHash("sha256").update(f.data).digest("hex")}`, - }); - const result = await releaseAssets.prepareAssets(f.args); - assert.equal(result.hasAssets, true); - assert.deepEqual(fs.readdirSync(result.pendingDirectory), [name]); - assert.equal( - fs.readFileSync(path.join(result.pendingDirectory, name), "utf8"), - "second package", + 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("asset discovery excludes nested tarballs, directories and symbolic links", async (t) => { +test("rejects asset names that could escape staging or are not tarball filenames", async (t) => { const f = assetsFixture(t); - const nested = path.join(f.args.directory, "previous-attempt"); - fs.mkdirSync(nested); - fs.writeFileSync(path.join(nested, "ignored.tgz"), "previous attempt"); - fs.mkdirSync(path.join(f.args.directory, "directory.tgz")); - fs.symlinkSync( - path.join(nested, "ignored.tgz"), - path.join(f.args.directory, "link.tgz"), - ); - const result = await releaseAssets.prepareAssets(f.args); - assert.deepEqual(fs.readdirSync(result.pendingDirectory), [ - "example-core-1.2.0.tgz", + 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", ]); - fs.unlinkSync(path.join(f.args.directory, "example-core-1.2.0.tgz")); - await assert.rejects( - releaseAssets.prepareAssets(f.args), - /No package tarballs/, - ); +}); + +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) => { diff --git a/actions/release/finalize/package-lock.json b/actions/release/finalize/package-lock.json index 471f9154..c6ee39bb 100644 --- a/actions/release/finalize/package-lock.json +++ b/actions/release/finalize/package-lock.json @@ -4,131 +4,7 @@ "requires": true, "packages": { "": { - "name": "release-finalize-tests", - "devDependencies": { - "@actions/glob": "^0.4.0" - } - }, - "node_modules/@actions/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz", - "integrity": "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@actions/exec": "^1.1.1", - "@actions/http-client": "^2.0.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/glob": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@actions/glob/-/glob-0.4.0.tgz", - "integrity": "sha512-+eKIGFhsFa4EBwaf/GMyzCdWrXWymGXfFmZU3FHQvYS8mPcHtTtZONbkcqqUMzw9mJ/pImEBFET1JNifhqGsAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@actions/core": "^1.9.1", - "minimatch": "^3.0.4" - } - }, - "node_modules/@actions/http-client": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.3.tgz", - "integrity": "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tunnel": "^0.0.6", - "undici": "^5.25.4" - } - }, - "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" - }, - "node_modules/@fastify/busboy": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", - "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/tunnel": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", - "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6.11 <=0.7.0 || >=0.7.3" - } - }, - "node_modules/undici": { - "version": "5.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", - "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@fastify/busboy": "^2.0.0" - }, - "engines": { - "node": ">=14.0" - } + "name": "release-finalize-tests" } } } diff --git a/actions/release/finalize/package.json b/actions/release/finalize/package.json index 7d158e4c..fcf7eb4a 100644 --- a/actions/release/finalize/package.json +++ b/actions/release/finalize/package.json @@ -4,8 +4,5 @@ "scripts": { "test": "node --test", "test:ci": "node --test" - }, - "devDependencies": { - "@actions/glob": "^0.4.0" } } diff --git a/docs/adr/0001-release-orchestration.md b/docs/adr/0001-release-orchestration.md index faa721ec..0b67a780 100644 --- a/docs/adr/0001-release-orchestration.md +++ b/docs/adr/0001-release-orchestration.md @@ -293,3 +293,7 @@ the alternatives and observations below preserve their history, not new validati `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.