Skip to content

feat(js): Publish without a token where the registry trusts it - #18

Merged
nfebe merged 1 commit into
mainfrom
js-trusted-publishing
Sep 20, 2026
Merged

nfebe merged 1 commit into
mainfrom
js-trusted-publishing

Conversation

@nfebe

@nfebe nfebe commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

js/publish takes trusted, publishing with a registry that trusts the workflow rather than a token. The job needs id-token: write, and the registry needs the repository and the workflow filename recorded against the package.

Two version floors come with it, both required before a registry will accept an attested publish: Node is pinned to 22.14 and npm is upgraded, since Node 22 still ships npm 10. The token is left out of the environment, because one present takes precedence and the publish stops being attested.

token is still read, for the GitHub release.

@sourceant

sourceant Bot commented Sep 20, 2026

Copy link
Copy Markdown

This repository is not connected to any of your workspaces. Please connect it at https://app.sourceant.ai to get reviews on it.

@nfebe
nfebe force-pushed the js-trusted-publishing branch from 1285d59 to 06650e3 Compare September 20, 2026 12:36
@sourceant

sourceant Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

Code Review Summary

✨ js/publish gains a trusted input that publishes with a registry trusting the workflow (OIDC/attested publishing) instead of a token. When trusted: 'true', Node is pinned to 22.14.0, npm is upgraded to ^11.5.1 from the public registry, and a fail-fast step verifies id-token: write is granted and that the target registry is not GitHub Packages. Publishing is split into two mutually exclusive steps so the attested path carries no NODE_AUTH_TOKEN at all; token is still consumed by the GitHub release step. Docs, CHANGELOG and VERSION are bumped to 1.4.0. The review questions the strength of the GitHub Packages guard: the host extraction in js/publish/action.yml is case-sensitive and does not strip ports, scheme case or userinfo, so several spellings of npm.pkg.github.com slip past the check.

Review

The new preconditions step is the right place to fail fast, but its host comparison is too literal: it only rejects the exact lowercase, port-free, credential-free form. Because npm lower-cases and normalises the host when it connects, inputs like https://NPM.PKG.GITHUB.COM, https://npm.pkg.github.com:443 or https://user@npm.pkg.github.com reach GitHub Packages anyway and fail later inside npm publish with an opaque auth error — the outcome the guard exists to prevent.

🚀 Key Improvements

  • js/publish/action.yml: new trusted input publishes without a token, with Node pinned to 22.14.0 and npm upgraded to ^11.5.1, the versions a registry requires before accepting an attested publish.
  • js/publish/action.yml: the publish step is split into token and no-token variants, so the trusted path never has a token in its environment and the credential the registry mints is actually used.
  • js/publish/action.yml: the precondition step names the missing id-token: write permission up front rather than failing inside npm.
  • js/publish/action.yml: access, version and registry values are passed through env: (ACCESS_INPUT, VERSION, REGISTRY) instead of being interpolated directly into shell scripts.

📉 Regressions

  • js/publish/action.yml: the GitHub Packages guard is written against one literal spelling of the host — the sed expression strips only a lowercase scheme and the path, keeping ports and credentials, and the comparison is case-sensitive, so HTTPS://NPM.PKG.GITHUB.COM, npm.pkg.github.com:443 or a host with userinfo pass the check and fail later inside npm publish with an opaque error.

💡 Minor Suggestions

  • The host parse only lowercases nothing and strips only scheme:// and the path: sed -E 's#^[a-z]+://##; s#/.*$##' leaves npm.pkg.github.com for the common spelling, but HTTPS://npm.pkg.github.com (uppercase scheme), npm.pkg.github.com:443 (port) and https://user@npm.pkg.github.com (credentials) all survive the transformation and therefore pass the guard, then fail later inside npm publish with an opaque error — the same class of hole the previous pass asked to close. Normalise the value with parameter expansion and compare case-insensitively so every spelling of the host is caught. Note this still cannot detect a non-GitHub registry without OIDC support, which is why README.md documents the narrower guarantee.

🚨 Critical Issues

  • The host is extracted with a lower-case-only scheme pattern and the trailing s#/.*$## keeps any port, so https://NPM.PKG.GITHUB.COM (scheme not stripped, host uppercased) and https://npm.pkg.github.com:443 (port retained) both compare unequal to npm.pkg.github.com and pass the guard. npm lower-cases the host when it connects, so those inputs hit GitHub Packages anyway and fail later inside npm publish with an opaque auth error — exactly what this fail-fast step is here to prevent. Normalise scheme/port/path and case before comparing.
  • The host comparison is case-sensitive. Host names are case-insensitive, so registry: https://NPM.PKG.GITHUB.COM/ extracts the host unchanged and compares unequal to npm.pkg.github.com, letting the trusted publish proceed against the one registry the guard exists to reject — then fail later inside npm publish as an opaque auth error. Fold the value to lower case before matching so the guard's promise holds for any spelling of the same host. This mirrors the earlier request to normalise the value rather than test it verbatim; the trailing-slash half of that fix is already applied here, the case half is not.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

Comment thread js/publish/action.yml Outdated
npm publish $ACCESS
env:
NODE_AUTH_TOKEN: ${{ inputs.token }}
# Unset when the registry trusts the workflow: a token in the environment takes

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This expression never evaluates to the empty string. GitHub Actions has no ternary operator: cond && a || b returns a only when a is truthy. '' is falsy, so inputs.trusted == 'true' && '' yields '', which makes the || fall through to inputs.token. The result is that when trusted is true — the one case where the token must be absent — the token is set, the publish is authenticated by token, and it is not attested. Invert the condition so the falsy branch is the final fallback and the truthy branch is the token. Note this still leaves an empty-but-present variable; if npm rejects that, replace it by splitting the publish into a trusted step with no env and a non-trusted step with NODE_AUTH_TOKEN.

Suggested change
# Unset when the registry trusts the workflow: a token in the environment takes
# Unset when the registry trusts the workflow: a token in the environment takes
# precedence, and the publish stops being attested. GitHub expressions have no
# ternary, and `cond && '' || token` evaluates to `token` because `''` is falsy,
# so the condition is inverted here.
NODE_AUTH_TOKEN: ${{ inputs.trusted != 'true' && inputs.token || '' }}

Comment thread js/publish/action.yml Outdated
- name: Upgrade npm
if: inputs.trusted == 'true'
shell: bash
run: npm install -g npm@latest

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment above this step names npm 11.5.1 as the floor the registry requires, but the step installs npm@latest. That is unpinned and non-reproducible — a future major can change flag/registry behaviour, and there is no guarantee the installed version clears the documented floor if the registry ever lags. Pin to the major that satisfies the floor and let patch releases float.

Suggested change
run: npm install -g npm@latest
run: npm install -g 'npm@^11.5.1'

Comment thread js/publish/action.yml
registry-url: ${{ inputs.registry }}
scope: ${{ inputs.scope }}

# Node 22 ships npm 10, and a registry only trusts a workflow from 11.5.1 onward.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The trusted path upgrades npm with npm@latest and never checks that the configured registry actually supports OIDC trusted publishing. Two consequences: (1) @latest is non-reproducible, so the toolchain the registry gates on (>= 11.5.1) can silently jump to a future major; (2) with the action's default registry (https://npm.pkg.github.com), the upgrade step installs the npm CLI through an auth-required registry while NODE_AUTH_TOKEN is empty, and any subsequent npm publish fails with an opaque 401 instead of naming the real precondition. Fail fast on the registry and pin the npm floor.

Suggested change
# Node 22 ships npm 10, and a registry only trusts a workflow from 11.5.1 onward.
# Only npmjs.org trusts a workflow, and only from npm 11.5.1 onward; Node 22 ships npm 10.
- name: Prepare trusted publishing
if: inputs.trusted == 'true'
shell: bash
run: |
if [ "${{ inputs.registry }}" != "https://registry.npmjs.org" ]; then
echo "::error::trusted publishing requires registry https://registry.npmjs.org"
exit 1
fi
npm install -g npm@^11.5.1

Comment thread README.md
@@ -410,6 +411,26 @@ token for it:
access: public

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The trusted-publishing example mixes a top-level permissions: block with a with: fragment indented eight spaces, so it is not valid YAML and does not show the caller where the with: belongs. id-token: write is a job/workflow permission, not something that can sit beside a step's with:. Present a complete, correctly indented job so the documented contract (permission + trusted/registry inputs) matches what a caller must actually write.

Suggested change
access: public
Set `trusted` where the registry trusts this workflow instead of a token. The
action then runs Node 22.14 with a current npm, and publishes with no token in
the environment, so the release carries a provenance attestation. The job needs
`id-token: write`, and the registry needs the repository and the workflow
filename recorded against the package:
```yaml
permissions:
contents: write
id-token: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: whilesmart/workflows/js/publish@main
with:
token: ${{ secrets.GITHUB_TOKEN }}
registry: https://registry.npmjs.org
access: public
trusted: 'true'

token is still read, for the GitHub release.

Comment thread js/publish/action.yml Outdated
npm publish $ACCESS
env:
NODE_AUTH_TOKEN: ${{ inputs.token }}
# Unset when the registry trusts the workflow: a token in the environment takes

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The GitHub Actions ternary idiom cond && value || fallback only acts as a ternary when value is truthy. Here the true-branch is '', which is falsy, so inputs.trusted == 'true' && '' || inputs.token evaluates to inputs.token in every case. When trusted: 'true' the token is therefore still present in the environment, npm takes it in preference to OIDC, and the publish is not attested — the change silently fails at its one job. Invert the condition so the token sits in the truthy branch.

Suggested change
# Unset when the registry trusts the workflow: a token in the environment takes
# Empty when the registry trusts the workflow: a token in the environment takes
# precedence, and the publish stops being attested.
NODE_AUTH_TOKEN: ${{ inputs.trusted != 'true' && inputs.token || '' }}

@nfebe
nfebe force-pushed the js-trusted-publishing branch from 06650e3 to 61aa868 Compare September 20, 2026 12:41
@nfebe

nfebe commented Sep 20, 2026

Copy link
Copy Markdown
Contributor Author

Both in. The token one was a real defect: the change failed at the one thing it exists to do.

trusted=true   -> NODE_AUTH_TOKEN = "ghs_secret"
trusted=false  -> NODE_AUTH_TOKEN = "ghs_secret"

&& and || return operand values, and the truthy branch was '', so the token survived in both cases. Rather than invert the condition I split it into two steps, one with env and one without, so no expression decides whether a credential is present.

The registry and version points are in too:

  • The action refuses to run in trusted mode against GitHub Packages, which cannot trust a workflow, and says so rather than failing later with a 401.
  • npm is pinned to ^11.5.1 rather than @latest, and installed from the public registry, since the configured one may want the credential this mode deliberately withholds.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

Comment thread js/publish/action.yml Outdated
registry-url: ${{ inputs.registry }}
scope: ${{ inputs.scope }}

- name: Check the registry can trust a workflow

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard is both narrower and weaker than the documented contract. It only rejects the exact default string https://npm.pkg.github.com, so registry: https://npm.pkg.github.com/ (or any other registry without OIDC support, e.g. a private Verdaccio) passes and then fails later inside npm publish with an opaque auth error — the opposite of the fail-fast this step exists for. Matching by pattern covers the trailing-slash and subdomain cases. The step also does not check the other precondition trusted publishing needs: the calling job must grant id-token: write. GitHub exports ACTIONS_ID_TOKEN_REQUEST_URL only when that permission is present, so it is a cheap, deterministic check that turns a confusing OIDC error into an actionable one. Passing inputs.registry through env: also keeps the caller-supplied value out of the shell text.

Suggested change
- name: Check the registry can trust a workflow
- name: Check the job can mint an OIDC token
if: inputs.trusted == 'true'
shell: bash
run: |
if [ -z "$ACTIONS_ID_TOKEN_REQUEST_URL" ]; then
echo '::error::trusted publishing needs the id-token: write permission on the calling job'
exit 1
fi
- name: Check the registry can trust a workflow
if: inputs.trusted == 'true'
shell: bash
env:
REGISTRY: ${{ inputs.registry }}
run: |
case "$REGISTRY" in
*npm.pkg.github.com*)
echo '::error::trusted publishing needs a registry that supports it; set the registry input'
exit 1
;;
esac

Comment thread CHANGELOG.md Outdated
- `js/publish` takes `trusted`, publishing with a registry that trusts the workflow rather
than a token. It pins Node to 22.14 and npm to 11.5.1 or later, the versions a registry
requires before it will accept an attested publish, and publishes from a step that
carries no token at all. It refuses to run against a registry that cannot trust a

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CHANGELOG promises more than the action enforces. js/publish/action.yml line 56 rejects exactly one host (https://npm.pkg.github.com); it has no way to tell whether any other registry trusts a workflow, so a private Verdaccio/Artifactory/Nexus registry, or even the default host written with a trailing slash or different casing, passes the guard and then dies as an opaque registry error. README.md (line 417) already states the narrower, accurate guarantee ("refuses to run against GitHub Packages"). Bring the CHANGELOG in line so the two documents describe the same contract as the code.

Suggested change
carries no token at all. It refuses to run against a registry that cannot trust a
+ carries no token at all. It refuses to run against GitHub Packages, which cannot
+ trust a workflow.

Comment thread js/publish/action.yml Outdated
registry-url: ${{ inputs.registry }}
scope: ${{ inputs.scope }}

- name: Check the registry can trust a workflow

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard is a second implementation of a fact the action already encodes: inputs.registry is declared above with default: 'https://npm.pkg.github.com', and the guard copies that URL as a literal to decide which registry cannot trust a workflow. Two things follow from the copy. (1) Drift: if the input default ever changes, the guard silently stops rejecting the default, and trusted: 'true' proceeds against GitHub Packages — the exact case it exists to stop. (2) The [ ... = ... ] test matches the string exactly, so the equivalent https://npm.pkg.github.com/ (trailing slash) slips through and the publish fails later instead of at the guard. Normalise the value and keep the knowledge in one place, so the guard's promise matches the CHANGELOG's "refuses to run against a registry that cannot trust a workflow".

Suggested change
- name: Check the registry can trust a workflow
- name: Check the registry can trust a workflow
if: inputs.trusted == 'true'
shell: bash
run: |
REGISTRY="${{ inputs.registry }}"
# The value the `registry` input defaults to is the one registry known here not to
# trust a workflow; compare it normalised so the two cannot drift apart and a
# trailing slash is still caught.
if [ "${REGISTRY%/}" = 'https://npm.pkg.github.com' ]; then
echo "::error::trusted publishing needs a registry that supports it; $REGISTRY cannot"
exit 1
fi

A registry that trusts a workflow mints a short-lived credential for the
job and attests the release to the commit that built it, so nothing
long-lived is stored. It accepts this only from Node 22.14 and a current
npm, and refuses to attest when a token is in the environment.
@nfebe
nfebe force-pushed the js-trusted-publishing branch from 61aa868 to 1673a96 Compare September 20, 2026 12:52

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

Comment thread js/publish/action.yml
exit 1
fi

HOST=$(printf '%s' "$REGISTRY" | sed -E 's#^[a-z]+://##; s#/.*$##')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The host parse only lowercases nothing and strips only scheme:// and the path: sed -E 's#^[a-z]+://##; s#/.*$##' leaves npm.pkg.github.com for the common spelling, but HTTPS://npm.pkg.github.com (uppercase scheme), npm.pkg.github.com:443 (port) and https://user@npm.pkg.github.com (credentials) all survive the transformation and therefore pass the guard, then fail later inside npm publish with an opaque error — the same class of hole the previous pass asked to close. Normalise the value with parameter expansion and compare case-insensitively so every spelling of the host is caught. Note this still cannot detect a non-GitHub registry without OIDC support, which is why README.md documents the narrower guarantee.

Suggested change
HOST=$(printf '%s' "$REGISTRY" | sed -E 's#^[a-z]+://##; s#/.*$##')
# Normalise how a registry URL is spelled, so scheme case, credentials and a port
# cannot hide the one host this guard is able to detect.
HOST="${REGISTRY#*://}"
HOST="${HOST##*@}"
HOST="${HOST%%/*}"
HOST="${HOST%%:*}"
if [ "${HOST,,}" = 'npm.pkg.github.com' ]; then
echo "::error::$HOST cannot trust a workflow; set the registry input to one that can"
exit 1
fi

Comment thread js/publish/action.yml
exit 1
fi

HOST=$(printf '%s' "$REGISTRY" | sed -E 's#^[a-z]+://##; s#/.*$##')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The host is extracted with a lower-case-only scheme pattern and the trailing s#/.*$## keeps any port, so https://NPM.PKG.GITHUB.COM (scheme not stripped, host uppercased) and https://npm.pkg.github.com:443 (port retained) both compare unequal to npm.pkg.github.com and pass the guard. npm lower-cases the host when it connects, so those inputs hit GitHub Packages anyway and fail later inside npm publish with an opaque auth error — exactly what this fail-fast step is here to prevent. Normalise scheme/port/path and case before comparing.

Suggested change
HOST=$(printf '%s' "$REGISTRY" | sed -E 's#^[a-z]+://##; s#/.*$##')
HOST=$(printf '%s' "$REGISTRY" | sed -E 's#^[A-Za-z]+://##; s#[/:].*$##' | tr 'A-Z' 'a-z')
if [ "$HOST" = 'npm.pkg.github.com' ]; then

Comment thread js/publish/action.yml
exit 1
fi

HOST=$(printf '%s' "$REGISTRY" | sed -E 's#^[a-z]+://##; s#/.*$##')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The host comparison is case-sensitive. Host names are case-insensitive, so registry: https://NPM.PKG.GITHUB.COM/ extracts the host unchanged and compares unequal to npm.pkg.github.com, letting the trusted publish proceed against the one registry the guard exists to reject — then fail later inside npm publish as an opaque auth error. Fold the value to lower case before matching so the guard's promise holds for any spelling of the same host. This mirrors the earlier request to normalise the value rather than test it verbatim; the trailing-slash half of that fix is already applied here, the case half is not.

Suggested change
HOST=$(printf '%s' "$REGISTRY" | sed -E 's#^[a-z]+://##; s#/.*$##')
HOST=$(printf '%s' "$REGISTRY" | tr '[:upper:]' '[:lower:]' | sed -E 's#^[a-z][a-z0-9+.-]*://##; s#/.*$##')
if [ "$HOST" = 'npm.pkg.github.com' ]; then
echo "::error::$HOST cannot trust a workflow; set the registry input to one that can"
exit 1
fi

@nfebe
nfebe merged commit 37461c3 into main Sep 20, 2026
4 checks passed
@nfebe
nfebe deleted the js-trusted-publishing branch September 20, 2026 12:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant