Publishing a Shared axe Rule Package to a Private Registry

A rule set written for one component library, as described in component-specific rule writing, stops being a snippet the moment a second repository needs it — and the cheapest distribution mechanism that already exists in every JavaScript pipeline is npm. This guide is part of Custom Rule Testing & Distribution, and it covers the packaging mechanics specifically: an exports map that serves a browser bundle and a Node entry point from one tarball, the .npmrc and token wiring for a GitHub Actions publish job, build provenance, and how a consuming pipeline installs the scoped package without the registry token leaking into a build log or a Docker layer.

Root Cause

The default npm publish assumes a library with one entry point consumed by a bundler. A rule package is not that. It has two genuinely different consumers: a Node process that requires the rules and hands them to an in-process axe instance, and a browser context where the rules arrive as an injected script and must attach themselves to window with no module loader available. Publish only the CommonJS build and every Playwright or Cypress consumer writes its own bundling step, badly and differently. Publish only a browser IIFE and the jsdom fixture tests cannot import the rule definitions at all.

Authentication is the second failure. A private registry needs a token at install time as well as at publish time, and the reflex is to write the token into .npmrc and commit it, or to echo it into .npmrc in a build step where it lands in the log. Both leak. Worse, a token with write:packages scope handed to a consumer’s install job means every application repository holds a credential that can publish a new rule version — and a rule version change is a change to everybody’s merge gate, so that credential is more dangerous than it looks.

The third problem is trust. Once six teams gate merges on a package, “which commit built version 3.5.0” needs an answer that does not depend on someone’s memory. npm provenance attaches a signed attestation linking the published tarball to the workflow run and commit that produced it, which turns that question into a lookup.

Dual entry points from a single published tarball The exports map routes a Node require to the CommonJS build for jsdom and Playwright in-process use, and a browser script tag to the IIFE bundle that attaches to window, with axe supplied by the host in both cases. registry tarball @acme/axe-rules 3.5.0 + provenance exports condition map require(...) jsdom, Playwright in-process axe addInitScript window.dsA11yRules page axe axe-core peer, not bundled both paths configure the consumer's axe instance, never a bundled copy
One tarball, two resolution paths — and in both of them axe-core is supplied by the host, never bundled in.

Configuration

The exports map is what makes the dual entry work without publishing two packages. The require condition serves the CommonJS build; a named ./browser subpath serves the IIFE so a consumer can read it off disk and inject it as text:

{
  "name": "@acme/axe-rules",
  "version": "3.5.0",
  "license": "UNLICENSED",
  "exports": {
    ".": {
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    },
    "./browser": "./dist/browser.js",
    "./fixtures/*": "./fixtures/*"
  },
  "files": ["dist", "fixtures"],
  "peerDependencies": {
    "axe-core": "^4.10.0"
  },
  "publishConfig": {
    "access": "restricted",
    "registry": "https://npm.pkg.github.com",
    "provenance": true
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/acme/axe-rules.git"
  }
}

Four fields carry weight here. publishConfig.registry pins the publish target inside the package, so a stray npm publish from a laptop cannot push an internal rule set to the public registry. access: "restricted" is the second layer of the same guard. provenance: true asks npm to generate and upload the signed build attestation, which requires the workflow to hold an OIDC token. And files is the field that most often causes a broken release: anything not listed is absent from the tarball, so omitting dist publishes a package with no code in it, which installs cleanly and fails at require time in six consumer repositories at once.

The repository.url must match the repository the workflow runs in or provenance generation fails with a mismatch error rather than silently skipping. The ./fixtures/* subpath export is there so a consumer can load the shipped good and bad fixtures directly out of node_modules and re-run them against its own pinned axe-core version, which is the cheapest way to prove a rule still behaves as documented after an axe upgrade.

The browser entry that feeds the IIFE build is three lines, and its job is to expose the same configure function under a predictable global without touching window.axe at load time:

// src/browser-entry.js — bundled to dist/browser.js as the dsA11yRules global.
const { configure, ruleIds } = require('./configure.js');

// Deliberately no side effects: the host page decides when axe exists.
module.exports = { configure, ruleIds, version: require('../package.json').version };

Exposing version costs nothing and answers the question a consumer’s failing pipeline always raises first, which is which rule build the browser actually loaded. Reading it in the page beats inferring it from a lockfile in a different job.

The publish job authenticates through setup-node’s registry support rather than by writing .npmrc by hand:

# .github/workflows/publish.yml
name: publish-rules
on:
  push:
    tags: ['v*.*.*']

permissions:
  contents: read
  packages: write        # publish scope
  id-token: write        # required for npm provenance attestation

jobs:
  publish:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          # Writes a scoped .npmrc in the runner home dir, outside the workspace.
          registry-url: 'https://npm.pkg.github.com'
          scope: '@acme'
      - run: npm ci
      - run: npm run build            # emits dist/index.cjs and dist/browser.js
      - run: npm test                 # fixture corpus must pass before publishing
      - name: Fail if the tag and package version disagree
        run: |
          test "v$(node -p "require('./package.json').version")" = "${GITHUB_REF_NAME}"
      - run: npm publish
        env:
          # setup-node's .npmrc reads this; the value is never echoed.
          NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

setup-node writes the .npmrc into the runner’s home directory, not the checked-out workspace, so the file cannot be picked up by a later docker build context or committed by an automated commit step. NODE_AUTH_TOKEN is referenced only as an environment variable, never interpolated into a shell string, so it stays out of the log even with step debugging enabled. And the tag-versus-version check is worth the three lines: a tag that does not match package.json publishes a version nobody expects and cannot be republished after deletion. The npm test step in front of the publish is the fixture corpus from unit-testing custom axe rules with Jest fixtures; a rule that no longer applies to its own fixture must never reach the registry.

Credential scope across the publish job steps The checkout and build steps need no credentials, setup-node writes a scoped npmrc into the runner home directory, provenance uses a short-lived OIDC token, and only the publish step reads the write-scoped registry token. step credential in scope checkout + npm ci + build none setup-node registry-url ~/.npmrc, outside workspace provenance attestation id-token, short-lived OIDC npm publish NODE_AUTH_TOKEN, write scope one step only
Only the final step ever holds a write-scoped token, and the .npmrc never enters the checked-out workspace.

Validation

Confirm the published artifact before any consumer touches it. Three checks: the tarball contains both entry points, the exports resolve, and the provenance attestation is attached to the version you just pushed.

# Resolve both entry paths from a clean directory using a read-only token.
mkdir -p /tmp/verify && cd /tmp/verify && npm init -y >/dev/null
npm i -D "@acme/axe-rules@3.5.0" --registry=https://npm.pkg.github.com

node -e "const m=require('@acme/axe-rules'); console.log(m.ruleIds.join(','))"
# ds-grid-header-scope,ds-toolbar-controls-named

node -e "console.log(require.resolve('@acme/axe-rules/browser'))"
# /tmp/verify/node_modules/@acme/axe-rules/dist/browser.js

# The browser bundle must attach a global and must NOT carry its own axe copy.
grep -c 'dsA11yRules' node_modules/@acme/axe-rules/dist/browser.js   # 1 or more
grep -c 'axe.version' node_modules/@acme/axe-rules/dist/browser.js   # 0 = axe stayed external

npm view "@acme/axe-rules@3.5.0" dist.attestations
# { provenance: { predicateType: 'https://slsa.dev/provenance/v1' } }

The grep -c 'axe.version' returning 0 is the assertion that matters most, because a bundled axe-core is silent: the rules register against the wrong instance and the consumer’s scan reports zero violations from a package that appears to be installed correctly.

Then verify the browser path in the runner that will actually use it, so the global name and the injection order are proven rather than assumed:

// tests/a11y/inject-rules.spec.ts
import { test, expect } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';

const resolve = createRequire(import.meta.url);
const bundle = readFileSync(resolve.resolve('@acme/axe-rules/browser'), 'utf8');
const axeSource = readFileSync(resolve.resolve('axe-core/axe.min.js'), 'utf8');

test('packaged rules register against the page axe instance', async ({ page }) => {
  await page.goto('/components/toolbar');
  await page.addScriptTag({ content: axeSource });   // axe first: rules need it present
  await page.addScriptTag({ content: bundle });      // then the packaged rule set
  const ids = await page.evaluate(() =>
    (window as any).dsA11yRules.configure((window as any).axe, { enableAll: true })
  );
  expect(ids).toContain('ds-toolbar-controls-named');
});

Injection order is load-bearing. The bundle reads window.axe at configure() time rather than at load time, so injecting the rules first still works — but only because of that deferral, and a bundle written to self-register on load would silently do nothing. Asserting on the returned id array rather than on a subsequent scan result keeps the failure legible: a wrong global name, a missing file, or a rule dropped from the release all report here instead of surfacing later as an unexplained change in violation count.

Edge Cases and Conditional Guards

  • Fork pull requests. A workflow triggered by pull_request from a fork receives no secrets, so any job that installs the scoped package fails at authentication rather than at the test. Split the accessibility job so fork builds run only the rules that ship with the repository, or move the scan to pull_request_target with a read-only token.
  • Docker builds. A Dockerfile that runs npm ci needs the read token, and an ARG for it is baked into the image history. Use a BuildKit secret mount so the token exists only for the duration of that layer, and never COPY .npmrc into the image.
  • Registry outages and mirrors. A private registry going down blocks every consumer’s install and therefore every merge. Point the scope at an internal proxy that caches the tarball, and pin exact versions so a cached copy is always a valid resolution.

Pipeline Impact

For the rule package repository, the publish job is the release gate: it exits non-zero on a fixture failure, a tag mismatch, or a provenance error, and a failed publish leaves consumers on their previous pinned version with no impact at all. That property — publishing is not deploying — is what makes the whole arrangement safe, and it is why the version bump in each consumer stays a reviewed pull request rather than an automatic install, under the policy set out in versioning custom rules without breaking existing pipelines.

For consumer pipelines the impact is one extra authenticated install. Give those jobs a read-only token in a separate secret from the publish token, so a compromised application repository cannot publish a rule version and thereby change what blocks merges everywhere else. Cache node_modules on the lockfile hash the same way the Docker-based execution guide caches scanner binaries, and the authenticated install costs a few seconds on a cold cache and nothing afterwards.

Which mechanism carries the read token depends on where the install runs, and the three cases have genuinely different safe answers:

Choosing a token mechanism for the consumer install A runner install uses setup-node with a read-only secret, a Docker build uses a BuildKit secret mount, and a fork pull request gets no secret at all so the scoped scan must be skipped or moved. where does npm ci run? scoped install of @acme on the runner setup-node + read only secret inside docker build BuildKit secret mount never ARG or COPY fork pull request no secret available skip or relocate token in ~/.npmrc outside the workspace token in one layer absent from history default rules only gate on the merge queue
Each install location has one safe token mechanism; the fork case has none, so the scoped scan moves rather than acquiring a credential.

The fork branch is the one teams get wrong most often, usually by reaching for pull_request_target to make the secret available. That runs workflow code with write access against an untrusted head, which trades an accessibility gap for a supply-chain hole. Running the fork build against axe-core’s default rule set and re-running the full scoped scan in the merge queue keeps the credential inside repositories the organisation controls, at the cost of catching custom-rule violations one step later.

Common Pitfalls

  • Omitting dist from files, publishing a code-free tarball that installs cleanly and fails at require time.
  • Bundling axe-core into the browser build, so rules register against a second instance and every scan reports zero violations.
  • Writing .npmrc into the workspace with a shell echo, which puts the token in the build log and the Docker context.
  • Handing consumers the write:packages token, so any application repository can publish a gate-changing rule version.
  • Publishing a tag whose version disagrees with package.json, producing a release no consumer’s range or pin will pick up.
  • Setting provenance: true without id-token: write permission, which fails the publish after the build has already succeeded.

FAQ

Why publish the fixtures as well as the built rules? Consumers pin an axe-core version of their own, and a rule’s behaviour can shift between axe minors — particularly around accessible-name computation. Shipping the fixture corpus lets a consumer re-run the same good, bad and neutral fixtures against its own axe version and prove the rule still behaves as documented before it raises its pin.

Is a scoped GitHub Packages registry enough, or is Artifactory needed? GitHub Packages is sufficient when every consumer already lives in the same organisation, because the built-in GITHUB_TOKEN covers publish and a read-only personal or app token covers install. A separate artifact repository earns its keep when consumers span organisations, when a caching proxy is needed for install reliability, or when retention policy has to be enforced independently of the source repository.

What does provenance actually prevent? It does not prevent a bad rule from being published; it makes the origin of a published version verifiable. Given version 3.5.0, the attestation names the workflow, the commit and the repository that produced it, so an unexplained version — or one built outside the release workflow — is detectable rather than a matter of trust in whoever ran npm publish.