diff --git a/.github/workflows/npm-publish.yaml b/.github/workflows/npm-publish.yaml index 8cce22b..dafc249 100644 --- a/.github/workflows/npm-publish.yaml +++ b/.github/workflows/npm-publish.yaml @@ -20,6 +20,18 @@ on: required: false default: false type: boolean + auth_method: + description: Authentication method (oidc uses GitHub OIDC trusted publisher; manual_otp uses NPM_TOKEN secret + 2FA OTP) + required: false + default: oidc + type: choice + options: + - oidc + - manual_otp + npm_otp: + description: 2FA OTP code (required when auth_method=manual_otp). Code is masked in workflow logs. + required: false + type: string permissions: contents: read @@ -68,6 +80,26 @@ jobs: echo "published=false" >> "$GITHUB_OUTPUT" fi + - name: Configure manual OTP auth + if: github.event_name == 'workflow_dispatch' && inputs.auth_method == 'manual_otp' + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_OTP: ${{ inputs.npm_otp }} + run: | + set -euo pipefail + if [ -z "${NPM_TOKEN}" ]; then + echo "::error::auth_method=manual_otp requires NPM_TOKEN to be configured as a secret on the production environment." + exit 1 + fi + if [ -z "${NPM_OTP}" ]; then + echo "::error::auth_method=manual_otp requires npm_otp input to be supplied with the current 2FA OTP code." + exit 1 + fi + # Override actions/setup-node OIDC NODE_AUTH_TOKEN with the legacy token. + echo "NODE_AUTH_TOKEN=${NPM_TOKEN}" >> "$GITHUB_ENV" + echo "NPM_OTP_CONFIGURED=true" >> "$GITHUB_ENV" + echo "Using manual_otp auth: NPM_TOKEN secret + 2FA OTP." + - name: Dry run publish if: github.event_name == 'workflow_dispatch' && inputs.dry_run env: @@ -76,16 +108,27 @@ jobs: - name: Publish package if: steps.package.outputs.published != 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) - run: npm publish --access public --tag "${{ github.event_name == 'workflow_dispatch' && inputs.npm_tag || 'latest' }}" + env: + NPM_OTP: ${{ env.NPM_OTP_CONFIGURED == 'true' && inputs.npm_otp || '' }} + run: | + set -euo pipefail + TAG="${{ github.event_name == 'workflow_dispatch' && inputs.npm_tag || 'latest' }}" + if [ -n "${NPM_OTP}" ]; then + npm publish --access public --tag "${TAG}" --otp="${NPM_OTP}" + else + npm publish --access public --tag "${TAG}" + fi - name: Publish scoped alias if: steps.package.outputs.published != 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) env: ALIAS_SCOPE: "@echohello" + NPM_OTP: ${{ env.NPM_OTP_CONFIGURED == 'true' && inputs.npm_otp || '' }} run: | set -euo pipefail VERSION=$(node --print "require('./package.json').version") ALIAS_NAME="${ALIAS_SCOPE}/skillet" + TAG="${{ github.event_name == 'workflow_dispatch' && inputs.npm_tag || 'latest' }}" # Skip if the alias version is already published if npm view "${ALIAS_NAME}@${VERSION}" version --registry=https://registry.npmjs.org >/dev/null 2>&1; then @@ -106,4 +149,8 @@ jobs: " cd "$ALIAS_DIR" - npm publish --access public --tag "${{ github.event_name == 'workflow_dispatch' && inputs.npm_tag || 'latest' }}" + if [ -n "${NPM_OTP}" ]; then + npm publish --access public --tag "${TAG}" --otp="${NPM_OTP}" + else + npm publish --access public --tag "${TAG}" + fi diff --git a/docs/distribution/npm.md b/docs/distribution/npm.md index eeb2992..447a7c1 100644 --- a/docs/distribution/npm.md +++ b/docs/distribution/npm.md @@ -67,6 +67,19 @@ Manual dispatch inputs: - `npm_tag`: npm dist-tag, defaults to `latest` - `dry_run`: when true, runs `npm publish --dry-run` and skips the real publish step - When `dry_run` is true, the workflow uses the provided `npm_tag`, or falls back to `dry-run` if you leave it at `latest`. +- `auth_method`: `oidc` (default, uses GitHub OIDC trusted publisher) or `manual_otp` (uses a legacy `NPM_TOKEN` secret plus a one-time password from the dispatch input). Use `manual_otp` when the OIDC integration is failing or for a one-off bootstrap publish. +- `npm_otp`: required when `auth_method=manual_otp`. Provide the current 2FA OTP code from your authenticator. The value is masked in workflow logs but still surfaces in the dispatch payload; rotate the OTP after each dispatch. + +### Manual OTP bootstrap publish + +When the OIDC trusted publisher is misbehaving (for example, returning `404 'getskillet is not in this registry'` during automated release publishes), fall back to manual OTP: + +1. Add `NPM_TOKEN` as an environment secret on the GitHub `production` environment (Settings → Environments → production → Add secret). Use a legacy automation token from `npmjs.com → Access Tokens → Automation` with 2FA-required publish scope. +2. Trigger `npm-publish.yaml` via `workflow_dispatch` with `auth_method=manual_otp` and `npm_otp=<6-digit code>`. +3. The workflow writes `NODE_AUTH_TOKEN` from the secret to `$GITHUB_ENV`, which overrides the `actions/setup-node` OIDC value for the publish steps. Both `getskillet` and `@echohello/skillet` are published with `--otp` against the same code. +4. Rotate the OTP after the run completes and consider rotating the `NPM_TOKEN` secret if it was exposed in any logs. + +This path is intentionally heavier than OIDC (manual step + secret rotation). It exists to unblock a release when the trusted publisher configuration is broken; it should not be the default. Local command: