diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ddb2cfe..3fe895c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,23 +6,94 @@ on: jobs: publish: runs-on: ubuntu-latest + # Scoped to this job, not the workflow: a job added to this file later + # would otherwise inherit `id-token: write` it does not need. permissions: contents: read id-token: write steps: - uses: actions/checkout@v4 + with: + fetch-depth: 2 + persist-credentials: false + - uses: actions/setup-node@v4 with: node-version: 22 registry-url: https://registry.npmjs.org - - run: npm ci --legacy-peer-deps - - run: npm test - - run: npm run build + # npm Trusted Publishing (OIDC) needs npm CLI >= 11.5.1. Node 22 only + # bundles npm 10.x, so upgrade before anything else touches the + # registry. Pinned to the 11.x line, not `@latest`: npm 12 is already + # out and warns that setup-node's `always-auth` config "will stop + # working in the next major" — floating to `@latest` would silently + # pull that break in on some future run. See + # https://docs.npmjs.com/trusted-publishers. + - name: Upgrade npm for trusted publishing (needs npm >= 11.5.1) + run: | + npm install -g npm@^11.5.1 + npm --version + + # CEL-1733 — only publish when this push actually bumped the version. + # Without this gate every merge to main (docs, tests, refactors) would + # re-run `npm publish` against an already-published version. + # + # Compare against the push event's previous main SHA + # (`github.event.before`) rather than `HEAD~1`: this repo allows rebase + # merges, and a rebase merge can push several commits at once, making + # `HEAD~1` the PR's second-to-last commit rather than the pre-merge tip + # of main — a version bump that isn't the newest commit would silently + # be skipped. `before` is the all-zeros SHA on the first push to a new + # branch (nothing to diff against, so PREVIOUS_VERSION stays empty); + # if it is set but can't be fetched (e.g. an older/replayed event) we + # fall back to the previous `HEAD~1` comparison. + - name: Check whether package.json version changed + id: version + run: | + CURRENT_VERSION=$(node -p "require('./package.json').version") + BEFORE_SHA="${{ github.event.before }}" + PREVIOUS_VERSION="" + if [ -n "$BEFORE_SHA" ] && [ "$BEFORE_SHA" != "0000000000000000000000000000000000000000" ]; then + if git fetch --no-tags --depth=1 origin "$BEFORE_SHA" 2>/dev/null && \ + git show "$BEFORE_SHA:package.json" > "$RUNNER_TEMP/prev-package.json" 2>/dev/null; then + PREVIOUS_VERSION=$(node -p "require('$RUNNER_TEMP/prev-package.json').version") + elif git show HEAD~1:package.json > "$RUNNER_TEMP/prev-package.json" 2>/dev/null; then + PREVIOUS_VERSION=$(node -p "require('$RUNNER_TEMP/prev-package.json').version") + fi + fi + if [ "$CURRENT_VERSION" = "$PREVIOUS_VERSION" ]; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "package.json version ($CURRENT_VERSION) is unchanged since the previous commit on main — skipping the rest of the publish job." + else + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "package.json version changed: ${PREVIOUS_VERSION:-} -> $CURRENT_VERSION" + fi + echo "version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT" + + # This job's OIDC request variables (ACTIONS_ID_TOKEN_REQUEST_URL / + # ACTIONS_ID_TOKEN_REQUEST_TOKEN) are visible to every step here, not + # just the publish step — so no untrusted script should run in this + # job. `--ignore-scripts` blocks install-time lifecycle scripts from + # this package's own (small, audited) dependency tree; verified + # `npm test` and `npm run build` still pass with it. + - name: Install dependencies + if: steps.version.outputs.changed == 'true' + run: npm ci --legacy-peer-deps --ignore-scripts + + - name: Test + if: steps.version.outputs.changed == 'true' + run: npm test + + - name: Build + if: steps.version.outputs.changed == 'true' + run: npm run build - # Check if this version is already published — skip if so + # Belt-and-braces alongside the version-changed gate above: also skip + # if this exact version somehow already exists on npm (e.g. a retried + # workflow run). Public package, so `npm view` works unauthenticated. - name: Check if version exists on npm id: version-check + if: steps.version.outputs.changed == 'true' run: | PACKAGE_NAME=$(node -p "require('./package.json').name") PACKAGE_VERSION=$(node -p "require('./package.json').version") @@ -34,8 +105,15 @@ jobs: echo "Version ${PACKAGE_VERSION} not yet published." fi + # Trusted Publishing (OIDC): no NODE_AUTH_TOKEN / NPM_TOKEN anywhere in + # this job. The npm CLI detects the GitHub Actions OIDC environment + # (this job's `id-token: write` permission) and exchanges it for a + # short-lived publish token itself — requires the npmjs.com Trusted + # Publisher config to exist first (Organization CellarNode, Repository + # beverage-utils, Workflow publish.yml, no Environment — see CEL-1733). + # Provenance is generated automatically here and needs no flag: this + # is a public repo publishing a public package, the only combination + # npm signs by default. - name: Publish - if: steps.version-check.outputs.exists == 'false' - run: npm publish --provenance --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + if: steps.version.outputs.changed == 'true' && steps.version-check.outputs.exists == 'false' + run: npm publish --access public diff --git a/Makefile b/Makefile index eb0db35..eb13075 100644 --- a/Makefile +++ b/Makefile @@ -34,41 +34,41 @@ check-exports: ## Manual-only: dist-orphan guard + publint + arethetypeswrong r build: typecheck test compile verify-dist ## Full build: typecheck + test + compile (clean-first) + dist-orphan gate ## Publishing - -# CEL-1660 — mirrors @cellarnode/ui's release-install/release-* shape. -# This repo has NO auth path configured for the agent session that wrote -# this Makefile — `npm publish` here returns ENEEDAUTH. Publishing remains -# a human action (Marcus); these targets exist so that action is a single -# command instead of a hand-run sequence, not so an agent can run them. +# +# CEL-1733 — publishing happens in CI on merge to main via npm trusted +# publishing (OIDC), see `.github/workflows/publish.yml`. The release +# targets below only bump the version, build, commit, and tag; they do NOT +# publish. Push the branch and open a PR — merging to main is what +# triggers the actual `npm publish` (gated on the version having changed). release-install: ## Refresh node_modules from the lockfile (release pre-gate) npm ci --legacy-peer-deps -publish: build ## Build (clean + typecheck + test + compile + dist-orphan gate) and publish current version to npm +# Explicit fallback only — normal path is CI (see above). Needs a local +# `npm login` with publish rights on @cellarnode/beverage-utils; not +# expected to work from an agent session. +publish: build ## Manual fallback ONLY — build and publish current version to npm (needs npm login; not the normal path) npm publish -release-patch: ## Bump patch version, publish, and git tag +release-patch: ## Bump patch version, commit, and git tag (CI publishes on merge to main) $(MAKE) release-install && \ $(MAKE) build && \ npm version patch --no-git-tag-version && \ - npm publish && \ git add package.json && \ git commit -m "release(beverage-utils): $$(node -p "require('./package.json').version")" && \ git tag "v$$(node -p "require('./package.json').version")" -release-minor: ## Bump minor version, publish, and git tag +release-minor: ## Bump minor version, commit, and git tag (CI publishes on merge to main) $(MAKE) release-install && \ $(MAKE) build && \ npm version minor --no-git-tag-version && \ - npm publish && \ git add package.json && \ git commit -m "release(beverage-utils): $$(node -p "require('./package.json').version")" && \ git tag "v$$(node -p "require('./package.json').version")" -release-major: ## Bump major version, publish, and git tag +release-major: ## Bump major version, commit, and git tag (CI publishes on merge to main) $(MAKE) release-install && \ $(MAKE) build && \ npm version major --no-git-tag-version && \ - npm publish && \ git add package.json && \ git commit -m "release(beverage-utils): $$(node -p "require('./package.json').version")" && \ git tag "v$$(node -p "require('./package.json').version")" diff --git a/README.md b/README.md index 53fd2a5..55e7941 100644 --- a/README.md +++ b/README.md @@ -270,8 +270,11 @@ npm run check-exports # runs publint + attw 1. Make your changes and update `CHANGELOG.md` 2. Bump `version` in `package.json` -3. Push to `main` -4. GitHub Actions automatically publishes to npm (with provenance) +3. Open a PR with the bump and merge it to `main` +4. GitHub Actions publishes to npm via **Trusted Publishing (OIDC)** — no npm token involved. + Provenance is generated automatically (public repo + public package). The workflow only + proceeds when `package.json`'s version differs from main's previous commit, so + non-release merges are a no-op. ## License