diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..93a5a36 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,26 @@ +{ + "name": "docusaurus-plugin-gitlab", + + // Image ships with nvm preinstalled (default Node 20). We additionally install + // the other CI matrix versions so you can switch instantly, e.g. `nvm use 18`. + "image": "mcr.microsoft.com/devcontainers/javascript-node:20-bookworm", + + // Install the full CI matrix (20 / 22 / 24) and keep 20 (the minimum) as default. + "onCreateCommand": "bash -c 'export NVM_DIR=/usr/local/share/nvm && . \"$NVM_DIR/nvm.sh\" && nvm install 20 && nvm install 22 && nvm install 24 && nvm alias default 20'", + + // Install dependencies once the container is up. + "postCreateCommand": "npm ci", + + // Docusaurus dev server (used by the example sites) listens here. + "forwardPorts": [3000], + + "customizations": { + "vscode": { + "extensions": [ + "dbaeumer.vscode-eslint", + "DavidAnson.vscode-markdownlint", + "vitest.explorer" + ] + } + } +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..0e0cacc --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ef81639 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,64 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: {} + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run lint + - run: npm run typecheck + + test: + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + node: [20, 22, 24] + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: ${{ matrix.node }} + cache: npm + cache-dependency-path: | + package-lock.json + examples/site/package-lock.json + - run: npm ci + - name: "Build package (needed by the example site file: link)" + run: npm run build + - name: Install example site deps + working-directory: examples/site + run: npm ci + - name: Run tests (unit + e2e docusaurus build) + run: npm test + + dependency-review: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 + with: + fail-on-severity: high diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..6377007 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,27 @@ +name: CodeQL + +on: + pull_request: + branches: [main] + push: + branches: [main] + schedule: + - cron: "27 3 * * 1" + +permissions: {} + +jobs: + analyze: + runs-on: ubuntu-latest + permissions: + security-events: write + contents: read + actions: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: github/codeql-action/init@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3.36.2 + with: + languages: javascript-typescript + - uses: github/codeql-action/analyze@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3.36.2 + with: + category: "/language:javascript-typescript" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0c43da0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,47 @@ +name: Release + +on: + push: + branches: [main] + +permissions: {} + +concurrency: + group: release + cancel-in-progress: false + +jobs: + release-please: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + steps: + - id: release + uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1 + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + + publish: + needs: release-please + if: needs.release-please.outputs.release_created == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 20 + registry-url: https://registry.npmjs.org + cache: npm + - name: Use OIDC-capable npm + run: npm install -g npm@latest + - run: npm ci + - run: npm run build + - run: npm publish --provenance --access public diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..5b54f7f --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,29 @@ +name: Scorecard + +on: + push: + branches: [main] + schedule: + - cron: "18 4 * * 1" + +permissions: {} + +jobs: + analysis: + runs-on: ubuntu-latest + permissions: + security-events: write + id-token: write + contents: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + - uses: github/codeql-action/upload-sarif@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3.36.2 + with: + sarif_file: results.sarif diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..466df71 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.1.0" +} diff --git a/CLAUDE.md b/CLAUDE.md index bcc4048..5ac9667 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,9 +13,12 @@ static HTML. The browser never holds a token or calls the GitLab API. ## Hard rules -- **DO NOT use git.** Never run any git command (no `init`, `status`, `add`, - `commit`, anything). This repo is intentionally not a git repository. -- **Docusaurus 3 only** (MDX v3, unified ESM, Node 18+ `fetch`). +- **Git/GitHub:** This is a live GitHub repo (`ebuildy/docusaurus-plugin-gitlab`). + Normal git usage is fine. CI runs on PRs and pushes to `main` via GitHub + Actions (`.github/workflows/`); releases are automated with release-please and + published to npm via OIDC trusted publishing — see CONTRIBUTING.md. +- **Docusaurus 3 only** (MDX v3, unified ESM, native `fetch`). **Node 20, 22, 24** + (Docusaurus 3 requires Node 20+; the e2e build will not run on Node 18). - Prefer the latest versions of libraries. - ESM-first. Intra-package imports use explicit `.js` extensions (e.g. `import { Fallback } from "./Fallback.js"`) — required by the @@ -25,7 +28,12 @@ static HTML. The browser never holds a token or calls the GitLab API. ## Commands -- `npm run build` — build the package with tsup (ESM + CJS + d.ts) into `dist/`. +- `npm run build` — build the package with tsup (ESM + d.ts) into `dist/`. + The package is **ESM-only**: every remark/rehype/unified dependency is pure ESM, + so a CJS build would `require()` them — which breaks under Node's `require(ESM)` + interop (`unified().use()` receives `{ default: fn }` → "empty preset", failing + the Docusaurus build). Do not re-add a `cjs` tsup format or a `require` export + condition; `test/packaging.test.ts` guards this. - `npm run test` — run all tests (Vitest). Use `npx vitest run ` for one file. - `npm run typecheck` — `tsc --noEmit`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 606c741..e82f160 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,8 +5,10 @@ you from clone to a green build. ## Prerequisites -- **Node 18+** and **npm** +- **Node 20, 22, or 24** and **npm** (Docusaurus 3 requires Node 20+) - A GitLab token is **not** required for development (the tests mock the API). +- Optionally, a **Dev Container** (`.devcontainer/`) — open the repo in a container + with nvm preloaded with Node 20/22/24; switch with `nvm use 22`. ## Setup @@ -27,7 +29,7 @@ npm run build # bundle the package into dist/ | `npm run typecheck` | `tsc --noEmit` | | `npm run lint` | ESLint + markdownlint | | `npm run lint:fix` | Auto-fix lint issues | -| `npm run build` | Build with tsup (ESM + CJS + types) | +| `npm run build` | Build with tsup (ESM-only + types) | The example sites have their own READMEs: [`examples/site`](./examples/site/README.md) (mocked, drives the e2e) and @@ -61,6 +63,31 @@ npm run lint && npm run typecheck && npm test Keep changes focused, and add/adjust tests for any behavior you change. +## Releasing + +Releases are automated with [release-please](https://github.com/googleapis/release-please) +and published to npm with provenance via OIDC trusted publishing. + +1. Land changes on `main` using **conventional commits** (`feat:`, `fix:`, + `chore:`, …). `feat:` bumps the minor version, `fix:` the patch version. +2. release-please opens/maintains a **release PR** (`chore: release x.y.z`) that + bumps `package.json` + `.release-please-manifest.json` and updates the + changelog. +3. Merge the release PR. The `Release` workflow tags the release and the + `publish` job runs `npm publish --provenance --access public`. No npm token is + stored — publishing authenticates via GitHub OIDC. + +### One-time setup + +- On npmjs.com, add `ebuildy/docusaurus-plugin-gitlab` + + `.github/workflows/release.yml` as a **trusted publisher** for + `@ebuildy/docusaurus-plugin-gitlab`. +- **First publish only:** because the package name does not yet exist on npm, the + very first publish may need to be bootstrapped manually from a clean checkout: + `npm ci && npm run build && npm publish --access public` (without + `--provenance`, which requires CI/OIDC). Subsequent releases publish + automatically. + ## Working with AI (Claude Code) This repo is set up to be worked on with **[Claude Code](https://claude.com/claude-code)**, diff --git a/README.md b/README.md index 20f2c25..15aa0aa 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,8 @@ tokens or network calls ever reach the browser, and pages stay fast. - ✅ README images **and badges are downloaded and localized** (offline-safe, frozen at build time) - ✅ On-disk caching, theme-aware (Infima) styling, graceful error fallbacks -> Requires Docusaurus **3.x** and Node **18+**. +> Requires Docusaurus **3.x** and Node **20, 22, or 24** (Docusaurus 3 itself +> needs Node 20+). ## Installation @@ -21,6 +22,14 @@ tokens or network calls ever reach the browser, and pages stay fast. npm install @ebuildy/docusaurus-plugin-gitlab ``` +> **ESM-only.** This package ships as ES modules (all of its remark/rehype +> dependencies are ESM). Load it from an ESM config — `docusaurus.config.ts` or +> `docusaurus.config.mjs` (the examples below use `import`). If your site still +> uses a CommonJS `docusaurus.config.js` on **Node < 20.19**, either switch the +> config to ESM or load the plugin with `await import(...)`. On **Node 20.19+** +> (and 22+) CommonJS configs work too, since Node can `require()` ES modules +> natively. + ## Setup Two one-time steps in your Docusaurus site. @@ -189,7 +198,7 @@ components, so you can wrap or restyle them as needed. ```bash npm install -npm run build # bundle with tsup (ESM + CJS + types) +npm run build # bundle with tsup (ESM-only + types) npm run test # unit tests (Vitest) npm run typecheck # tsc --noEmit ``` diff --git a/docs/superpowers/plans/2026-06-28-github-actions-ci-release.md b/docs/superpowers/plans/2026-06-28-github-actions-ci-release.md new file mode 100644 index 0000000..ed4461d --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-github-actions-ci-release.md @@ -0,0 +1,602 @@ +# GitHub Actions CI + Release + Security Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add GitHub Actions to lint, type-check, test, build, and release `@ebuildy/docusaurus-plugin-gitlab` to npm (via release-please + OIDC trusted publishing), plus security checks (CodeQL, Dependabot + dependency review, OpenSSF Scorecard). + +**Architecture:** Plan A — two core workflows (`ci.yml`, `release.yml`) plus three security workflows (`codeql.yml`, `scorecard.yml`, `dependabot.yml`). CI runs lint/typecheck once and the full test suite (unit + real Docusaurus e2e) on a Node 18/20/22 matrix. Releases are conventional-commit driven by release-please; publishing uses npm OIDC trusted publishing with provenance (no long-lived token). All third-party actions are pinned to commit SHAs. + +**Tech Stack:** GitHub Actions, release-please (`googleapis/release-please-action@v4`), CodeQL (`github/codeql-action@v3`), OpenSSF Scorecard (`ossf/scorecard-action@v2`), Dependabot, npm 11+ (OIDC), actionlint, pinact. + +**Spec:** `docs/superpowers/specs/2026-06-28-github-actions-ci-release-design.md` + +--- + +## Prerequisites (one-time, mostly outside this repo) + +These are tracked here but are not code tasks. Do them before/around the first release: + +- On npmjs.com, register `ebuildy/docusaurus-plugin-gitlab` + `.github/workflows/release.yml` as a **trusted publisher** for the package. +- First publish bootstrap: package `@ebuildy/docusaurus-plugin-gitlab` is not yet on npm. The first publish may need a manual `npm publish --access public` (NO `--provenance` — provenance requires CI/OIDC) to create the package name, after which OIDC publishing works. Documented in CONTRIBUTING.md (Task 9). +- In repo Settings: enable Dependabot alerts/security updates, secret scanning + push protection. + +## Tooling used for verification + +- **actionlint** validates workflow YAML. Install: `brew install actionlint` (macOS), or `bash <(curl -s https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)` which drops an `./actionlint` binary in the cwd. Run: `actionlint` (auto-discovers `.github/workflows/`). +- **pinact** pins `uses:` tags to SHAs (Task 8). Install: `go install github.com/suzuki-shunsuke/pinact/cmd/pinact@latest`, or `brew install suzuki-shunsuke/pinact/pinact`. +- JSON validity: `node -e "JSON.parse(require('node:fs').readFileSync('','utf8')); console.log('ok')"`. + +## File structure + +| File | Responsibility | +|---|---| +| `CLAUDE.md` (modify) | Retire the stale "DO NOT use git" hard rule; document the CI/CD flow | +| `.claude/.../memory/never-use-git.md` (delete) + `MEMORY.md` (modify) | Remove the stale no-git memory | +| `.github/workflows/ci.yml` (create) | lint + typecheck job; test/build matrix job; dependency-review job | +| `.github/workflows/release.yml` (create) | release-please job + OIDC publish job | +| `.github/workflows/codeql.yml` (create) | CodeQL JS/TS SAST | +| `.github/workflows/scorecard.yml` (create) | OpenSSF Scorecard | +| `.github/dependabot.yml` (create) | npm + github-actions weekly updates | +| `release-please-config.json` (create) | release-please single-package config | +| `.release-please-manifest.json` (create) | version manifest seeded at 0.1.0 | +| `package.json` (modify) | add `publishConfig.access = public` | +| `CONTRIBUTING.md` (modify) | document the release process + bootstrap | + +--- + +## Task 1: Retire the stale no-git rule + +This authorizes git usage for the rest of execution. Do it first. + +**Files:** +- Modify: `CLAUDE.md` (the "Hard rules" list) +- Delete: `/Users/tdecaux/.claude/projects/-Users-tdecaux-dev-ebuildy-mdx-gitlab-set/memory/never-use-git.md` +- Modify: `/Users/tdecaux/.claude/projects/-Users-tdecaux-dev-ebuildy-mdx-gitlab-set/memory/MEMORY.md` + +- [ ] **Step 1: Edit `CLAUDE.md` to remove the no-git rule** + +Replace this bullet in the "Hard rules" section: + +```markdown +- **DO NOT use git.** Never run any git command (no `init`, `status`, `add`, + `commit`, anything). This repo is intentionally not a git repository. +``` + +with: + +```markdown +- **Git/GitHub:** This is a live GitHub repo (`ebuildy/docusaurus-plugin-gitlab`). + Normal git usage is fine. CI runs on PRs and pushes to `main` via GitHub + Actions (`.github/workflows/`); releases are automated with release-please and + published to npm via OIDC trusted publishing — see CONTRIBUTING.md. +``` + +- [ ] **Step 2: Delete the stale memory file** + +```bash +rm "/Users/tdecaux/.claude/projects/-Users-tdecaux-dev-ebuildy-mdx-gitlab-set/memory/never-use-git.md" +``` + +- [ ] **Step 3: Remove its index line from `MEMORY.md`** + +Delete this line from `MEMORY.md`: + +```markdown +- [Never use git](never-use-git.md) — no git commands in this project, ever +``` + +- [ ] **Step 4: Commit** + +```bash +git add CLAUDE.md +git commit -m "docs: retire stale no-git rule now that repo is on GitHub" +``` + +(The memory files live outside the repo and are not committed.) + +--- + +## Task 2: CI workflow (`ci.yml`) + +**Files:** +- Create: `.github/workflows/ci.yml` + +- [ ] **Step 1: Create the workflow file** + +```yaml +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: {} + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run lint + - run: npm run typecheck + + test: + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + node: [18, 20, 22] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + cache-dependency-path: | + package-lock.json + examples/site/package-lock.json + - run: npm ci + - name: "Build package (needed by the example site file: link)" + run: npm run build + - name: Install example site deps + working-directory: examples/site + run: npm ci + - name: Run tests (unit + e2e docusaurus build) + run: npm test + + dependency-review: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: actions/dependency-review-action@v4 + with: + fail-on-severity: high +``` + +- [ ] **Step 2: Validate the workflow with actionlint** + +Run: `actionlint .github/workflows/ci.yml` +Expected: no output (exit 0). Any error message means fix the YAML. + +- [ ] **Step 3: Confirm the referenced npm scripts succeed locally** + +Run: `npm run lint && npm run typecheck && npm run build` +Expected: all exit 0; `dist/` is (re)generated. + +(`npm test` runs the slow ~1min e2e — optional to run locally here; CI will run it. To run it locally: `npm run build && (cd examples/site && npm ci) && npm test`.) + +- [ ] **Step 4: Commit** + +```bash +git add .github/workflows/ci.yml +git commit -m "ci: add lint, typecheck, test matrix, and dependency review" +``` + +--- + +## Task 3: release-please config files + +**Files:** +- Create: `release-please-config.json` +- Create: `.release-please-manifest.json` + +- [ ] **Step 1: Create `release-please-config.json`** + +```json +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "release-type": "node", + "package-name": "@ebuildy/docusaurus-plugin-gitlab" + } + } +} +``` + +- [ ] **Step 2: Create `.release-please-manifest.json` seeded at the current version** + +```json +{ + ".": "0.1.0" +} +``` + +- [ ] **Step 3: Validate both files parse as JSON** + +Run: +```bash +node -e "JSON.parse(require('node:fs').readFileSync('release-please-config.json','utf8')); JSON.parse(require('node:fs').readFileSync('.release-please-manifest.json','utf8')); console.log('ok')" +``` +Expected: `ok` + +- [ ] **Step 4: Commit** + +```bash +git add release-please-config.json .release-please-manifest.json +git commit -m "build: add release-please config and manifest" +``` + +--- + +## Task 4: `publishConfig` for public scoped publishing + +**Files:** +- Modify: `package.json` + +- [ ] **Step 1: Add `publishConfig` to `package.json`** + +Insert this block immediately after the `"license": "MIT",` line: + +```json + "publishConfig": { + "access": "public" + }, +``` + +(Scoped packages default to restricted; this makes both CI and any manual bootstrap publish public. Provenance is passed as a CI-only flag, not here, so a local bootstrap publish without OIDC still works.) + +- [ ] **Step 2: Verify the package tarball contains only `dist/`** + +Run: `npm pack --dry-run` +Expected: the file list shows `dist/...` entries and package metadata, and does NOT include `src/`, `test/`, or `examples/`. + +- [ ] **Step 3: Commit** + +```bash +git add package.json +git commit -m "build: publish scoped package with public access" +``` + +--- + +## Task 5: Release workflow (`release.yml`) + +**Files:** +- Create: `.github/workflows/release.yml` + +- [ ] **Step 1: Create the workflow file** + +```yaml +name: Release + +on: + push: + branches: [main] + +permissions: {} + +concurrency: + group: release + cancel-in-progress: false + +jobs: + release-please: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + steps: + - id: release + uses: googleapis/release-please-action@v4 + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + + publish: + needs: release-please + if: needs.release-please.outputs.release_created == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: https://registry.npmjs.org + cache: npm + - name: Use OIDC-capable npm + run: npm install -g npm@latest + - run: npm ci + - run: npm run build + - run: npm publish --provenance --access public +``` + +- [ ] **Step 2: Validate with actionlint** + +Run: `actionlint .github/workflows/release.yml` +Expected: no output (exit 0). + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/release.yml +git commit -m "ci: add release-please + OIDC npm publish workflow" +``` + +--- + +## Task 6: CodeQL workflow (`codeql.yml`) + +**Files:** +- Create: `.github/workflows/codeql.yml` + +- [ ] **Step 1: Create the workflow file** + +```yaml +name: CodeQL + +on: + pull_request: + branches: [main] + push: + branches: [main] + schedule: + - cron: "27 3 * * 1" + +permissions: {} + +jobs: + analyze: + runs-on: ubuntu-latest + permissions: + security-events: write + contents: read + actions: read + steps: + - uses: actions/checkout@v4 + - uses: github/codeql-action/init@v3 + with: + languages: javascript-typescript + - uses: github/codeql-action/analyze@v3 + with: + category: "/language:javascript-typescript" +``` + +- [ ] **Step 2: Validate with actionlint** + +Run: `actionlint .github/workflows/codeql.yml` +Expected: no output (exit 0). + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/codeql.yml +git commit -m "ci: add CodeQL JS/TS static analysis" +``` + +--- + +## Task 7: Dependabot + Scorecard + +**Files:** +- Create: `.github/dependabot.yml` +- Create: `.github/workflows/scorecard.yml` + +- [ ] **Step 1: Create `.github/dependabot.yml`** + +```yaml +version: 2 +updates: + - package-ecosystem: npm + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 +``` + +- [ ] **Step 2: Create `.github/workflows/scorecard.yml`** + +```yaml +name: Scorecard + +on: + push: + branches: [main] + schedule: + - cron: "18 4 * * 1" + +permissions: {} + +jobs: + analysis: + runs-on: ubuntu-latest + permissions: + security-events: write + id-token: write + contents: read + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: ossf/scorecard-action@v2 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + - uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: results.sarif +``` + +- [ ] **Step 3: Validate the scorecard workflow with actionlint** + +Run: `actionlint .github/workflows/scorecard.yml` +Expected: no output (exit 0). + +(`dependabot.yml` is not a workflow; actionlint does not cover it. GitHub validates it on push and surfaces errors in the repo's Insights → Dependency graph → Dependabot tab. Visually confirm it matches the block above.) + +- [ ] **Step 4: Commit** + +```bash +git add .github/dependabot.yml .github/workflows/scorecard.yml +git commit -m "ci: add Dependabot and OpenSSF Scorecard" +``` + +--- + +## Task 8: Pin all third-party actions to commit SHAs + +Hardening per the spec. Tags above are working/testable; this converts them to SHAs. Dependabot's `github-actions` ecosystem (Task 7) keeps them current afterward. + +**Files:** +- Modify: every file under `.github/workflows/` + +- [ ] **Step 1: Install pinact** + +Run (pick one): +```bash +brew install suzuki-shunsuke/pinact/pinact +# or: +go install github.com/suzuki-shunsuke/pinact/cmd/pinact@latest +``` +Expected: `pinact --version` prints a version. + +- [ ] **Step 2: Provide a GitHub token so pinact can resolve tags** + +Run: `export GITHUB_TOKEN="$(gh auth token)"` +Expected: no error (requires `gh auth login` already done). + +- [ ] **Step 3: Pin tags to SHAs** + +Run: `pinact run` +Expected: `pinact` rewrites each `uses: owner/repo@vTAG` to `uses: owner/repo@<40-char-sha> # vTAG` across all workflow files. `git diff .github/workflows` shows only `uses:` lines changing. + +Manual fallback if pinact is unavailable — for each `uses: OWNER/REPO@TAG`, resolve and replace by hand: +```bash +gh api repos/OWNER/REPO/commits/TAG --jq '.sha' +``` +Then edit the line to `uses: OWNER/REPO@ # TAG`. + +- [ ] **Step 4: Re-validate all workflows** + +Run: `actionlint` +Expected: no output (exit 0). + +- [ ] **Step 5: Commit** + +```bash +git add .github/workflows +git commit -m "ci: pin third-party actions to commit SHAs" +``` + +--- + +## Task 9: Document the release process + +**Files:** +- Modify: `CONTRIBUTING.md` + +- [ ] **Step 1: Append a "Releasing" section to `CONTRIBUTING.md`** + +Add this to the end of the file: + +```markdown +## Releasing + +Releases are automated with [release-please](https://github.com/googleapis/release-please) +and published to npm with provenance via OIDC trusted publishing. + +1. Land changes on `main` using **conventional commits** (`feat:`, `fix:`, + `chore:`, …). `feat:` bumps the minor version, `fix:` the patch version. +2. release-please opens/maintains a **release PR** (`chore: release x.y.z`) that + bumps `package.json` + `.release-please-manifest.json` and updates the + changelog. +3. Merge the release PR. The `Release` workflow tags the release and the + `publish` job runs `npm publish --provenance --access public`. No npm token is + stored — publishing authenticates via GitHub OIDC. + +### One-time setup + +- On npmjs.com, add `ebuildy/docusaurus-plugin-gitlab` + `.github/workflows/release.yml` + as a **trusted publisher** for `@ebuildy/docusaurus-plugin-gitlab`. +- **First publish only:** because the package name does not yet exist on npm, the + very first publish may need to be bootstrapped manually from a clean checkout: + `npm ci && npm run build && npm publish --access public` (without + `--provenance`, which requires CI/OIDC). Subsequent releases publish + automatically. +``` + +- [ ] **Step 2: Lint the docs** + +Run: `npm run lint:md` +Expected: exit 0 (no markdownlint errors). If it reports issues, run `npm run lint:md -- --fix` and re-check. + +- [ ] **Step 3: Commit** + +```bash +git add CONTRIBUTING.md +git commit -m "docs: document the automated release process" +``` + +--- + +## Task 10: Open a PR and verify the pipeline end-to-end + +**Files:** none (verification only) + +- [ ] **Step 1: Push the branch and open a PR** + +```bash +git push -u origin HEAD +gh pr create --fill +``` + +- [ ] **Step 2: Watch the CI checks** + +Run: `gh pr checks --watch` +Expected: `lint`, `test (18)`, `test (20)`, `test (22)`, `dependency-review`, and `CodeQL` all pass. (The `test` jobs include the ~1min e2e Docusaurus build.) + +- [ ] **Step 3: Confirm CodeQL produced results** + +Check the PR's checks / the repo Security tab → Code scanning. Expected: a CodeQL run completed (0 or more alerts). + +- [ ] **Step 4: Merge and confirm release-please opens a release PR** + +After merging this PR to `main`, run: `gh pr list` +Expected: a `chore: release ...` PR opened by release-please appears (driven by the conventional commits on `main`). + +- [ ] **Step 5 (gated on one-time npm setup): cut the first release** + +Complete the npm trusted-publisher setup + first-publish bootstrap (see Prerequisites / CONTRIBUTING.md), then merge the release PR. Confirm with: `npm view @ebuildy/docusaurus-plugin-gitlab version` +Expected: the published version matches the release tag, and the npm page shows a provenance attestation. + +--- + +## Notes for the executor + +- Workflow YAML and config files are not unit-testable; their "tests" are + `actionlint` (syntax/contract) plus a real PR run (Task 10). Treat a clean + `actionlint` + green PR checks as the pass criteria. +- Keep commits conventional — release-please derives the next version from them. +- If `npm test` fails in CI only on the e2e step, confirm the `npm run build` + (root) and `examples/site` `npm ci` steps ran before it; the e2e spawns a real + `docusaurus build` that loads the freshly built `dist/` via the `file:../..` + link. diff --git a/docs/superpowers/specs/2026-06-28-github-actions-ci-release-design.md b/docs/superpowers/specs/2026-06-28-github-actions-ci-release-design.md new file mode 100644 index 0000000..74a0993 --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-github-actions-ci-release-design.md @@ -0,0 +1,228 @@ +# GitHub Actions: CI + Release — Design + +**Date:** 2026-06-28 +**Package:** `@ebuildy/docusaurus-plugin-gitlab` +**Repo:** `github.com/ebuildy/docusaurus-plugin-gitlab` + +## Goal + +Add GitHub Actions to lint, type-check, test, build, and release the package. +Releases are automated with **release-please** and published to npm via **OIDC +trusted publishing** (no long-lived token). + +## Decisions (locked) + +| Question | Decision | +|---|---| +| Release model | release-please (conventional-commit driven release PR) | +| npm auth | Trusted publishing (OIDC), provenance enabled | +| Node test matrix | 18, 20, 22 | +| e2e in CI | Yes — runs on every PR/push, all matrix versions | +| Workflow structure | Plan A: two workflows (`ci.yml` + `release.yml`) | +| Security checks | CodeQL, Dependabot + dependency review, OpenSSF Scorecard (no standalone `npm audit` gate) | +| CI hardening | Least-privilege `permissions`, SHA-pinned third-party actions, npm provenance | + +## Repo facts that shape the design + +- ESM-first dual build via tsup → `dist/` (ESM + CJS + d.ts). `npm run build`. +- Scripts: `build` (tsup), `test` (`vitest run`), `typecheck` (`tsc --noEmit`), + `lint` (`eslint . && markdownlint-cli2`). +- Vitest `include` is `src/**/*.test.{ts,tsx}` and `test/**/*.test.{ts,tsx}` — + so `npm test` **already includes** the e2e suite (`test/e2e/build.test.ts`). +- The e2e test spawns `npm run build` inside `examples/site` (a real Docusaurus + 3 build) against a local GitLab stub. It therefore requires: + 1. the package built (`dist/`) — the site links it via `file:../..`; + 2. the example site's own deps installed (`examples/site` has its own + `package-lock.json`). +- Scoped package (`@ebuildy/...`) → publishing needs `--access public`. +- Node 20 local; CLAUDE.md states Node 18+ support. +- Existing local quality gates: husky `pre-commit` → `lint-staged` + (eslint --fix, markdownlint --fix). CI re-checks lint without `--fix`. + +## Architecture + +Files under `.github/`: + +- `.github/workflows/ci.yml` — lint, typecheck, test/build matrix, dependency review. +- `.github/workflows/release.yml` — release-please + OIDC publish. +- `.github/workflows/codeql.yml` — SAST. +- `.github/workflows/scorecard.yml` — OpenSSF Scorecard. +- `.github/dependabot.yml` — npm + github-actions update schedule. + +The two core CI/CD workflows (`ci.yml`, `release.yml`) are detailed first; the +security workflows are specified in the **Security** section below. + +### `ci.yml` + +```text +on: pull_request, push (branches: [main]) +concurrency: group per ref, cancel-in-progress: true + +jobs: + lint: # runtime-independent checks, Node 20 only + - actions/checkout + - actions/setup-node (node 20, cache: npm) + - npm ci + - npm run lint # eslint . && markdownlint-cli2 + - npm run typecheck # tsc --noEmit + + test: # matrix: node 18, 20, 22 + strategy.matrix.node: [18, 20, 22] + - actions/checkout + - actions/setup-node (matrix node, cache: npm, + cache-dependency-path: [package-lock.json, examples/site/package-lock.json]) + - npm ci # root deps + - npm run build # produce dist/ for the file: link + - npm ci --prefix examples/site # example site deps (Docusaurus) + - npm test # vitest: unit + e2e (real docusaurus build) +``` + +Notes: +- Build is verified implicitly: `test` cannot pass without a successful + `npm run build`, so no standalone build job is needed. +- e2e runs on all three Node versions (heaviest part, ~3–4 min total); accepted + for maximum confidence per decision. + +### `release.yml` + +```text +on: push (branches: [main]) +concurrency: group "release", cancel-in-progress: false + +jobs: + release-please: + permissions: { contents: write, pull-requests: write } + - googleapis/release-please-action (release-type: node) + outputs: release_created, tag_name + + publish: + needs: release-please + if: needs.release-please.outputs.release_created == 'true' + permissions: { id-token: write, contents: read } + - actions/checkout + - actions/setup-node (node 20, registry-url: https://registry.npmjs.org) + - npm install -g npm@latest # ensure OIDC-capable npm (>= 11.5) + - npm ci + - npm run build + - npm publish --provenance --access public +``` + +Notes: +- `release-please` maintains a "release PR" (version bump + CHANGELOG) from + conventional commits. Merging that PR sets `release_created=true`, tags, and + triggers `publish`. +- `publish` uses OIDC trusted publishing: no `NODE_AUTH_TOKEN` / `NPM_TOKEN`. + `id-token: write` is required for both OIDC auth and provenance. + +### release-please config files + +- `release-please-config.json` — single package at repo root, `release-type: + node`, `package-name: @ebuildy/docusaurus-plugin-gitlab`. +- `.release-please-manifest.json` — seeded to current version `"." : "0.1.0"`. + +## Security + +### Baseline hardening (applied to all workflows) + +- **Least privilege**: each workflow declares a top-level `permissions: {}` (deny + all by default) and grants the minimum tokens per job (e.g. `contents: read`, + and `security-events: write` only where SARIF is uploaded). +- **SHA-pinned actions**: all third-party actions (release-please, checkout, + setup-node, codeql, scorecard, dependency-review) are pinned to a full commit + SHA, not a floating tag, to defend against tag-retargeting / action compromise. + Dependabot (below) keeps these SHAs updated. +- **npm provenance**: publish runs `--provenance` under OIDC (see release.yml). + +### `codeql.yml` — static analysis (SAST) + +```text +on: pull_request, push (branches: [main]), schedule (weekly cron) +permissions: { security-events: write, contents: read, actions: read } + +jobs: + analyze: + - github/codeql-action/init (languages: javascript-typescript) + - github/codeql-action/analyze +``` + +Relevant because the package renders sanitized HTML via +`dangerouslySetInnerHTML`; CodeQL's JS/TS queries flag injection-style patterns. +Findings surface in the Security tab and as PR checks. + +### `dependabot.yml` + dependency review + +- `.github/dependabot.yml` — two ecosystems on a weekly schedule: + - `npm` (root `package.json`; `examples/site` is a test fixture — include if + desired, otherwise root only), + - `github-actions` (keeps the SHA-pinned action versions patched). +- **Dependency review** step in `ci.yml` on `pull_request` + (`actions/dependency-review-action`): blocks PRs that introduce dependencies + with known high/critical CVEs or disallowed licenses. + +### `scorecard.yml` — OpenSSF Scorecard + +```text +on: push (branches: [main]), schedule (weekly cron) +permissions: { security-events: write, id-token: write, contents: read } + +jobs: + analysis: + - ossf/scorecard-action (results_format: sarif, publish_results: true) + - github/codeql-action/upload-sarif (results.sarif) +``` + +Posture report (branch protection, pinned deps, token perms, etc.), published to +the Security tab; a README badge is optional. + +### Repo settings to enable (manual, outside code) + +- Secret scanning + push protection (GitHub native, free for public repos). +- Dependabot alerts/security updates enabled in repo settings. + +## One-time manual setup (outside this codebase) + +1. **npmjs.com trusted publisher**: register the repo + `ebuildy/docusaurus-plugin-gitlab` + workflow `release.yml` as a trusted + publisher for the package. +2. **First publish bootstrap** ⚠️: the package is not yet published (v0.1.0). + Trusted publishing for a brand-new package name may require a one-time manual + `npm publish --access public` to create the package, after which OIDC + publishing works for subsequent releases. Document this in the README / + CONTRIBUTING release notes. + +## Stale-instruction cleanup (in scope) + +`CLAUDE.md` currently states "DO NOT use git / this repo is intentionally not a +git repository," and the auto-memory `never-use-git` mirrors it. The repo is now +a live GitHub repository with CI/CD. As part of this work: + +- Update `CLAUDE.md` to remove the no-git hard rule and reflect the GitHub + Actions workflow (CI on PRs, release-please + OIDC publish). +- Update/remove the `never-use-git` memory accordingly. + +(During the brainstorming session itself, no git commands were run, honoring the +rule until it is formally retired.) + +## Optional / nice-to-have (decide at planning time) + +- `actionlint` to validate workflow YAML locally or as a small CI step. +- Dependabot for GitHub Actions version bumps. + +## Testing strategy for this change + +Workflows are validated by execution: +1. Open a PR with the new workflows → confirm `lint` + `test` (3 Node versions, + including e2e) pass. +2. Land a conventional commit on `main` → confirm release-please opens a release + PR. +3. Merge the release PR → confirm `publish` runs and the package appears on npm + with provenance. + +Static check before pushing: `actionlint` (optional) and a manual review of the +two workflow files. + +## Out of scope + +- Multi-package / monorepo release flows (single package). +- Publishing the example site (it is a test fixture only). +- Coverage upload / external reporting services. diff --git a/package.json b/package.json index 2082687..12eeaef 100644 --- a/package.json +++ b/package.json @@ -3,22 +3,28 @@ "version": "0.1.0", "description": "MDX extensions to embed GitLab resources in Docusaurus 3 docs", "license": "MIT", + "publishConfig": { + "access": "public" + }, "type": "module", + "engines": { + "node": ">=20" + }, "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", - "require": "./dist/index.cjs" + "default": "./dist/index.js" }, "./remark": { "types": "./dist/remark/index.d.ts", "import": "./dist/remark/index.js", - "require": "./dist/remark/index.cjs" + "default": "./dist/remark/index.js" }, "./components": { "types": "./dist/components/index.d.ts", "import": "./dist/components/index.js", - "require": "./dist/components/index.cjs" + "default": "./dist/components/index.js" } }, "files": [ diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..e497d5b --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "release-type": "node", + "package-name": "@ebuildy/docusaurus-plugin-gitlab" + } + } +} diff --git a/test/packaging.test.ts b/test/packaging.test.ts new file mode 100644 index 0000000..433f628 --- /dev/null +++ b/test/packaging.test.ts @@ -0,0 +1,44 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, it, expect } from "vitest"; + +/** + * This package is **ESM-only on purpose**. + * + * Every runtime dependency in the markdown pipeline (`unified`, `remark-*`, + * `rehype-*`, `unist-util-*`) is pure ESM. A CJS build of this package therefore + * emits `require("remark-gfm")` etc., and under Node >= 20.19 / >= 22 (where + * `require(ESM)` is enabled) esbuild's interop returns the module *namespace* + * (`{ default: fn }`) instead of the default export. Passing that to + * `unified().use()` throws "Expected usable value but received an empty preset", + * which broke the Docusaurus build in CI. + * + * Shipping ESM-only removes the broken artifact. These assertions guard against + * accidentally reintroducing a CJS build / `require` export condition. + */ +describe("packaging: ESM-only", () => { + const pkg = JSON.parse( + readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"), + ); + + const subpaths = [".", "./remark", "./components"]; + + it("exposes the documented export subpaths", () => { + for (const sub of subpaths) { + expect(pkg.exports[sub], `missing export "${sub}"`).toBeTruthy(); + } + }); + + it("declares no CJS `require` condition on any export", () => { + for (const sub of subpaths) { + expect(pkg.exports[sub].require, `export "${sub}" must not have a require condition`).toBeUndefined(); + } + }); + + it("points every export target at an ESM `.js` file (never `.cjs`)", () => { + const targets = subpaths.flatMap((sub) => Object.values(pkg.exports[sub] as Record)); + for (const target of targets) { + expect(target.endsWith(".cjs"), `export target ${target} must not be .cjs`).toBe(false); + } + }); +}); diff --git a/tsup.config.ts b/tsup.config.ts index ef8bfc2..c290f2b 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ entry: ["src/index.ts", "src/remark/index.ts", "src/components/index.ts"], - format: ["esm", "cjs"], + format: ["esm"], dts: true, clean: true, sourcemap: true,