diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml index 706e027..9972a1e 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build-image.yml @@ -137,7 +137,10 @@ jobs: KB_REGISTRY: ${{ steps.registry.outputs.path }} KB_HEADLESS: ${{ inputs.headless }} KB_STRICT: ${{ inputs.strict }} - GITHUB_TOKEN: ${{ secrets.docs-token }} + # Always a token, so the fetch never falls back to the gh CLI, which a + # self-hosted runner need not have. github.token covers public + # docs repos; private ones need docs-token. + GITHUB_TOKEN: ${{ secrets.docs-token || github.token }} 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/actions/lib/release.cjs b/actions/lib/release.cjs new file mode 100644 index 0000000..a0a0037 --- /dev/null +++ b/actions/lib/release.cjs @@ -0,0 +1,155 @@ +/** + * release.cjs — attaches kb-docs.tar.gz to a GitHub Release and notifies a + * deployment, through the REST API only. + * + * Both action.yml files run this from an `actions/github-script` step, which + * brings its own Node runtime and an authenticated Octokit client. That is the + * whole point: the runner needs no `gh` CLI, no preinstalled toolchain and no + * network access beyond the GitHub API, so the actions run on self-hosted + * runners exactly as they do on GitHub-hosted ones. + * + * CommonJS in an ESM package on purpose. github-script hands the script a + * `require`, and a `.cjs` file is what that loads without leaning on Node's + * require(esm) support. The module has no dependencies for the same reason: it + * runs under github-script's Node, not under the tree `npm ci` installed. + * + * Everything consumer-controlled arrives through `env`, never interpolated into + * the script body — the same rule the shell steps follow. + */ +'use strict'; + +const { readFileSync } = require('node:fs'); +const { basename } = require('node:path'); + +const ASSET_NAME = 'kb-docs.tar.gz'; +const DISPATCH_EVENT = 'kb-docs-published'; + +/** The HTTP status of a failed Octokit request, or undefined for anything else. */ +const statusOf = (err) => (err && typeof err.status === 'number' ? err.status : undefined); + +/** + * Resolves the release the artifact goes on. + * + * With `tag`: that release. `getReleaseByTag` only sees published releases, so + * a draft — a release workflow that has not flipped the switch yet — is found + * by scanning the list instead, the way `gh release upload` would. + * + * Without: the latest published release, then the most recently created + * release of any kind (`/releases/latest` excludes drafts and pre-releases), + * then null. + */ +async function resolveRelease({ github, owner, repo, tag }) { + if (tag) { + try { + const { data } = await github.rest.repos.getReleaseByTag({ owner, repo, tag }); + return data; + } catch (err) { + if (statusOf(err) !== 404) throw err; + } + const { data: releases } = await github.rest.repos.listReleases({ owner, repo, per_page: 100 }); + return releases.find((release) => release.tag_name === tag) || null; + } + + try { + const { data } = await github.rest.repos.getLatestRelease({ owner, repo }); + return data; + } catch (err) { + if (statusOf(err) !== 404) throw err; + } + const { data: releases } = await github.rest.repos.listReleases({ owner, repo, per_page: 1 }); + return releases[0] || null; +} + +/** + * Uploads `file` to `release`, replacing an asset of the same name — the + * `--clobber` an idempotent re-run needs. + * + * The upload goes to the release's own `upload_url` rather than to the API + * host: on GitHub Enterprise the two are different origins, and a hard-coded + * uploads.github.com would be one more thing the runner's environment has to + * look like. + */ +async function uploadAsset({ github, core, owner, repo, release, file }) { + const name = basename(file); + const existing = (release.assets || []).find((asset) => asset.name === name); + if (existing) { + core.info(`Replacing the existing ${name} on ${release.tag_name}.`); + await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: existing.id }); + } + + const data = readFileSync(file); + // upload_url is a URI template: …/releases/1/assets{?name,label} + const url = `${release.upload_url.replace(/\{[^}]*\}$/, '')}?name=${encodeURIComponent(name)}`; + const { data: asset } = await github.request({ + method: 'POST', + url, + headers: { 'content-type': 'application/gzip', 'content-length': data.length }, + data, + }); + return asset; +} + +/** + * The upload step. Reads KB_RELEASE_TAG, KB_ARTIFACT, KB_COUNT and KB_SLUGS + * from `env`; sets the `release-tag` output and writes the step summary. + * `noun` is what the summary counts: "app" for publish-docs, "doc" for + * publish-single-page-docs. + */ +async function publish({ github, core, context, env, noun = 'app' }) { + const { owner, repo } = context.repo; + const full = `${owner}/${repo}`; + const file = env.KB_ARTIFACT; + if (!file) throw new Error('KB_ARTIFACT is not set: the build step produced no artifact path.'); + + const tag = (env.KB_RELEASE_TAG || '').trim(); + const release = await resolveRelease({ github, owner, repo, tag }); + if (!release) { + throw new Error( + tag + ? `${full} has no release tagged '${tag}'. Create it and re-run, or pass a different release-tag.` + : `${full} has no GitHub Release to attach the docs artifact to. Create a release (any tag) and re-run, or pass release-tag explicitly.` + ); + } + + core.info(`Uploading ${basename(file)} to release ${release.tag_name} (replacing any existing asset)…`); + const asset = await uploadAsset({ github, core, owner, repo, release, file }); + core.info(`Uploaded ${asset.name} (${asset.size} bytes).`); + + core.setOutput('release-tag', release.tag_name); + await core.summary.addRaw(`Published ${env.KB_COUNT} ${noun}(s): ${env.KB_SLUGS}`, true).write(); + return release.tag_name; +} + +/** + * The notify step. Fires `kb-docs-published` at KB_NOTIFY_REPO with the + * publishing repo, tag and slugs as the payload. Never throws: the artifact is + * already on the release, and the deployment's schedule will pick it up, so a + * failed notify is a warning on a successful publish. + */ +async function notify({ github, core, context, env }) { + const target = (env.KB_NOTIFY_REPO || '').trim(); + const [owner, repo, ...rest] = target.split('/'); + if (!owner || !repo || rest.length) { + core.warning(`notify-repo must be 'owner/name', got '${target}'. Nothing was notified.`); + return false; + } + + const source = `${context.repo.owner}/${context.repo.repo}`; + try { + await github.rest.repos.createDispatchEvent({ + owner, + repo, + event_type: DISPATCH_EVENT, + client_payload: { repo: source, tag: env.KB_RELEASE_TAG, slugs: env.KB_SLUGS }, + }); + core.info(`Notified ${target}.`); + return true; + } catch (err) { + core.warning( + `Could not notify ${target} (${err.message}). The artifact was published; the deployment will pick it up on its next scheduled build.` + ); + return false; + } +} + +module.exports = { ASSET_NAME, DISPATCH_EVENT, resolveRelease, uploadAsset, publish, notify }; diff --git a/actions/lib/release.selftest.js b/actions/lib/release.selftest.js new file mode 100644 index 0000000..7fb6911 --- /dev/null +++ b/actions/lib/release.selftest.js @@ -0,0 +1,242 @@ +/** + * release.selftest.js — `npm run selftest:release` inside actions/. + * + * Exercises lib/release.cjs — the release upload and the notify both actions + * run through actions/github-script — against a recording fake of the Octokit + * client and of `core`. No network, no runner: the point is to pin which API + * calls are made, in which order, and what a consumer sees when a release is + * missing, so that dropping the gh CLI did not change the behaviour a + * docs repo already relies on. + */ + +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const require = createRequire(import.meta.url); +const { ASSET_NAME, DISPATCH_EVENT, publish, notify } = require('./release.cjs'); + +const root = mkdtempSync(join(tmpdir(), 'kb-release-')); +const artifact = join(root, ASSET_NAME); +writeFileSync(artifact, 'not really gzip, but bytes'); + +let failures = 0; +async function check(name, fn) { + try { + await fn(); + console.log(` \x1b[32m✓\x1b[0m ${name}`); + } catch (err) { + failures++; + console.log(` \x1b[31m✗\x1b[0m ${name}\n ${err.message}`); + } +} + +/** An Octokit error the way @octokit/request throws it. */ +function httpError(status) { + const err = new Error(`HTTP ${status}`); + err.status = status; + return err; +} + +function release(tag, { id = 1, assets = [], draft = false } = {}) { + return { + id, + tag_name: tag, + draft, + assets, + upload_url: `https://uploads.example.test/repos/acme/docs/releases/${id}/assets{?name,label}`, + }; +} + +/** + * A recording fake of the github-script client. `responses` maps a method name + * to a value to resolve with or an Error to reject with; every call is logged. + */ +function fakeGithub(responses) { + const calls = []; + const method = (name) => async (params) => { + calls.push({ name, params }); + const r = responses[name]; + if (r instanceof Error) throw r; + if (typeof r === 'function') return r(params); + if (r === undefined) throw new Error(`unexpected call to ${name}`); + return { data: r }; + }; + return { + calls, + request: method('request'), + rest: { + repos: { + getReleaseByTag: method('getReleaseByTag'), + getLatestRelease: method('getLatestRelease'), + listReleases: method('listReleases'), + deleteReleaseAsset: method('deleteReleaseAsset'), + createDispatchEvent: method('createDispatchEvent'), + }, + }, + }; +} + +function fakeCore() { + const core = { infos: [], warnings: [], outputs: {}, summaryText: '' }; + core.info = (m) => core.infos.push(m); + core.warning = (m) => core.warnings.push(m); + core.setOutput = (k, v) => { core.outputs[k] = v; }; + core.summary = { + addRaw(text, eol) { core.summaryText += text + (eol ? '\n' : ''); return this; }, + async write() { core.summaryWritten = true; }, + }; + return core; +} + +const context = { repo: { owner: 'acme', repo: 'docs' } }; +const env = { KB_ARTIFACT: artifact, KB_COUNT: '2', KB_SLUGS: 'a,b' }; + +console.log('\nrelease.cjs — publish'); + +await check('an explicit tag resolves through getReleaseByTag and uploads with the asset name', async () => { + const github = fakeGithub({ getReleaseByTag: release('v1.2.0'), request: { name: ASSET_NAME, size: 26 } }); + const core = fakeCore(); + const tag = await publish({ github, core, context, env: { ...env, KB_RELEASE_TAG: 'v1.2.0' } }); + + assert.equal(tag, 'v1.2.0'); + assert.deepEqual(github.calls.map((c) => c.name), ['getReleaseByTag', 'request']); + assert.deepEqual(github.calls[0].params, { owner: 'acme', repo: 'docs', tag: 'v1.2.0' }); + + const upload = github.calls[1].params; + assert.equal(upload.method, 'POST'); + assert.equal(upload.url, `https://uploads.example.test/repos/acme/docs/releases/1/assets?name=${ASSET_NAME}`); + assert.equal(upload.headers['content-type'], 'application/gzip'); + assert.equal(upload.headers['content-length'], 26); + assert.ok(Buffer.isBuffer(upload.data)); + + assert.equal(core.outputs['release-tag'], 'v1.2.0'); + assert.equal(core.summaryText, 'Published 2 app(s): a,b\n'); + assert.ok(core.summaryWritten); +}); + +await check('an existing asset of the same name is deleted first (the --clobber)', async () => { + const assets = [{ id: 77, name: ASSET_NAME }, { id: 78, name: 'other.zip' }]; + const github = fakeGithub({ getReleaseByTag: release('v1', { assets }), deleteReleaseAsset: {}, request: { name: ASSET_NAME, size: 1 } }); + await publish({ github, core: fakeCore(), context, env: { ...env, KB_RELEASE_TAG: 'v1' } }); + + assert.deepEqual(github.calls.map((c) => c.name), ['getReleaseByTag', 'deleteReleaseAsset', 'request']); + assert.deepEqual(github.calls[1].params, { owner: 'acme', repo: 'docs', asset_id: 77 }); +}); + +await check('a draft release with the given tag is found by scanning the list', async () => { + const github = fakeGithub({ + getReleaseByTag: httpError(404), + listReleases: [release('v2.0.0', { id: 5, draft: true }), release('v1.9.0', { id: 4 })], + request: { name: ASSET_NAME, size: 1 }, + }); + const core = fakeCore(); + await publish({ github, core, context, env: { ...env, KB_RELEASE_TAG: 'v2.0.0' } }); + + assert.deepEqual(github.calls.map((c) => c.name), ['getReleaseByTag', 'listReleases', 'request']); + assert.equal(github.calls[1].params.per_page, 100); + assert.match(github.calls[2].params.url, /releases\/5\/assets/); + assert.equal(core.outputs['release-tag'], 'v2.0.0'); +}); + +await check('a tag that matches no release fails naming the tag', async () => { + const github = fakeGithub({ getReleaseByTag: httpError(404), listReleases: [release('v1')] }); + await assert.rejects( + publish({ github, core: fakeCore(), context, env: { ...env, KB_RELEASE_TAG: 'v9' } }), + /acme\/docs has no release tagged 'v9'.*pass a different release-tag/ + ); +}); + +await check('without a tag, the latest published release wins', async () => { + const github = fakeGithub({ getLatestRelease: release('v3.1.0', { id: 9 }), request: { name: ASSET_NAME, size: 1 } }); + const core = fakeCore(); + await publish({ github, core, context, env: { ...env, KB_RELEASE_TAG: '' }, noun: 'doc' }); + + assert.deepEqual(github.calls.map((c) => c.name), ['getLatestRelease', 'request']); + assert.equal(core.outputs['release-tag'], 'v3.1.0'); + assert.equal(core.summaryText, 'Published 2 doc(s): a,b\n'); +}); + +await check('without a published release, the most recently created one of any kind is used', async () => { + const github = fakeGithub({ + getLatestRelease: httpError(404), + listReleases: [release('v0.1.0-rc.1', { id: 2 })], + request: { name: ASSET_NAME, size: 1 }, + }); + const core = fakeCore(); + await publish({ github, core, context, env }); + + assert.deepEqual(github.calls.map((c) => c.name), ['getLatestRelease', 'listReleases', 'request']); + assert.equal(github.calls[1].params.per_page, 1); + assert.equal(core.outputs['release-tag'], 'v0.1.0-rc.1'); +}); + +await check('with no release at all, the message says to create one', async () => { + const github = fakeGithub({ getLatestRelease: httpError(404), listReleases: [] }); + await assert.rejects( + publish({ github, core: fakeCore(), context, env }), + /acme\/docs has no GitHub Release.*Create a release \(any tag\) and re-run, or pass release-tag explicitly/ + ); +}); + +await check('an API failure other than 404 is not swallowed', async () => { + const github = fakeGithub({ getLatestRelease: httpError(403) }); + await assert.rejects(publish({ github, core: fakeCore(), context, env }), /HTTP 403/); + assert.deepEqual(github.calls.map((c) => c.name), ['getLatestRelease']); +}); + +await check('a missing artifact path fails before touching the API', async () => { + const github = fakeGithub({}); + await assert.rejects( + publish({ github, core: fakeCore(), context, env: { ...env, KB_ARTIFACT: '' } }), + /KB_ARTIFACT is not set/ + ); + assert.equal(github.calls.length, 0); +}); + +console.log('\nrelease.cjs — notify'); + +await check('fires kb-docs-published at the target with repo, tag and slugs', async () => { + const github = fakeGithub({ createDispatchEvent: {} }); + const core = fakeCore(); + const ok = await notify({ github, core, context, env: { KB_NOTIFY_REPO: 'acme/deployment', KB_RELEASE_TAG: 'v1', KB_SLUGS: 'a,b' } }); + + assert.equal(ok, true); + assert.deepEqual(github.calls[0].params, { + owner: 'acme', + repo: 'deployment', + event_type: DISPATCH_EVENT, + client_payload: { repo: 'acme/docs', tag: 'v1', slugs: 'a,b' }, + }); + assert.deepEqual(core.warnings, []); +}); + +await check('a failed dispatch is a warning, never an error', async () => { + const github = fakeGithub({ createDispatchEvent: httpError(404) }); + const core = fakeCore(); + const ok = await notify({ github, core, context, env: { KB_NOTIFY_REPO: 'acme/deployment', KB_RELEASE_TAG: 'v1', KB_SLUGS: 'a' } }); + + assert.equal(ok, false); + assert.equal(core.warnings.length, 1); + assert.match(core.warnings[0], /Could not notify acme\/deployment.*next scheduled build/); +}); + +await check('a malformed notify-repo warns and makes no call', async () => { + const github = fakeGithub({}); + const core = fakeCore(); + const ok = await notify({ github, core, context, env: { KB_NOTIFY_REPO: 'just-a-name' } }); + + assert.equal(ok, false); + assert.equal(github.calls.length, 0); + assert.match(core.warnings[0], /notify-repo must be 'owner\/name'/); +}); + +rmSync(root, { recursive: true, force: true }); + +if (failures) { + console.log(`\n${failures} check(s) failed.`); + process.exit(1); +} +console.log('\nAll checks passed.'); diff --git a/actions/package.json b/actions/package.json index b58b00d..e935d12 100644 --- a/actions/package.json +++ b/actions/package.json @@ -6,9 +6,10 @@ "type": "module", "license": "Apache-2.0", "scripts": { - "selftest": "node publish-single-page-docs/src/selftest.js && node publish-docs/src/selftest.js", + "selftest": "node publish-single-page-docs/src/selftest.js && node publish-docs/src/selftest.js && node lib/release.selftest.js", "selftest:single-page": "node publish-single-page-docs/src/selftest.js", - "selftest:docs": "node publish-docs/src/selftest.js" + "selftest:docs": "node publish-docs/src/selftest.js", + "selftest:release": "node lib/release.selftest.js" }, "dependencies": { "ajv": "8.17.1", diff --git a/actions/publish-docs/README.md b/actions/publish-docs/README.md index d6a349c..2a166f7 100644 --- a/actions/publish-docs/README.md +++ b/actions/publish-docs/README.md @@ -74,6 +74,24 @@ The action attaches to an existing release — it never creates one. Trigger on `release: published` and it is guaranteed to be there, or pass `release-tag` explicitly. +## Runner requirements + +The action assumes nothing about the runner image, so it runs the same on +GitHub-hosted and self-hosted runners: + +- **Node** comes from `actions/setup-node`; nothing needs to be preinstalled. +- **The release upload and the notify** go through `actions/github-script`, + which ships its own runtime and an authenticated Octokit client. The `gh` CLI + is **not** required. Uploads go to the release's own `upload_url`, so + GitHub Enterprise hosts work without configuration. +- **`bash`** is needed for the two one-line `run:` steps (`npm ci` and the + entry point). Every GitHub-hosted image and Git for Windows provide it. +- **Network**: the GitHub API of the instance the workflow runs on, and the npm + registry for `npm ci`. Nothing else is fetched. + +The runner must be new enough for Node 24 actions (`actions/runner` ≥ 2.327.1), +which `actions/setup-node@v7` already requires. + ## Notifying a deployment Set `notify-repo` and `notify-token` to fire a `kb-docs-published` @@ -108,10 +126,12 @@ that does not say which file is wrong costs somebody a CI round trip. | Path | Role | |---|---| -| `action.yml` | Composite action: setup-node → `npm ci` → verify + pack → `gh release upload` → notify | +| `action.yml` | Composite action: setup-node → `npm ci` → verify + pack → upload (`github-script`) → notify (`github-script`) | | `src/index.js` | Entry point. Reads inputs from env, writes step outputs. | | `src/selftest.js` | The self-test described above. | Shared with the other action in [`actions/lib/`](../lib): manifest validation, -HTML verification, deterministic packing and the runner plumbing. Dependencies -are pinned once in [`actions/package.json`](../package.json). +HTML verification, deterministic packing, the runner plumbing, and +`release.cjs` — the release upload and the notify, run by `github-script` +against its Octokit client. Dependencies are pinned once in +[`actions/package.json`](../package.json). diff --git a/actions/publish-docs/action.yml b/actions/publish-docs/action.yml index 0297905..5bb9217 100644 --- a/actions/publish-docs/action.yml +++ b/actions/publish-docs/action.yml @@ -89,62 +89,42 @@ runs: KB_STAGE: ${{ runner.temp }}/kb-publish/stage KB_ARTIFACT: ${{ runner.temp }}/kb-publish/kb-docs.tar.gz + # Through the REST API, not the gh CLI: gh is preinstalled on GitHub-hosted + # runners but is not part of the runner, and a self-hosted runner need not + # have it. github-script brings its own Node and an authenticated + # Octokit client, so this step assumes nothing about the machine it runs on. - name: Upload kb-docs.tar.gz to the release id: upload - shell: bash + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_TOKEN: ${{ inputs.github-token }} KB_RELEASE_TAG: ${{ inputs.release-tag }} KB_ARTIFACT: ${{ steps.build.outputs.artifact }} # Read through the environment, never interpolated into the script body: - # a ${{ }} expression is substituted into the shell text before bash sees - # it, so any metacharacter in the value would execute rather than quote - # (#55). These come from the consuming repo's inputs. + # a ${{ }} expression is substituted into the script text before it is + # parsed, so any metacharacter in the value would execute rather than + # quote. These come from the consuming repo's inputs. KB_COUNT: ${{ steps.build.outputs.count }} KB_SLUGS: ${{ steps.build.outputs.slugs }} - run: | - set -euo pipefail - - tag="${KB_RELEASE_TAG:-}" - - # /releases/latest excludes drafts and pre-releases, so fall back to the - # most recently created release of any kind before giving up. - if [ -z "$tag" ]; then - tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq '.tag_name' 2>/dev/null || true)" - fi - if [ -z "$tag" ] || [ "$tag" = "null" ]; then - tag="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=1" --jq '.[0].tag_name // empty' 2>/dev/null || true)" - fi - if [ -z "$tag" ] || [ "$tag" = "null" ]; then - echo "::error::$GITHUB_REPOSITORY has no GitHub Release to attach the docs artifact to. Create a release (any tag) and re-run, or pass release-tag explicitly." - exit 1 - fi - - echo "Uploading $(basename "$KB_ARTIFACT") to release $tag (replacing any existing asset)…" - gh release upload "$tag" "$KB_ARTIFACT" --clobber --repo "$GITHUB_REPOSITORY" - - echo "release-tag=$tag" >> "$GITHUB_OUTPUT" - echo "Published $KB_COUNT app(s): $KB_SLUGS" >> "$GITHUB_STEP_SUMMARY" + KB_RELEASE_LIB: ${{ github.action_path }}/../lib/release.cjs + with: + github-token: ${{ inputs.github-token }} + script: | + const { publish } = require(process.env.KB_RELEASE_LIB); + await publish({ github, core, context, env: process.env, noun: 'app' }); + # A failed notify must not fail a successful publish: the artifact is + # already on the release, and the deployment's schedule will pick it up. + # `notify()` warns instead of throwing. - name: Notify the deployment repository if: ${{ inputs.notify-repo != '' && inputs.notify-token != '' }} - shell: bash + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_TOKEN: ${{ inputs.notify-token }} KB_NOTIFY_REPO: ${{ inputs.notify-repo }} KB_RELEASE_TAG: ${{ steps.upload.outputs.release-tag }} KB_SLUGS: ${{ steps.build.outputs.slugs }} - run: | - set -euo pipefail - # A failed notify must not fail a successful publish: the artifact is - # already on the release, and the deployment's schedule will pick it up. - if gh api "repos/$KB_NOTIFY_REPO/dispatches" \ - --method POST \ - --field event_type=kb-docs-published \ - --field "client_payload[repo]=$GITHUB_REPOSITORY" \ - --field "client_payload[tag]=$KB_RELEASE_TAG" \ - --field "client_payload[slugs]=$KB_SLUGS"; then - echo "Notified $KB_NOTIFY_REPO." - else - echo "::warning::Could not notify $KB_NOTIFY_REPO. The artifact was published; the deployment will pick it up on its next scheduled build." - fi + KB_RELEASE_LIB: ${{ github.action_path }}/../lib/release.cjs + with: + github-token: ${{ inputs.notify-token }} + script: | + const { notify } = require(process.env.KB_RELEASE_LIB); + await notify({ github, core, context, env: process.env }); diff --git a/actions/publish-single-page-docs/README.md b/actions/publish-single-page-docs/README.md index 9786513..22d842c 100644 --- a/actions/publish-single-page-docs/README.md +++ b/actions/publish-single-page-docs/README.md @@ -28,7 +28,7 @@ If your docs are a real site rather than a markdown file or two, you want | Path | Role | |---|---| -| `action.yml` | Composite action: setup-node → `npm ci` → render → `gh release upload --clobber` | +| `action.yml` | Composite action: setup-node → `npm ci` → render → upload (`github-script`, replacing any existing asset) | | `src/index.js` | Entry point. Reads inputs from env, writes step outputs. | | `src/inputs.js` | Parses and validates the `docs` list. Every message names the entry and the fix. | | `src/markdown.js` | markdown-it pipeline: GFM, highlight.js, mermaid passthrough. | @@ -38,8 +38,19 @@ If your docs are a real site rather than a markdown file or two, you want Dependencies are pinned once in [`actions/package.json`](../package.json), shared with the packaged-site action, so an onboarding repo needs no toolchain of its -own. Manifest validation, deterministic packing and the runner plumbing live in -[`actions/lib/`](../lib) and are the same code both actions run. +own. Manifest validation, deterministic packing, the runner plumbing and the +release upload (`release.cjs`) live in [`actions/lib/`](../lib) and are the same +code both actions run. + +## Runner requirements + +Nothing beyond what the runner itself provides, so self-hosted runners work +unchanged: Node comes from `actions/setup-node`, the release upload goes through +`actions/github-script` and its Octokit client rather than the `gh` CLI, +and `bash` is needed only for the two one-line `run:` steps. The runner must +support Node 24 actions (`actions/runner` ≥ 2.327.1), as `actions/setup-node@v7` +already requires. See the [`publish-docs` README](../publish-docs/README.md#runner-requirements) +for the full list. ## Working on it diff --git a/actions/publish-single-page-docs/action.yml b/actions/publish-single-page-docs/action.yml index c836e91..090218e 100644 --- a/actions/publish-single-page-docs/action.yml +++ b/actions/publish-single-page-docs/action.yml @@ -67,39 +67,25 @@ runs: KB_STAGE: ${{ runner.temp }}/kb-single-page/stage KB_ARTIFACT: ${{ runner.temp }}/kb-single-page/kb-docs.tar.gz + # Through the REST API, not the gh CLI: gh is preinstalled on GitHub-hosted + # runners but is not part of the runner, and a self-hosted runner need not + # have it. github-script brings its own Node and an authenticated + # Octokit client, so this step assumes nothing about the machine it runs on. - name: Upload kb-docs.tar.gz to the release id: upload - shell: bash + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_TOKEN: ${{ inputs.github-token }} KB_RELEASE_TAG: ${{ inputs.release-tag }} KB_ARTIFACT: ${{ steps.build.outputs.artifact }} # Read through the environment, never interpolated into the script body: - # a ${{ }} expression is substituted into the shell text before bash sees - # it, so any metacharacter in the value would execute rather than quote. - # These come from the consuming repo's `docs` input. + # a ${{ }} expression is substituted into the script text before it is + # parsed, so any metacharacter in the value would execute rather than + # quote. These come from the consuming repo's `docs` input. KB_COUNT: ${{ steps.build.outputs.count }} KB_SLUGS: ${{ steps.build.outputs.slugs }} - run: | - set -euo pipefail - - tag="${KB_RELEASE_TAG:-}" - - # /releases/latest excludes drafts and pre-releases, so fall back to the - # most recently created release of any kind before giving up. - if [ -z "$tag" ]; then - tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq '.tag_name' 2>/dev/null || true)" - fi - if [ -z "$tag" ] || [ "$tag" = "null" ]; then - tag="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=1" --jq '.[0].tag_name // empty' 2>/dev/null || true)" - fi - if [ -z "$tag" ] || [ "$tag" = "null" ]; then - echo "::error::$GITHUB_REPOSITORY has no GitHub Release to attach the docs bundle to. Create a release (any tag) and re-run, or pass release-tag explicitly." - exit 1 - fi - - echo "Uploading $(basename "$KB_ARTIFACT") to release $tag (replacing any existing asset)…" - gh release upload "$tag" "$KB_ARTIFACT" --clobber --repo "$GITHUB_REPOSITORY" - - echo "release-tag=$tag" >> "$GITHUB_OUTPUT" - echo "Published $KB_COUNT doc(s): $KB_SLUGS" >> "$GITHUB_STEP_SUMMARY" + KB_RELEASE_LIB: ${{ github.action_path }}/../lib/release.cjs + with: + github-token: ${{ inputs.github-token }} + script: | + const { publish } = require(process.env.KB_RELEASE_LIB); + await publish({ github, core, context, env: process.env, noun: 'doc' }); diff --git a/contract/DEPLOYMENT.md b/contract/DEPLOYMENT.md index ecae652..b3dbe6b 100644 --- a/contract/DEPLOYMENT.md +++ b/contract/DEPLOYMENT.md @@ -140,8 +140,13 @@ private repositories work, and it passes the token as a request header rather than on a command line (#43). An installation token is a `Bearer` token like any other and needs no special handling. -Public docs repos need no token at all, but an unauthenticated build shares the -anonymous API rate limit. Pass the token anyway. +Public docs repos need no App: when `docs-token` is omitted the workflow falls +back to `github.token`, which reads any public release. Pass the App token +anyway once a private repo is registered. + +Neither the reusable workflow nor the publishing actions need the `gh` CLI on +the runner — every GitHub call goes through the REST API with a token from the +environment — so both run unchanged on self-hosted runners. --- diff --git a/scripts/fetch-apps.js b/scripts/fetch-apps.js index 4b85e20..acf8d3c 100644 --- a/scripts/fetch-apps.js +++ b/scripts/fetch-apps.js @@ -77,7 +77,15 @@ async function ghApi(path) { } // Fallback: gh CLI. spawnSync takes an argv array, so nothing is shell-parsed. + // This is a local-development convenience; CI should always set GITHUB_TOKEN + // (the reusable workflow does), since a runner need not have gh at all. const result = spawnSync('gh', ['api', path], { encoding: 'utf8' }); + if (result.error) { + fail( + `GITHUB_TOKEN is not set and the gh CLI could not be run (${result.error.message}).\n` + + ` Set GITHUB_TOKEN, or install and authenticate gh for local builds.` + ); + } if (result.status !== 0) fail(`gh api ${path} failed:\n${result.stderr}`); return JSON.parse(result.stdout); }