diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..accc01c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Shell scripts run on Linux runners and on Git bash alike; a CRLF checkout on +# Windows (core.autocrlf=true, the Git for Windows default) would put a `\r` at +# the end of every line, which bash reports as a command not found. +*.sh text eol=lf diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml index 9972a1e..d8074d9 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build-image.yml @@ -63,6 +63,32 @@ on: type: string required: false default: ghcr.io + runs-on: + description: >- + Where to run the build: a single runner label, or a JSON array or + object for anything more (`'["self-hosted", "linux"]'`, + `'{"group": "internal"}'`). Defaults to ubuntu-latest. + type: string + required: false + default: ubuntu-latest + npm-registry: + description: >- + npm registry to install the build's dependencies from, for runners + that cannot reach registry.npmjs.org — an Artifactory npm remote, say. + Empty means whatever npm resolves on the runner. The lockfile needs + no change: npm rewrites its registry.npmjs.org hosts to this registry. + type: string + required: false + default: '' + node-mirror: + description: >- + Mirror of https://nodejs.org to download Node from, for runners that + can reach neither nodejs.org nor the github.com release assets + `setup-node` tries first. Not needed when the runner image already + carries Node in its tool cache. + type: string + required: false + default: '' secrets: docs-token: description: >- @@ -74,6 +100,14 @@ on: required: false registry-password: required: false + npm-token: + description: >- + Bearer token for `npm-registry`. Leave unset for a registry that + allows anonymous reads. + required: false + node-mirror-token: + description: Authorization header value for `node-mirror`. + required: false outputs: image: description: 'The first pushed image reference, when one was pushed.' @@ -85,7 +119,10 @@ permissions: jobs: build: name: Build - runs-on: ubuntu-latest + # A label is used as it is; anything starting with `[` or `{` is parsed, so + # a caller can name several labels or a runner group. `&&`/`||` short-circuit, + # so fromJSON never sees a bare label. + runs-on: ${{ (startsWith(inputs.runs-on, '[') || startsWith(inputs.runs-on, '{')) && fromJSON(inputs.runs-on) || inputs.runs-on }} outputs: image: ${{ steps.push.outputs.image }} steps: @@ -108,9 +145,44 @@ jobs: node-version: '24' cache: npm cache-dependency-path: knowledge-base/package-lock.json + mirror: ${{ inputs.node-mirror }} + mirror-token: ${{ secrets.node-mirror-token }} + + # Same mechanism as the publishing actions (actions/lib/npm-registry.sh): + # project-level npm config next to the lockfile, applied to this install + # and nothing else. Inlined rather than run from the checkout so that a + # caller pinning an older kb-ref still gets the registry it asked for. + - name: Point npm at a private registry + if: ${{ inputs.npm-registry != '' }} + working-directory: knowledge-base + env: + KB_NPM_REGISTRY: ${{ inputs.npm-registry }} + KB_NPM_TOKEN: ${{ secrets.npm-token }} + run: | + set -euo pipefail + case "$KB_NPM_REGISTRY" in + *[[:space:]]*) + echo "::error::npm-registry must be a single URL with no whitespace, got '$KB_NPM_REGISTRY'" + exit 1 ;; + http://*|https://*) ;; + *) + echo "::error::npm-registry must be an http(s) URL, got '$KB_NPM_REGISTRY'" + exit 1 ;; + esac + registry="${KB_NPM_REGISTRY%/}/" + { + echo "registry=${registry}" + # A reference npm expands at run time, so the token never lands on disk. + if [ -n "${KB_NPM_TOKEN:-}" ]; then + echo "${registry#*:}:_authToken=\${KB_NPM_TOKEN}" + fi + } > .npmrc + echo "npm installs from ${registry}${KB_NPM_TOKEN:+ (authenticated)}" - name: Install working-directory: knowledge-base + env: + KB_NPM_TOKEN: ${{ secrets.npm-token }} run: npm ci - name: Resolve the registry @@ -141,6 +213,9 @@ jobs: # self-hosted runner need not have. github.token covers public # docs repos; private ones need docs-token. GITHUB_TOKEN: ${{ secrets.docs-token || github.token }} + # The docs repos and the registry are the only things a build should + # talk to. Matters on a runner whose egress is a short allowlist. + ASTRO_TELEMETRY_DISABLED: '1' run: node scripts/build-vite.js ${{ inputs.headless && '--headless' || '' }} # Without an image name this is a dry run: prove the registry builds, keep diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a28d56f..7ab17e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -182,6 +182,9 @@ jobs: kb-ref: ${{ github.sha }} registry: apps.json strict: false + # The JSON form, so the branch of the runs-on expression that parses it + # runs here; the bare-label default is what every other caller exercises. + runs-on: '["ubuntu-latest"]' # ── 5. Container image ───────────────────────────────────────────────────── # # Builds the runtime image from the dist/ the build job produced, then scans diff --git a/AGENTS.md b/AGENTS.md index 6c5f3d8..2d40e41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,6 +118,17 @@ anything there, rebuild, then run it. repository must carry `"optional": true` so a fresh clone still builds. Never add a registry entry that requires network or a sibling checkout without it. +### Lockfiles resolve to registry.npmjs.org + +Every `resolved` in `package-lock.json` and `actions/package-lock.json` must +point at `https://registry.npmjs.org/`, and `tests/private-registry.spec.js` +fails the build otherwise. Consumers on private networks install from an +internal mirror by *configuring* a registry; npm rewrites the default host to +it at fetch time, but only the default host. A lockfile regenerated behind a +corporate `~/.npmrc` bakes that registry's URLs in and installs in one network +only. If your machine has such an `.npmrc`, run +`npm install --registry=https://registry.npmjs.org/` when touching either lockfile. + ### Two build modes, one document Every page renders through `src/layouts/Base.astro`. Headless (web-fragment) and diff --git a/CLAUDE.md b/CLAUDE.md index 60de197..92ef942 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -157,6 +157,10 @@ commands listed in `AGENTS.md`: - `artifact-safety.spec.js` — tarball extraction guards (traversal, absolute paths, symlinks). - `nginx-config.spec.js` — static assertions on `nginx.conf`/`nginx.headers.conf`, including that the CSP the Express mirror serves is byte-identical to nginx's. +- `private-registry.spec.js` — both lockfiles resolve to `registry.npmjs.org` (npm rewrites + only that host to a configured mirror), the `npm-registry`/`npm-token`/`node-mirror` inputs + exist on both actions and `build-image.yml`, and `actions/lib/npm-registry.sh` writes the + project `.npmrc` without ever putting the token on disk. - `standalone.spec.js` — the `:3000` fragment server directly (`playwright.config.ci.js`). - `container.spec.js` — the real nginx image (`playwright.config.docker.js`, needs Docker). - `support/fragment.js` — shadow-DOM traversal + reframed-body wait/query helpers. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e357a6d..c8fb27e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,6 +14,10 @@ The build and tests are fully hermetic — they use the committed `tests/fixtures/docs-example.dist.tar.gz` fixture (registered via `apps.json`), so no `GITHUB_TOKEN`, network, or sibling repository is required. +If your `~/.npmrc` points at a corporate registry, install with +`--registry=https://registry.npmjs.org/` before committing a lockfile change: +both lockfiles must keep resolving to the public registry (see `AGENTS.md`). + See [`CLAUDE.md`](CLAUDE.md) for an architecture overview and the full command list, and [`README.md`](README.md) for usage. diff --git a/actions/lib/npm-registry.sh b/actions/lib/npm-registry.sh new file mode 100755 index 0000000..25eb941 --- /dev/null +++ b/actions/lib/npm-registry.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Points the `npm ci` that follows at a private npm registry. +# +# Consuming repositories may run on runners inside a private network where +# registry.npmjs.org is unreachable and every package has to come from an +# internal mirror (a JFrog Artifactory npm remote, typically). The lockfiles in +# this repository resolve every package to registry.npmjs.org, and that is fine: +# npm's `replace-registry-host` (default `npmjs`) rewrites that host to the +# configured registry at fetch time, and the lockfile's integrity hashes still +# verify because the mirror serves the same tarballs. All that is missing is a +# way to say which registry — this script. +# +# Usage: npm-registry.sh [DIR] +# +# KB_NPM_REGISTRY the registry URL. Empty: write nothing, so npm resolves +# the registry the ordinary way — registry.npmjs.org, or +# whatever the runner's own npm configuration says. +# KB_NPM_TOKEN optional bearer token for that registry. +# +# Writes DIR/.npmrc (default: the current directory), which npm reads as +# project-level config for anything installed from DIR. Project config layers +# on top of the runner's user and global config rather than replacing it, and +# it reaches no other step of the calling workflow. +# +# The token is written as a `${KB_NPM_TOKEN}` reference, which npm expands from +# the environment when it runs, so the secret never lands on disk. The step +# that runs `npm ci` must therefore carry KB_NPM_TOKEN in its environment. +set -euo pipefail + +dir="${1:-.}" +registry="${KB_NPM_REGISTRY:-}" +token="${KB_NPM_TOKEN:-}" + +if [ -z "$registry" ]; then + exit 0 +fi + +# An .npmrc is line-oriented; anything that is not a single http(s) URL is +# either a typo or an attempt to smuggle a second setting in, and both should +# fail before npm sees them. +case "$registry" in + *[[:space:]]*) + echo "::error::npm-registry must be a single URL with no whitespace, got '$registry'" + exit 1 + ;; + http://*|https://*) ;; + *) + echo "::error::npm-registry must be an http(s) URL, got '$registry'" + exit 1 + ;; +esac + +# npm keys credentials by the registry URL without its scheme, trailing slash +# included, so normalise to exactly one. +registry="${registry%/}/" +auth_key="${registry#*:}" + +{ + echo "registry=${registry}" + if [ -n "$token" ]; then + echo "${auth_key}:_authToken=\${KB_NPM_TOKEN}" + fi +} > "${dir}/.npmrc" + +if [ -n "$token" ]; then + echo "npm installs from ${registry} (authenticated)" +else + echo "npm installs from ${registry}" +fi diff --git a/actions/publish-docs/README.md b/actions/publish-docs/README.md index 2a166f7..7d33cfc 100644 --- a/actions/publish-docs/README.md +++ b/actions/publish-docs/README.md @@ -36,6 +36,10 @@ markdown for you and needs no manifest at all. | `github-token` | ☐ | `${{ github.token }}` | Needs `contents: write` | | `notify-repo` | ☐ | — | `owner/name` of a deployment repo to notify on publish | | `notify-token` | ☐ | — | Token with `contents: write` on `notify-repo` only | +| `npm-registry` | ☐ | — | npm registry to install the action's dependencies from, for runners that cannot reach `registry.npmjs.org` | +| `npm-token` | ☐ | — | Bearer token for `npm-registry` | +| `node-mirror` | ☐ | — | Mirror of `https://nodejs.org` for `setup-node`, when the runner reaches neither nodejs.org nor github.com release assets | +| `node-mirror-token` | ☐ | — | `Authorization` header value for `node-mirror` | `dist` is the app's own directory when the manifest declares **one** app — a repo publishing a single site should not have to invent a subdirectory named after its @@ -92,6 +96,38 @@ GitHub-hosted and self-hosted runners: The runner must be new enough for Node 24 actions (`actions/runner` ≥ 2.327.1), which `actions/setup-node@v7` already requires. +### Runners in a private network + +If your runners cannot reach `registry.npmjs.org` and every package has to come +from an internal mirror — an Artifactory npm remote, typically — point the +action at it: + +```yaml +- uses: AbsaOSS/knowledge-base/actions/publish-docs@v1 + with: + manifest: kb-docs.json + dist: dist + npm-registry: https://artifactory.example.com/artifactory/api/npm/npm-remote/ + npm-token: ${{ secrets.ARTIFACTORY_TOKEN }} # omit for anonymous reads +``` + +The action's lockfile resolves every package to `registry.npmjs.org` and stays +that way: npm rewrites that host to the configured registry when it fetches +(`replace-registry-host`), and the lockfile's integrity hashes still verify +because the mirror serves the same tarballs. The registry is applied as +project-level npm config for this install only — the runner's own npm +configuration and the rest of your workflow are untouched, and the token is +read from the environment rather than written to disk. + +A runner whose own `~/.npmrc` already names the mirror needs none of this: leave +`npm-registry` empty and npm uses what the machine says. + +`setup-node` fetches Node from the `actions/node-versions` releases on +github.com, falling back to nodejs.org. If both are blocked, `node-mirror` +names a mirror of `https://nodejs.org` (an Artifactory generic remote works), +with `node-mirror-token` as its `Authorization` header. A runner image that +already carries Node 20 in its tool cache downloads nothing. + ## Notifying a deployment Set `notify-repo` and `notify-token` to fire a `kb-docs-published` diff --git a/actions/publish-docs/action.yml b/actions/publish-docs/action.yml index 5bb9217..c61c72b 100644 --- a/actions/publish-docs/action.yml +++ b/actions/publish-docs/action.yml @@ -48,6 +48,32 @@ inputs: `github.token` cannot dispatch to another repository. required: false default: '' + npm-registry: + description: >- + npm registry to install the action's own dependencies from, for runners + that cannot reach registry.npmjs.org — an Artifactory npm remote, say. + Empty means whatever npm resolves on the runner, which is the public + registry unless the machine's npm configuration says otherwise. + required: false + default: '' + npm-token: + description: >- + Bearer token for `npm-registry`. Leave empty for a registry that allows + anonymous reads. + required: false + default: '' + node-mirror: + description: >- + Mirror of https://nodejs.org to download Node from, for runners that can + reach neither nodejs.org nor the github.com release assets `setup-node` + tries first. Not needed when the runner image already carries Node in its + tool cache. + required: false + default: '' + node-mirror-token: + description: Authorization header value for `node-mirror`. + required: false + default: '' outputs: slugs: @@ -72,10 +98,30 @@ runs: uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '20' + mirror: ${{ inputs.node-mirror }} + mirror-token: ${{ inputs.node-mirror-token }} + + # A private registry is applied as project-level npm config next to the + # lockfile, so it reaches nothing outside this install: not the runner's own + # npm configuration, not the calling workflow's later steps. The lockfile + # itself needs no change — npm rewrites its registry.npmjs.org hosts to the + # configured registry (`replace-registry-host`). Skipped when unset. + - name: Point npm at a private registry + if: ${{ inputs.npm-registry != '' }} + shell: bash + working-directory: ${{ github.action_path }}/.. + env: + KB_NPM_REGISTRY: ${{ inputs.npm-registry }} + KB_NPM_TOKEN: ${{ inputs.npm-token }} + run: bash "$GITHUB_ACTION_PATH/../lib/npm-registry.sh" - name: Install publisher dependencies shell: bash working-directory: ${{ github.action_path }}/.. + env: + # Expanded by npm from the .npmrc the previous step wrote; the token is + # never written to disk. + KB_NPM_TOKEN: ${{ inputs.npm-token }} run: npm ci --omit=dev --no-audit --no-fund - name: Verify and pack the artifact diff --git a/actions/publish-single-page-docs/action.yml b/actions/publish-single-page-docs/action.yml index 090218e..54b1797 100644 --- a/actions/publish-single-page-docs/action.yml +++ b/actions/publish-single-page-docs/action.yml @@ -27,6 +27,32 @@ inputs: Token used to upload the release asset. Needs `contents: write`. required: false default: ${{ github.token }} + npm-registry: + description: >- + npm registry to install the action's own dependencies from, for runners + that cannot reach registry.npmjs.org — an Artifactory npm remote, say. + Empty means whatever npm resolves on the runner, which is the public + registry unless the machine's npm configuration says otherwise. + required: false + default: '' + npm-token: + description: >- + Bearer token for `npm-registry`. Leave empty for a registry that allows + anonymous reads. + required: false + default: '' + node-mirror: + description: >- + Mirror of https://nodejs.org to download Node from, for runners that can + reach neither nodejs.org nor the github.com release assets `setup-node` + tries first. Not needed when the runner image already carries Node in its + tool cache. + required: false + default: '' + node-mirror-token: + description: Authorization header value for `node-mirror`. + required: false + default: '' outputs: slugs: @@ -51,10 +77,30 @@ runs: uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '20' + mirror: ${{ inputs.node-mirror }} + mirror-token: ${{ inputs.node-mirror-token }} + + # A private registry is applied as project-level npm config next to the + # lockfile, so it reaches nothing outside this install: not the runner's own + # npm configuration, not the calling workflow's later steps. The lockfile + # itself needs no change — npm rewrites its registry.npmjs.org hosts to the + # configured registry (`replace-registry-host`). Skipped when unset. + - name: Point npm at a private registry + if: ${{ inputs.npm-registry != '' }} + shell: bash + working-directory: ${{ github.action_path }}/.. + env: + KB_NPM_REGISTRY: ${{ inputs.npm-registry }} + KB_NPM_TOKEN: ${{ inputs.npm-token }} + run: bash "$GITHUB_ACTION_PATH/../lib/npm-registry.sh" - name: Install publisher dependencies shell: bash working-directory: ${{ github.action_path }}/.. + env: + # Expanded by npm from the .npmrc the previous step wrote; the token is + # never written to disk. + KB_NPM_TOKEN: ${{ inputs.npm-token }} run: npm ci --omit=dev --no-audit --no-fund - name: Render markdown and pack the bundle diff --git a/contract/DEPLOYMENT.md b/contract/DEPLOYMENT.md index b3dbe6b..3c9ad81 100644 --- a/contract/DEPLOYMENT.md +++ b/contract/DEPLOYMENT.md @@ -150,6 +150,73 @@ environment — so both run unchanged on self-hosted runners. --- +## Private networks: self-hosted runners and an internal registry + +A deployment repository whose runners sit inside a private network — no route +to `registry.npmjs.org`, every package served by an internal Artifactory — sets +three more things and changes nothing else: + +```yaml + build: + needs: token + uses: AbsaOSS/knowledge-base/.github/workflows/build-image.yml@v1 + with: + kb-ref: v1.0.0 + registry: apps.json + image-name: artifactory.example.com/docker-local/knowledge-base + registry-host: artifactory.example.com + runs-on: '["self-hosted", "linux"]' + npm-registry: https://artifactory.example.com/artifactory/api/npm/npm-remote/ + node-mirror: https://artifactory.example.com/artifactory/nodejs-remote # if needed + secrets: + docs-token: ${{ needs.token.outputs.token }} + npm-token: ${{ secrets.ARTIFACTORY_TOKEN }} + registry-username: ${{ secrets.ARTIFACTORY_USER }} + registry-password: ${{ secrets.ARTIFACTORY_TOKEN }} +``` + +| Input | What it covers | +|---|---| +| `runs-on` | A single label as it is; a JSON array or object for several labels or a runner group. Default `ubuntu-latest`. | +| `npm-registry`, `npm-token` (secret) | The `npm ci` of the build's own dependencies. Empty means whatever npm resolves on the runner. | +| `node-mirror`, `node-mirror-token` (secret) | Where `setup-node` downloads Node when it can reach neither the github.com release assets it tries first nor nodejs.org. A mirror of `https://nodejs.org` — an Artifactory generic remote. Not needed when the runner image carries Node 24 in its tool cache. | + +The publishing actions take the same four inputs, so docs repos on the same +runners publish the same way (`actions/publish-docs/README.md`, +`SINGLE_PAGE.md`). + +**Why the lockfile needs no change.** Every `resolved` URL in the lockfile +points at `registry.npmjs.org`, and it stays that way. npm's +`replace-registry-host` (default `npmjs`) rewrites that host to the configured +registry at fetch time; the `integrity` hashes still verify because the mirror +serves the same tarballs. That is what lets one lockfile, one workflow and one +action ref serve GitHub-hosted and internal runners alike. It is also why the +lockfile must never be regenerated behind a corporate `.npmrc`: npm rewrites +*only* the default host, so a lockfile carrying Artifactory URLs installs in +exactly one network. CI enforces the rule. + +**What the registry input does not touch.** It is written as project-level npm +config next to the lockfile being installed, for that install only. The +runner's own npm configuration is layered underneath, not replaced — so a +runner that already carries an `~/.npmrc` naming the mirror can leave +`npm-registry` empty — and nothing is exported into the calling workflow's +later steps. The token is referenced from the environment, never written to +disk. + +**What no workflow input can cover.** + +- `docker build` pulls the nginx base image from Docker Hub by digest. On a + private network that is a *daemon* setting — Artifactory's Docker remote as a + `registry-mirrors` entry in the runner's `/etc/docker/daemon.json` — and the + digest pin in the `Dockerfile` verifies the mirror served the same image. +- `setup-node`'s npm cache uses GitHub's cache service, and the App token comes + from `api.github.com`. Both are on GitHub's published list of hosts a + self-hosted runner must reach. +- Pushing the image goes to `registry-host` with `registry-username` / + `registry-password`, which already cover an Artifactory Docker repository. + +--- + ## Rebuilding when a docs repo publishes The publishing actions accept `notify-repo` and `notify-token`. With both set, @@ -210,3 +277,6 @@ alone cannot give once `latest` has moved. - [ ] `concurrency` set so a burst of publishes collapses into one build - [ ] Docs repos that should trigger rebuilds have `notify-repo` / `notify-token` - [ ] A dry-run check on PRs to the registry (`image-name` empty) +- [ ] On runners without a route to `registry.npmjs.org`: `runs-on`, + `npm-registry` and the `npm-token` secret set; `node-mirror` if Node is + neither preinstalled nor downloadable; the Docker daemon's mirror configured diff --git a/contract/SINGLE_PAGE.md b/contract/SINGLE_PAGE.md index dc8554e..c352586 100644 --- a/contract/SINGLE_PAGE.md +++ b/contract/SINGLE_PAGE.md @@ -70,6 +70,10 @@ That is the entire onboarding on your side. Multiple docs go in the same list: | `docs` | ✅ | — | YAML (or JSON) **list** of doc definitions — see below | | `release-tag` | ☐ | the triggering release, else the repo's latest | Release to attach `kb-docs.tar.gz` to | | `github-token` | ☐ | `${{ github.token }}` | Needs `contents: write` | +| `npm-registry` | ☐ | — | npm registry to install the action's dependencies from, for runners that cannot reach `registry.npmjs.org` — see below | +| `npm-token` | ☐ | — | Bearer token for `npm-registry` | +| `node-mirror` | ☐ | — | Mirror of `https://nodejs.org` for `setup-node`, when the runner reaches neither nodejs.org nor github.com release assets | +| `node-mirror-token` | ☐ | — | `Authorization` header value for `node-mirror` | ### Each doc definition @@ -100,6 +104,34 @@ The action attaches the bundle to an existing release — it never creates one. the repo has no releases at all the run fails with a message saying so. Trigger on `release: published` (as above) and the release is guaranteed to be there. +### Runners in a private network + +On GitHub-hosted runners the workflow above is complete. If your job runs on +self-hosted runners that cannot reach `registry.npmjs.org` — every package has +to come from an internal Artifactory — add the registry to the same step: + +```yaml + - uses: AbsaOSS/knowledge-base/actions/publish-single-page-docs@v1 + with: + npm-registry: https://artifactory.example.com/artifactory/api/npm/npm-remote/ + npm-token: ${{ secrets.ARTIFACTORY_TOKEN }} # omit for anonymous reads + docs: | + - md: docs/overview.md + … +``` + +The action's own lockfile stays pinned to `registry.npmjs.org`; npm rewrites +that host to the registry you name when it fetches, so the same action ref +installs on both kinds of runner. The setting applies to this install only and +the token is never written to disk. A runner whose own `~/.npmrc` already names +the mirror needs neither input. + +Node itself comes from `actions/setup-node`, which tries github.com release +assets and then nodejs.org. If both are blocked and the runner image does not +carry Node 20 in its tool cache, set `node-mirror` to a mirror of +`https://nodejs.org` (an Artifactory generic remote) and `node-mirror-token` if +it needs one. + --- ## 2. Ask for the registry entry @@ -261,6 +293,8 @@ only navigation; there is no sidebar and no in-app chrome. | `slug "…" is invalid` | Slugs are lowercase kebab-case only — no underscores, capitals or spaces. | | `Duplicate app slug "…"` (knowledge-base build) | Another registered app already owns that URL prefix. Rename yours and republish. | | `the release artifact has no kb-docs.json at its root` | The release's `kb-docs.tar.gz` was not produced by this action. | +| `npm error … ENOTFOUND registry.npmjs.org` or a `403` from a proxy at `Install publisher dependencies` | The runner cannot reach the public registry. Set `npm-registry` (and `npm-token`) to your internal mirror. | +| `Unable to find Node version` / a download error at `Set up Node.js` | The runner reaches neither github.com release assets nor nodejs.org. Set `node-mirror`, or preinstall Node 20 in the runner's tool cache. | --- diff --git a/examples/deployment-repo/.github/workflows/build.yml b/examples/deployment-repo/.github/workflows/build.yml index 0705f45..b1674e0 100644 --- a/examples/deployment-repo/.github/workflows/build.yml +++ b/examples/deployment-repo/.github/workflows/build.yml @@ -57,5 +57,12 @@ jobs: image-tags: | ${{ github.sha }} latest + # Runners inside a private network, where every package comes from an + # internal mirror rather than registry.npmjs.org — see the "Private + # networks" section of contract/DEPLOYMENT.md. + # runs-on: '["self-hosted", "linux"]' + # npm-registry: https://artifactory.example.com/artifactory/api/npm/npm-remote/ + # node-mirror: https://artifactory.example.com/artifactory/nodejs-remote secrets: docs-token: ${{ needs.token.outputs.token }} + # npm-token: ${{ secrets.ARTIFACTORY_TOKEN }} diff --git a/examples/deployment-repo/README.md b/examples/deployment-repo/README.md index 4de9a5e..9a1a2be 100644 --- a/examples/deployment-repo/README.md +++ b/examples/deployment-repo/README.md @@ -25,6 +25,9 @@ The reasoning behind each piece is in 3. Replace `ghcr.io/absaoss/knowledge-base` in `build.yml` with your image, and pin `kb-ref` to a released tag of `AbsaOSS/knowledge-base`. 4. Put your real entries in `apps.json`. +5. If the build has to run on self-hosted runners inside a private network, + uncomment `runs-on`, `npm-registry` and `npm-token` in `build.yml` — the + "Private networks" section of `contract/DEPLOYMENT.md` explains each. ## What is deliberately not here diff --git a/tests/private-registry.spec.js b/tests/private-registry.spec.js new file mode 100644 index 0000000..c0632fb --- /dev/null +++ b/tests/private-registry.spec.js @@ -0,0 +1,182 @@ +/** + * tests/private-registry.spec.js + * + * Consuming repositories may run on runners inside a private network: no route + * to registry.npmjs.org, every package served by an internal mirror. The three + * shared CI pieces — both publishing actions and the reusable build workflow — + * cover that with one mechanism, and this file pins the parts of it nothing + * else exercises. Static assertions plus the shell script; no network. + * + * The lockfile rule matters most. npm rewrites a `resolved` host to the + * configured registry only when that host is the default one + * (`replace-registry-host`, default `npmjs`). A lockfile regenerated behind a + * corporate `~/.npmrc` carries that registry's URLs instead, npm does not + * rewrite those, and the lockfile then installs in exactly one network. It + * would still pass every other test here. + */ + +import { test, expect } from '@playwright/test'; +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const NPMJS = 'https://registry.npmjs.org/'; +const SCRIPT = join(ROOT, 'actions', 'lib', 'npm-registry.sh'); + +const LOCKFILES = ['package-lock.json', 'actions/package-lock.json']; + +const ACTIONS = ['actions/publish-docs/action.yml', 'actions/publish-single-page-docs/action.yml']; +const WORKFLOW = '.github/workflows/build-image.yml'; + +/** The inputs every consumer-facing manifest has to offer, by the same names. */ +const INPUTS = ['npm-registry', 'npm-token', 'node-mirror', 'node-mirror-token']; + +// A CRLF checkout (the Git for Windows default) must not break the `\n`-anchored +// assertions below. +const read = (rel) => readFileSync(join(ROOT, rel), 'utf8').replace(/\r\n/g, '\n'); + +test.describe('lockfiles resolve to the public registry', () => { + for (const rel of LOCKFILES) { + test(`${rel}: every package resolves to ${NPMJS} and carries an integrity hash`, () => { + const lock = JSON.parse(read(rel)); + expect(lock.lockfileVersion).toBeGreaterThanOrEqual(2); + + const offenders = []; + for (const [path, pkg] of Object.entries(lock.packages)) { + if (path === '' || pkg.link || pkg.inBundle) continue; + if (typeof pkg.resolved !== 'string' || !pkg.resolved.startsWith(NPMJS)) { + offenders.push(`${path}: resolved=${pkg.resolved}`); + } else if (typeof pkg.integrity !== 'string' || pkg.integrity === '') { + offenders.push(`${path}: no integrity`); + } + } + expect(offenders, 'regenerate with --registry=https://registry.npmjs.org/').toEqual([]); + }); + } +}); + +test.describe('the private-registry inputs exist on every shared CI piece', () => { + for (const rel of ACTIONS) { + test(`${rel} declares the inputs and installs through the shared script`, () => { + const text = read(rel); + for (const input of INPUTS) expect(text).toContain(`\n ${input}:\n`); + expect(text).toContain('lib/npm-registry.sh'); + expect(text).toContain('KB_NPM_REGISTRY: ${{ inputs.npm-registry }}'); + // Both the step that writes the .npmrc and the install that reads it. + expect(text.split('KB_NPM_TOKEN: ${{ inputs.npm-token }}').length - 1).toBe(2); + expect(text).toContain('mirror: ${{ inputs.node-mirror }}'); + expect(text).toContain('mirror-token: ${{ inputs.node-mirror-token }}'); + }); + } + + test(`${WORKFLOW} declares the inputs, the secrets and a configurable runner`, () => { + const text = read(WORKFLOW); + for (const input of ['runs-on', 'npm-registry', 'node-mirror']) { + expect(text).toContain(`\n ${input}:\n`); + } + for (const secret of ['npm-token', 'node-mirror-token']) { + expect(text).toContain(`\n ${secret}:\n`); + } + expect(text).toContain('KB_NPM_REGISTRY: ${{ inputs.npm-registry }}'); + expect(text.split('KB_NPM_TOKEN: ${{ secrets.npm-token }}').length - 1).toBe(2); + expect(text).toContain('mirror: ${{ inputs.node-mirror }}'); + expect(text).toContain('mirror-token: ${{ secrets.node-mirror-token }}'); + expect(text).toMatch(/runs-on: \$\{\{ .*fromJSON\(inputs\.runs-on\).*inputs\.runs-on \}\}/); + // The workflow inlines the script's logic; keep the two token lines identical + // so a fix to one cannot silently miss the other. + expect(text).toContain('echo "${registry#*:}:_authToken=\\${KB_NPM_TOKEN}"'); + expect(read('actions/lib/npm-registry.sh')).toContain( + 'echo "${auth_key}:_authToken=\\${KB_NPM_TOKEN}"', + ); + }); +}); + +/** + * Git for Windows ships the bash the script is written for; the WSL launcher in + * System32 is also called bash and must not be picked up by accident. + */ +function findBash() { + if (process.platform === 'win32') { + const gitBash = join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Git', 'bin', 'bash.exe'); + if (existsSync(gitBash)) return gitBash; + } + return 'bash'; +} + +test.describe('actions/lib/npm-registry.sh', () => { + const BASH = findBash(); + const hasBash = spawnSync(BASH, ['-c', 'exit 0']).status === 0; + + /** Runs the script against a throwaway directory and returns what it did. */ + function run(env) { + const dir = mkdtempSync(join(tmpdir(), 'kb-npmrc-')); + try { + // Git bash accepts a drive-letter path as long as the separators are its own. + const result = spawnSync(BASH, [SCRIPT, dir.replace(/\\/g, '/')], { + env: { PATH: process.env.PATH, ...env }, + encoding: 'utf8', + }); + const file = join(dir, '.npmrc'); + return { + status: result.status, + stdout: result.stdout, + stderr: result.stderr, + npmrc: existsSync(file) ? readFileSync(file, 'utf8') : null, + }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + + test.skip(!hasBash, 'needs bash'); + + test('writes nothing when no registry is given', () => { + const { status, npmrc } = run({}); + expect(status).toBe(0); + expect(npmrc).toBeNull(); + }); + + test('normalises the URL to one trailing slash and writes only the registry', () => { + const { status, stdout, npmrc } = run({ + KB_NPM_REGISTRY: 'https://artifactory.example.com/artifactory/api/npm/npm-remote', + }); + expect(status).toBe(0); + expect(npmrc).toBe('registry=https://artifactory.example.com/artifactory/api/npm/npm-remote/\n'); + expect(stdout).toContain('npm installs from https://artifactory.example.com/artifactory/api/npm/npm-remote/'); + expect(stdout).not.toContain('authenticated'); + }); + + test('scopes the token to the registry and never writes it to disk', () => { + const { status, stdout, npmrc } = run({ + KB_NPM_REGISTRY: 'https://artifactory.example.com/artifactory/api/npm/npm-remote/', + KB_NPM_TOKEN: 's3cr3t-token-value', + }); + expect(status).toBe(0); + expect(npmrc).toBe( + 'registry=https://artifactory.example.com/artifactory/api/npm/npm-remote/\n' + + '//artifactory.example.com/artifactory/api/npm/npm-remote/:_authToken=${KB_NPM_TOKEN}\n', + ); + expect(npmrc).not.toContain('s3cr3t'); + expect(stdout).not.toContain('s3cr3t'); + expect(stdout).toContain('(authenticated)'); + }); + + test('rejects a registry that is not an http(s) URL', () => { + const { status, stdout, npmrc } = run({ KB_NPM_REGISTRY: 'artifactory.example.com/npm' }); + expect(status).toBe(1); + expect(stdout).toContain('::error::npm-registry must be an http(s) URL'); + expect(npmrc).toBeNull(); + }); + + test('rejects a registry that would smuggle a second line into the .npmrc', () => { + const { status, stdout, npmrc } = run({ + KB_NPM_REGISTRY: 'https://artifactory.example.com/npm/\nalways-auth=true', + }); + expect(status).toBe(1); + expect(stdout).toContain('::error::npm-registry must be a single URL with no whitespace'); + expect(npmrc).toBeNull(); + }); +});