From 5182ee00c2d5729ec64756624f6147753c6c3109 Mon Sep 17 00:00:00 2001 From: Joe Blau Date: Tue, 27 Oct 2026 09:15:20 -0700 Subject: [PATCH 1/3] ci(release): publish to npm via trusted publishing with provenance Signed-off-by: Joe Blau --- .github/workflows/publish_npm.yml | 33 +++++++++++++------------- apps/docs/content/docs/development.mdx | 4 +++- docs/ci-runners.md | 16 ++++++++----- 3 files changed, 30 insertions(+), 23 deletions(-) diff --git a/.github/workflows/publish_npm.yml b/.github/workflows/publish_npm.yml index 8500d49..51f689a 100644 --- a/.github/workflows/publish_npm.yml +++ b/.github/workflows/publish_npm.yml @@ -3,10 +3,8 @@ name: Publish to npm # Publishing a GitHub release publishes @bloxwap/chart at the version in packages/chart/package.json. # Release flow: bump the version in a PR, merge it, then create a release tagged v. # -# npm trusted publishing does not support self-hosted runners -# (https://docs.npmjs.com/trusted-publishers/). This job authenticates with the -# NPM_TOKEN secret instead. That drops OIDC trusted publishing and -# automatic provenance. See docs/ci-runners.md. +# Only the publish job uses GitHub-hosted Ubuntu: npm trusted publishing and +# provenance require it. See docs/ci-runners.md for the transition and approval gate. on: release: types: [published] @@ -34,12 +32,12 @@ jobs: publish: needs: security - runs-on: [self-hosted, macOS, ARM64] + runs-on: ubuntu-24.04 + environment: npm-release timeout-minutes: 20 permissions: contents: read - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + id-token: write steps: # Job-level env cannot use the runner context; RUNNER_TEMP is set by the runner. @@ -48,13 +46,17 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - # Minis persist ~/.npm on disk. GitHub's remote cache is 0.4–1 MB/s for ~1.4 GB. + # Do not restore dependency caches in the release job. - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '24' registry-url: 'https://registry.npmjs.org' package-manager-cache: false + - name: Install npm with trusted publishing support + # Bootstrap a fixed OIDC-capable npm CLI outside the project dependency lockfile. + run: npm install --global npm@11.6.1 # zizmor: ignore[adhoc-packages] + - run: npm ci - name: Determine version and npm tag @@ -84,12 +86,11 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "value=$TAG" >> "$GITHUB_OUTPUT" - - name: Check npm CLI - run: | - npm --version - npx --yes semver -r '>=11.5.1' "$(npm --version)" > /dev/null || npm install -g npm@latest - - # prepack builds and prepublishOnly runs the full test suite before anything is uploaded; - # publishConfig.access is public. Token publish does not attach provenance. + # prepack builds and prepublishOnly runs the full Node/Happy DOM test suite; + # no browser download is needed. publishConfig.access is public. - name: Publish @bloxwap/chart@${{ steps.tag.outputs.version }} - run: npm publish --workspace @bloxwap/chart --tag ${{ steps.tag.outputs.value }} + env: + NPM_TAG: ${{ steps.tag.outputs.value }} + # Transitional fallback: delete once the npm Trusted Publisher is verified. + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish --workspace @bloxwap/chart --tag "$NPM_TAG" --provenance diff --git a/apps/docs/content/docs/development.mdx b/apps/docs/content/docs/development.mdx index 1fc1e43..a33248b 100644 --- a/apps/docs/content/docs/development.mdx +++ b/apps/docs/content/docs/development.mdx @@ -60,7 +60,9 @@ The preview server reads the exported base path, so this build opens at `http:// ## Release to npm -Publishing a GitHub release publishes `@bloxwap/chart` from CI (`.github/workflows/publish_npm.yml`) on the org Mac mini runners. Authentication uses the org `NPM_TOKEN` secret (`NODE_AUTH_TOKEN`). npm trusted publishing and automatic provenance are unavailable on self-hosted runners. +Publishing a GitHub release publishes `@bloxwap/chart` from CI (`.github/workflows/publish_npm.yml`). Only the publish job runs on GitHub-hosted `ubuntu-24.04`, as required by npm trusted publishing; the prerequisite security scan and all other jobs remain on the Mac minis. Node 24 and npm 11.6.1 build and test the package on that runner, with dependency caching disabled. Publishing requests npm provenance, including during token fallback. + +Configure the GitHub `npm-release` environment with required reviewers before releasing; the workflow names this environment, but approval rules must be set in GitHub settings. The publish step keeps `NPM_TOKEN` as `NODE_AUTH_TOKEN` only as a transitional fallback, so releases work before npmjs.com setup. Configure the npm Trusted Publisher for organization `bloxwap`, repository `chart`, workflow filename `publish_npm.yml`, environment `npm-release`, allowing direct publishing. Once trusted publishing is verified, remove the fallback and delete this repository's token secret/access. See [CI runner setup](https://github.com/bloxwap/chart/blob/main/docs/ci-runners.md) for details. 1. In a pull request, bump `version` in `packages/chart/package.json` (and the `version` export in `packages/chart/src/index.ts`), then merge it. A test fails if the two disagree. 2. Create a GitHub release tagged `v`, for example `v0.0.2`. The workflow fails if the tag and the package version disagree. diff --git a/docs/ci-runners.md b/docs/ci-runners.md index d959356..d14944a 100644 --- a/docs/ci-runners.md +++ b/docs/ci-runners.md @@ -1,18 +1,22 @@ # CI runners -Every GitHub Actions job in this repository runs on self-hosted Apple Silicon Mac minis (runner group JWS): +Except for the npm publish job in `.github/workflows/publish_npm.yml`, every GitHub Actions job in this repository runs on self-hosted Apple Silicon Mac minis (runner group JWS): ```yaml runs-on: [self-hosted, macOS, ARM64] ``` -These jobs stay queued until a runner with all three labels is online. Workflows do not call `apt`, `sudo`, or `playwright install --with-deps` (that flag installs Linux packages). Shell used by CI is POSIX / BSD-portable, and Mac jobs write scratch files under `$RUNNER_TEMP` (`TMPDIR` and Playwright's browser path are exported from `$RUNNER_TEMP` in a setup step; GitHub Actions does not allow the `runner` context in job-level `env`). +These jobs stay queued until a runner with all three labels is online. Mac mini jobs do not call `apt`, `sudo`, or `playwright install --with-deps` (that flag installs Linux packages). Shell used by CI is POSIX / BSD-portable, and Mac jobs write scratch files under `$RUNNER_TEMP` (`TMPDIR` and Playwright's browser path are exported from `$RUNNER_TEMP` in a setup step; GitHub Actions does not allow the `runner` context in job-level `env`). `actions/setup-node` sets `package-manager-cache: false` and does not set `cache:`. The minis already keep `~/.npm` on disk across jobs, so GitHub's remote dependency cache is extra upload/download (a ~1.4 GB archive at 0.4–1 MB/s) with no install-time benefit. -## Trusted publishing is unavailable +## npm release exception and setup -[npm trusted publishing does not support self-hosted runners](https://docs.npmjs.com/trusted-publishers/). The publish job therefore authenticates with a long-lived `NPM_TOKEN` secret (`NODE_AUTH_TOKEN`). That **drops OIDC trusted publishing and automatic provenance**. The org-level `NPM_TOKEN` (publish rights for `@bloxwap` packages) must be shared with this repository. +Only `publish` in `.github/workflows/publish_npm.yml` runs on GitHub-hosted `ubuntu-24.04`: [npm trusted publishing requires GitHub-hosted runners](https://docs.npmjs.com/trusted-publishers/). Its existing prerequisite security scan stays on the minis. The job uses Node 24, pinned npm 11.6.1, only `contents: read` and `id-token: write` permissions, and the `npm-release` environment. It builds locally on that runner and runs the full Node/Happy DOM suite through npm's publish lifecycle; no browser or Python installation is needed. Dependency caching is disabled for releases. + +Before releasing, configure GitHub **Settings → Environments → npm-release** with required reviewers. Merely naming the environment in YAML does not configure an approval gate. Keep the existing org/repository `NPM_TOKEN` available during the transition; it is exposed as `NODE_AUTH_TOKEN` only on the publish step. npm tries OIDC first and falls back to this token while no trusted publisher is configured, so releases do not require npmjs.com setup first. `npm publish --provenance` attaches provenance even during token fallback. + +In the npmjs.com settings for `@bloxwap/chart`, add a GitHub Actions Trusted Publisher with organization `bloxwap`, repository `chart`, workflow filename `publish_npm.yml` (not its path), and environment `npm-release`, allowing direct publishing. Keep that workflow filename unchanged. Verify a release uses trusted publishing, then remove the fallback `NODE_AUTH_TOKEN` entry and delete this repository's `NPM_TOKEN` secret/access (do not revoke a shared org token still used by other repositories). OIDC does not authorize commands such as `npm whoami`, `npm access`, or `npm deprecate`; do not add token-only auth preflights to this publish path. ## Jobs @@ -22,7 +26,7 @@ These jobs stay queued until a runner with all three labels is online. Workflows | [`.github/workflows/coverage.yml`](../.github/workflows/coverage.yml) | `coverage` | Mac mini | LCOV report and Codecov upload | | [`.github/workflows/docs.yml`](../.github/workflows/docs.yml) | `build` | Mac mini | Docs export, typecheck, lint, Playwright Chrome checks; Pages artifact on `main` only (PRs still build and validate) | | [`.github/workflows/docs.yml`](../.github/workflows/docs.yml) | `deploy` | Mac mini | `configure-pages` + `deploy-pages` on `main` | -| [`.github/workflows/publish_npm.yml`](../.github/workflows/publish_npm.yml) | `publish` | Mac mini | Version/tag check and `npm publish` of `@bloxwap/chart` (`NPM_TOKEN`; no provenance) | +| [`.github/workflows/publish_npm.yml`](../.github/workflows/publish_npm.yml) | `publish` | GitHub-hosted Ubuntu 24.04 | Approved `npm-release`; version/tag check and publish with provenance (OIDC; transitional token fallback) | ## Prerequisites on each Mac mini @@ -61,5 +65,5 @@ Browsers go to `$RUNNER_TEMP/ms-playwright` (`PLAYWRIGHT_BROWSERS_PATH`). There - Give the runner enough free disk for `npm ci` (and a persistent `~/.npm`), the Next.js static export, and a Chrome for Testing download. - Headless Chrome on macOS does not need the Accessibility TCC prompt that headed browsers do. -- npm publish (`Publish to npm`) uses the `NPM_TOKEN` secret, not OIDC. GitHub Pages deploy still uses `pages: write` and `id-token: write`. +- npm publish (`Publish to npm`) uses the Ubuntu exception and approval setup above. GitHub Pages deploy still uses `pages: write` and `id-token: write`. - Fork pull requests on a public repository should stay on GitHub-hosted runners or a dedicated untrusted pool if org policy forbids self-hosted runners on forks. This repo's workflows themselves do not special-case forks. From 1a60528f7fae586755a5905e31c75a89a820caff Mon Sep 17 00:00:00 2001 From: Joe Blau Date: Tue, 27 Oct 2026 09:16:23 -0700 Subject: [PATCH 2/3] ci: TEMPORARY ubuntu publish dry-run (remove before merge) Signed-off-by: Joe Blau --- .github/workflows/tmp-publish-dryrun.yml | 68 ++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 .github/workflows/tmp-publish-dryrun.yml diff --git a/.github/workflows/tmp-publish-dryrun.yml b/.github/workflows/tmp-publish-dryrun.yml new file mode 100644 index 0000000..2458791 --- /dev/null +++ b/.github/workflows/tmp-publish-dryrun.yml @@ -0,0 +1,68 @@ +name: TEMPORARY Ubuntu publish dry-run + +# Remove before merge. Exercises the publish lifecycle without uploading a package. +on: + pull_request: + +permissions: + contents: read + +jobs: + publish-dryrun: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + + steps: + # Job-level env cannot use the runner context; RUNNER_TEMP is set by the runner. + - name: Point TMPDIR at runner temp + run: echo "TMPDIR=$RUNNER_TEMP" >> "$GITHUB_ENV" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # Do not restore dependency caches in the release job. + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '24' + registry-url: 'https://registry.npmjs.org' + package-manager-cache: false + + - name: Install npm with trusted publishing support + # Bootstrap a fixed OIDC-capable npm CLI outside the project dependency lockfile. + run: npm install --global npm@11.6.1 # zizmor: ignore[adhoc-packages] + + - run: npm ci + + - name: Determine version and npm tag + id: tag + env: + RELEASE_TAG: '' # Pull requests have no release tag; use the package version. + run: | + # POSIX / BSD-portable: no jq, no GNU sed, no bashisms. + VERSION=$(node -p "require('./packages/chart/package.json').version") + # The private workspace root has no release version; the chart package owns it. + # A release must be tagged for the version it publishes, or npm and GitHub disagree. + if [ -n "$RELEASE_TAG" ] && [ "$RELEASE_TAG" != "v$VERSION" ]; then + echo "::error::Release tag $RELEASE_TAG does not match packages/chart/package.json version v$VERSION" + exit 1 + fi + PRE=${VERSION#*-} + if [ "$PRE" = "$VERSION" ]; then + TAG=latest + else + case "$PRE" in + alpha*) TAG=alpha ;; + beta*) TAG=beta ;; + rc*) TAG=rc ;; + *) TAG=prerelease ;; + esac + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "value=$TAG" >> "$GITHUB_OUTPUT" + + # prepack builds and prepublishOnly runs the full Node/Happy DOM test suite; + # no browser download is needed. publishConfig.access is public. + - name: Dry-run @bloxwap/chart@${{ steps.tag.outputs.version }} + env: + NPM_TAG: ${{ steps.tag.outputs.value }} + # The version may already exist: force skips registry version checks only for this dry-run. + run: npm publish --workspace @bloxwap/chart --tag "$NPM_TAG" --provenance --dry-run --force From f147565a78dff5d5d9ba9ebcdc5ad567d389e846 Mon Sep 17 00:00:00 2001 From: Joe Blau Date: Tue, 27 Oct 2026 09:37:15 -0700 Subject: [PATCH 3/3] ci: remove temporary Ubuntu publish dry-run Signed-off-by: Joe Blau --- .github/workflows/tmp-publish-dryrun.yml | 68 ------------------------ 1 file changed, 68 deletions(-) delete mode 100644 .github/workflows/tmp-publish-dryrun.yml diff --git a/.github/workflows/tmp-publish-dryrun.yml b/.github/workflows/tmp-publish-dryrun.yml deleted file mode 100644 index 2458791..0000000 --- a/.github/workflows/tmp-publish-dryrun.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: TEMPORARY Ubuntu publish dry-run - -# Remove before merge. Exercises the publish lifecycle without uploading a package. -on: - pull_request: - -permissions: - contents: read - -jobs: - publish-dryrun: - runs-on: ubuntu-24.04 - timeout-minutes: 20 - - steps: - # Job-level env cannot use the runner context; RUNNER_TEMP is set by the runner. - - name: Point TMPDIR at runner temp - run: echo "TMPDIR=$RUNNER_TEMP" >> "$GITHUB_ENV" - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - # Do not restore dependency caches in the release job. - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: '24' - registry-url: 'https://registry.npmjs.org' - package-manager-cache: false - - - name: Install npm with trusted publishing support - # Bootstrap a fixed OIDC-capable npm CLI outside the project dependency lockfile. - run: npm install --global npm@11.6.1 # zizmor: ignore[adhoc-packages] - - - run: npm ci - - - name: Determine version and npm tag - id: tag - env: - RELEASE_TAG: '' # Pull requests have no release tag; use the package version. - run: | - # POSIX / BSD-portable: no jq, no GNU sed, no bashisms. - VERSION=$(node -p "require('./packages/chart/package.json').version") - # The private workspace root has no release version; the chart package owns it. - # A release must be tagged for the version it publishes, or npm and GitHub disagree. - if [ -n "$RELEASE_TAG" ] && [ "$RELEASE_TAG" != "v$VERSION" ]; then - echo "::error::Release tag $RELEASE_TAG does not match packages/chart/package.json version v$VERSION" - exit 1 - fi - PRE=${VERSION#*-} - if [ "$PRE" = "$VERSION" ]; then - TAG=latest - else - case "$PRE" in - alpha*) TAG=alpha ;; - beta*) TAG=beta ;; - rc*) TAG=rc ;; - *) TAG=prerelease ;; - esac - fi - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "value=$TAG" >> "$GITHUB_OUTPUT" - - # prepack builds and prepublishOnly runs the full Node/Happy DOM test suite; - # no browser download is needed. publishConfig.access is public. - - name: Dry-run @bloxwap/chart@${{ steps.tag.outputs.version }} - env: - NPM_TAG: ${{ steps.tag.outputs.value }} - # The version may already exist: force skips registry version checks only for this dry-run. - run: npm publish --workspace @bloxwap/chart --tag "$NPM_TAG" --provenance --dry-run --force