diff --git a/.github/actions/flybot-tests/action.yml b/.github/actions/flybot-tests/action.yml new file mode 100644 index 00000000..4616fe06 --- /dev/null +++ b/.github/actions/flybot-tests/action.yml @@ -0,0 +1,89 @@ +name: FlyBot tests +description: >- + Node, npm ci, the DSH harness, lint, the client bundle check, ds-bot's suite and the social + and tree plugin suites (optionally as one coverage run). Used by Test (the PR gate); the + Compatibility legs (nightly.yml) can use it too, to run the same checks. +inputs: + node: + description: Node.js version + required: true + dsh: + description: DSH (@deepseek-ai/dsh) version for the harness + required: true + coverage: + description: "'true': run all suites in one coverage run and upload lcov" + required: false + default: 'false' +runs: + using: composite + steps: + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: ${{ inputs.node }} + cache: npm + cache-dependency-path: ds-bot/package-lock.json + # The job-level timeout-minutes bounds a stuck registry (composite steps take none). + - name: npm ci + shell: bash + working-directory: ds-bot + run: npm ci --no-fund --no-audit + - name: Install DSH + shell: bash + working-directory: ds-bot + env: + DSH: ${{ inputs.dsh }} + run: npm run harness -- --no-save "@deepseek-ai/dsh@$DSH" + # Lint and the bundle check first: a slip fails in seconds instead of after the suite. + - name: Lint + shell: bash + working-directory: ds-bot + run: npm run lint + - name: Build (client bundle is up to date) + shell: bash + working-directory: ds-bot + run: node scripts/build-client.mjs --check + # ds-bot's suite (incl. tree-host / tree-plugin / bot-tree: the tree plugin on a real + # ds-bot), then each plugin's own suite (social; tree's test/tree.test.js). + - name: Unit tests + if: inputs.coverage != 'true' + shell: bash + working-directory: ds-bot + run: npm test + - name: Plugin tests (social) + if: inputs.coverage != 'true' + shell: bash + working-directory: ds-bot/plugins/social + run: node --import ../../test/harness-resolve.mjs --test "test/*.test.js" + - name: Plugin tests (tree) + if: inputs.coverage != 'true' + shell: bash + working-directory: ds-bot/plugins/tree + run: node --test "test/*.test.js" + # The same three suites in one run, so coverage spans ds-bot and both plugins. + - name: Unit and plugin tests with coverage + if: inputs.coverage == 'true' + shell: bash + working-directory: ds-bot + env: + NODE_V: ${{ inputs.node }} + DSH: ${{ inputs.dsh }} + run: | + set -o pipefail + node --import ./test/harness-resolve.mjs --test --experimental-test-coverage \ + --test-reporter=spec --test-reporter-destination=stdout \ + --test-reporter=lcov --test-reporter-destination=lcov.info \ + "test/*.test.js" "plugins/social/test/*.test.js" "plugins/tree/test/*.test.js" | tee test-output.txt + { + echo "### Coverage: ds-bot + social + tree (Node $NODE_V, DSH $DSH)" + echo '```' + grep -E 'file +\| line %|all files' test-output.txt | sed 's/^ℹ //' | head -2 + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + - name: Upload coverage + if: always() && inputs.coverage == 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: coverage-lcov + path: ds-bot/lcov.info + if-no-files-found: ignore + retention-days: 14 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..0bf55358 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,34 @@ +# Weekly, grouped updates with a 7-day cooldown so a fresh release has a week to settle +# (retags, withdrawals) before it is proposed. +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + cooldown: + default-days: 7 + groups: + github-actions: + patterns: ["*"] + + - package-ecosystem: npm + # Plugins only declare peer dependencies (no lockfile), so ds-bot is the one npm project. + directory: /ds-bot + schedule: + interval: weekly + cooldown: + default-days: 7 + semver-major-days: 7 + semver-minor-days: 7 + semver-patch-days: 7 + # The DSH harness is pinned per matrix leg in workflows/test.yml; bump it there by hand. + ignore: + - dependency-name: "@deepseek-ai/*" + groups: + npm-dev: + dependency-type: development + patterns: ["*"] + npm-prod: + dependency-type: production + patterns: ["*"] diff --git a/.github/scripts/check-pack.mjs b/.github/scripts/check-pack.mjs new file mode 100644 index 00000000..bbac26ba --- /dev/null +++ b/.github/scripts/check-pack.mjs @@ -0,0 +1,27 @@ +// Packs each FlyBot package as a DSH profile would install it and checks the tarball: +// every `main`/`exports` target and every `files` entry is inside, nothing from test/ or dev/ +// leaks in. Usage: node .github/scripts/check-pack.mjs [outDir] (run from the repo root; +// with outDir the .tgz files are kept there, for a release). +import { execFileSync } from 'node:child_process' +import { mkdirSync, readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' + +const packages = ['ds-bot', 'ds-bot/plugins/social', 'ds-bot/plugins/tree'] +const out = process.argv[2] ? resolve(process.argv[2]) : undefined +if (out) mkdirSync(out, { recursive: true }) +let failed = false +const targets = (exp) => typeof exp === 'string' ? [exp] : exp && typeof exp === 'object' ? Object.values(exp).flatMap(targets) : [] +for (const dir of packages) { + const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) + const args = ['pack', '--json', '--ignore-scripts', ...(out ? ['--pack-destination', out] : ['--dry-run'])] + const [info] = JSON.parse(execFileSync('npm', args, { cwd: dir, encoding: 'utf8', shell: process.platform === 'win32' })) + const inside = new Set(info.files.map((f) => f.path)) + const has = (p) => inside.has(p.replace(/^\.\//, '')) || [...inside].some((f) => f.startsWith(p.replace(/^\.\//, '').replace(/\/?$/, '/'))) + const want = [pkg.main ?? 'index.js', ...targets(pkg.exports), ...(pkg.files ?? [])] + const missing = [...new Set(want)].filter((p) => !has(p)) + const leaked = [...inside].filter((f) => /^(test|dev)\//.test(f)) + console.log(`${pkg.name}@${pkg.version}: ${info.files.length} files, ${(info.size / 1024).toFixed(0)} KiB packed${out ? ` -> ${info.filename}` : ''}`) + for (const p of missing) { console.error(` missing from the package: ${p}`); failed = true } + for (const p of leaked) { console.error(` should not be packed: ${p}`); failed = true } +} +if (failed) process.exit(1) diff --git a/.github/scripts/release-notes.mjs b/.github/scripts/release-notes.mjs new file mode 100644 index 00000000..85419e13 --- /dev/null +++ b/.github/scripts/release-notes.mjs @@ -0,0 +1,10 @@ +// Prints the CHANGELOG.md section for one version (e.g. 0.1.5), for the release notes. +// Exits 1 when the changelog has no section for it. +import { readFileSync } from 'node:fs' + +const version = process.argv[2] +const lines = readFileSync('CHANGELOG.md', 'utf8').split('\n') +const start = lines.findIndex((l) => l.startsWith(`## [${version}]`)) +if (!version || start < 0) { console.error(`CHANGELOG.md has no "## [${version}]" section`); process.exit(1) } +const end = lines.findIndex((l, i) => i > start && l.startsWith('## [')) +console.log(lines.slice(start + 1, end < 0 ? undefined : end).join('\n').trim()) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..15700401 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,50 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + # Weekly, Sunday 07:13 UTC (off-peak). + - cron: "13 7 * * 0" + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + actions: read + contents: read + # Uploads results to the Security tab; works with GITHUB_TOKEN, no secrets. + security-events: write + strategy: + fail-fast: false + matrix: + # javascript-typescript: ds-bot and its plugins (no build step needed). + # actions: data-flow analysis of these workflows, complementing zizmor. + language: [javascript-typescript, actions] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + with: + languages: ${{ matrix.language }} + build-mode: none + queries: security-and-quality + config: | + paths-ignore: + - ds-bot/client.js + - ds-bot/plugins/*/client.js + - "**/test/**" + - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..9f188116 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,84 @@ +name: Release + +# Pushing a v* tag (matching ds-bot/package.json's version) publishes a GitHub Release with +# the packed ds-bot, social and tree tarballs and that version's CHANGELOG.md section. It +# waits for the Test run on the tagged commit to pass first. Only GITHUB_TOKEN is used. + +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g. 0.1.6, no v prefix; the v tag must exist)' + required: true + type: string + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: 75 + permissions: + contents: write + actions: read + env: + GH_TOKEN: ${{ github.token }} + INPUT_VERSION: ${{ inputs.version }} + steps: + - name: Version + id: version + run: | + if [[ "$GITHUB_REF" == refs/tags/* ]]; then VERSION="${GITHUB_REF_NAME#v}"; else VERSION="$INPUT_VERSION"; fi + # a newline or quote would add outputs + [[ "$VERSION" =~ ^[0-9][0-9A-Za-z.+-]*$ ]] || { echo "bad version: $VERSION" >&2; exit 1; } + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "ref=v$VERSION" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ steps.version.outputs.ref }} + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + # No cache in a job that publishes: nothing restored from a cache goes into a release. + package-manager-cache: false + - name: Tag matches ds-bot/package.json + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + have=$(node -p "require('./ds-bot/package.json').version") + [ "$have" = "$VERSION" ] || { echo "ds-bot/package.json is $have, tag is v$VERSION" >&2; exit 1; } + - name: Wait for a green Test run on this commit + run: | + sha=$(git rev-parse HEAD) + for i in $(seq 1 120); do + c=$(gh run list -R "$GITHUB_REPOSITORY" --workflow Test --commit "$sha" --json status,conclusion \ + -q '[.[] | select(.status == "completed")] | map(.conclusion) | if any(. == "success") then "success" elif length > 0 and all(. != "success") then "failure" else "" end') + echo "attempt $i: ${c:-pending}" + [ "$c" = success ] && exit 0 + [ "$c" = failure ] && { echo "Test failed on $sha" >&2; exit 1; } + sleep 30 + done + echo "no completed Test run on $sha" >&2; exit 1 + - name: Pack + run: node .github/scripts/check-pack.mjs dist + - name: Publish the release + env: + VERSION: ${{ steps.version.outputs.version }} + REF: ${{ steps.version.outputs.ref }} + run: | + set -euo pipefail + node .github/scripts/release-notes.mjs "$VERSION" > notes.md + if gh release view "$REF" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release edit "$REF" -R "$GITHUB_REPOSITORY" --notes-file notes.md + gh release upload "$REF" dist/*.tgz -R "$GITHUB_REPOSITORY" --clobber + else + gh release create "$REF" dist/*.tgz -R "$GITHUB_REPOSITORY" --verify-tag --title "FlyBot $REF" --notes-file notes.md + fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index efe966a5..345132d2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,48 +10,71 @@ on: pull_request: workflow_dispatch: -permissions: - contents: read - +# On main a run finishes: a release waits for a green run on its commit, and one cancelled +# by every next push would leave none to wait for. A PR's run is cancelled by its next push. concurrency: group: test-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} +permissions: + contents: read + jobs: + # The PR gate: one job (see #31). Its name is the required check on main: keep it exactly. + # Windows, Node 22 and the next DSH run in the Compatibility workflow (nightly.yml). test: - name: ${{ matrix.os }} · Node ${{ matrix.node }} · DSH ${{ matrix.dsh }} - runs-on: ${{ matrix.os }} - timeout-minutes: 20 - strategy: - fail-fast: false - matrix: - # Slim PR/push shipgate: one cheap job. - # The dropped legs run in the Compatibility workflow (nightly.yml): - # - windows-latest · Node 24 · DSH 0.2.0-rc.2 - # - ubuntu-latest · Node 22 · DSH 0.2.0-rc.2 - # - ubuntu-latest · Node 24 · DSH 0.2.1-alpha.1 - include: - - { os: ubuntu-latest, node: 24, dsh: 0.2.0-rc.2 } - defaults: - run: - shell: bash - working-directory: ds-bot + name: ubuntu-latest · Node 24 · DSH 0.2.0-rc.2 + runs-on: ubuntu-latest + timeout-minutes: 25 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: actions/setup-node@v4 + - uses: ./.github/actions/flybot-tests with: - node-version: ${{ matrix.node }} - cache: npm - cache-dependency-path: ds-bot/package-lock.json - - name: npm ci - run: npm ci --no-fund --no-audit - timeout-minutes: 5 - - name: Install DSH ${{ matrix.dsh }} - run: npm run harness -- --no-save "@deepseek-ai/dsh@${{ matrix.dsh }}" - timeout-minutes: 5 - - run: npm test - - name: Build (client bundle is up to date) - run: node scripts/build-client.mjs --check - - run: npm run lint + node: '24' + dsh: 0.2.0-rc.2 + coverage: 'true' + + # What a DSH profile installs: each package packs with everything its main/exports/files + # name, and no tests or dev tools. The release job ships these same tarballs. + package: + name: package + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + - name: Pack and check ds-bot, social, tree + run: node .github/scripts/check-pack.mjs + - name: Typecheck the plugin API (types/*.d.ts, strict) + working-directory: ds-bot + run: npx --yes -p typescript@5.9.3 tsc --noEmit --strict --skipLibCheck types/*.d.ts + - name: Changelog has an Unreleased section + run: grep -q '^## \[Unreleased\]' CHANGELOG.md + - name: Lint the workflows (actionlint) + env: + VERSION: 1.7.7 + run: | + curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${VERSION}/actionlint_${VERSION}_linux_amd64.tar.gz" | tar -xz actionlint + ./actionlint -color + + # One check that is green only when the test job and the package job are. + tests-ok: + name: tests-ok + if: always() + needs: [test, package] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Every job passed + env: + TEST: ${{ needs.test.result }} + PACKAGE: ${{ needs.package.result }} + run: | + echo "test: $TEST, package: $PACKAGE" + [ "$TEST" = success ] && [ "$PACKAGE" = success ]