From de392584e432d90a7ed7e6f2e1ca8242ba3ca21a Mon Sep 17 00:00:00 2001 From: hsliuustc0106 Date: Wed, 14 Oct 2026 20:53:48 +0800 Subject: [PATCH 1/2] ci: slim Test matrix to ubuntu Node 24 + DSH 0.2.0-rc.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep a cheap PR/push shipgate: one required job (ubuntu-latest · Node 24 · DSH 0.2.0-rc.2). Drop windows, Node 22, and DSH 0.2.1-alpha.1 from the required matrix; they can return later as nightly/manual. Lint, npm test, and build-client --check are unchanged. Verifier: not run (workflow-only change; CI will exercise on this PR). --- .github/workflows/test.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 11c85777..12cf91c3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,11 +20,13 @@ jobs: strategy: fail-fast: false matrix: + # Slim PR/push shipgate: one cheap job. + # Dropped from the required matrix (restore later as nightly/manual if needed): + # - 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 } - - { os: windows-latest, node: 24, dsh: 0.2.0-rc.2 } - - { os: ubuntu-latest, node: 22, dsh: 0.2.0-rc.2 } - - { os: ubuntu-latest, node: 24, dsh: 0.2.1-alpha.1 } defaults: run: shell: bash From c0507e1040da401e8d7335257a030ec5776f4a5b Mon Sep 17 00:00:00 2001 From: Birfy Date: Wed, 14 Oct 2026 21:05:52 +0800 Subject: [PATCH 2/2] ci: CI modeled on yetone/magpie, on top of the slim PR gate (#31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Test (PR gate, one job as in #31, same required name): lint and bundle check first, ds-bot + social + tree suites (incl. tree-host and plugins/tree/test/tree.test.js) in one coverage run (job summary + lcov artifact); main's runs are never cancelled. - package: npm-pack ds-bot, social and tree and check main/exports/files are packed and no test/dev files; strict tsc on types/*.d.ts; CHANGELOG Unreleased check; actionlint. - tests-ok: one aggregate check. - Compatibility (nightly, manual, and on PRs touching it): Windows, macOS, Node 22 and DSH 0.2.1-alpha.1 through the same composite action, job names prefixed 'compat ·'. - Release on v* tags: version check, wait for a green Test run, tarballs + CHANGELOG notes. - CodeQL (javascript-typescript, actions) and Dependabot; actions pinned to SHAs with persist-credentials off and read-only permissions. No secrets. --- .github/actions/flybot-tests/action.yml | 89 +++++++++++++++++++++++ .github/dependabot.yml | 34 +++++++++ .github/scripts/check-pack.mjs | 27 +++++++ .github/scripts/release-notes.mjs | 10 +++ .github/workflows/codeql.yml | 50 +++++++++++++ .github/workflows/compat.yml | 46 ++++++++++++ .github/workflows/release.yml | 84 ++++++++++++++++++++++ .github/workflows/test.yml | 93 ++++++++++++++++--------- 8 files changed, 401 insertions(+), 32 deletions(-) create mode 100644 .github/actions/flybot-tests/action.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/scripts/check-pack.mjs create mode 100644 .github/scripts/release-notes.mjs create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/compat.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/actions/flybot-tests/action.yml b/.github/actions/flybot-tests/action.yml new file mode 100644 index 00000000..cba675f0 --- /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). Shared by Test (the PR gate) and + Compatibility (nightly/manual legs), so both run exactly 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/compat.yml b/.github/workflows/compat.yml new file mode 100644 index 00000000..190067ef --- /dev/null +++ b/.github/workflows/compat.yml @@ -0,0 +1,46 @@ +name: Compatibility + +# The legs the PR gate (Test) no longer runs: Windows, macOS, Node 22 and the next DSH. +# Nightly on main, on demand, and on PRs that change them (Actions → Compatibility → Run workflow, any branch). +# Not required for merging; a red night shows here and in the job summary. + +on: + schedule: + - cron: "37 18 * * *" # 02:37 Asia/Shanghai + workflow_dispatch: + # A PR that changes these legs or the shared steps runs them too. + pull_request: + paths: + - .github/workflows/compat.yml + - .github/actions/flybot-tests/** + +permissions: + contents: read + +concurrency: + group: compat-${{ github.ref }} + cancel-in-progress: false + +jobs: + test: + # Prefixed so these never collide with the required "ubuntu-latest · Node 24 · ..." check. + name: compat · ${{ matrix.os }} · Node ${{ matrix.node }} · DSH ${{ matrix.dsh }} + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - { os: windows-latest, node: 24, dsh: 0.2.0-rc.2 } + # FlyBot's users run it on macOS (launchd profiles). + - { os: macos-latest, node: 24, dsh: 0.2.0-rc.2 } + - { os: ubuntu-latest, node: 22, dsh: 0.2.0-rc.2 } + - { os: ubuntu-latest, node: 24, dsh: 0.2.1-alpha.1 } + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/flybot-tests + with: + node: ${{ matrix.node }} + dsh: ${{ matrix.dsh }} 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 12cf91c3..55e71ac6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,42 +6,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: true + 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, macOS, Node 22 and the next DSH run nightly and on demand in compat.yml. test: - name: ${{ matrix.os }} · Node ${{ matrix.node }} · DSH ${{ matrix.dsh }} - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - # Slim PR/push shipgate: one cheap job. - # Dropped from the required matrix (restore later as nightly/manual if needed): - # - 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/setup-node@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/flybot-tests with: - node-version: ${{ matrix.node }} - cache: npm - cache-dependency-path: ds-bot/package-lock.json - - run: npm ci --no-fund --no-audit - - name: Install DSH ${{ matrix.dsh }} - run: npm run harness -- --no-save "@deepseek-ai/dsh@${{ matrix.dsh }}" - - 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 ]