diff --git a/.github/workflows/add-from-issue.yml b/.github/workflows/add-from-issue.yml index 098c2c0..84dac72 100644 --- a/.github/workflows/add-from-issue.yml +++ b/.github/workflows/add-from-issue.yml @@ -14,8 +14,10 @@ jobs: if: contains(github.event.issue.labels.*.name, 'add-repo') runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + # pin: v6.0.0 -- actions/checkout + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + # pin: v6.0.0 -- actions/setup-node + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 with: node-version-file: '.nvmrc' cache: 'npm' diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 971157e..52c6136 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -22,8 +22,10 @@ jobs: matrix: path: ['.', 'mcp', 'cli'] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + # pin: v4.2.2 -- actions/checkout + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + # pin: v4.4.0 -- actions/setup-node + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version-file: '.nvmrc' cache: 'npm' diff --git a/.github/workflows/auto-merge-dependabot.yml b/.github/workflows/auto-merge-dependabot.yml new file mode 100644 index 0000000..a0b3d18 --- /dev/null +++ b/.github/workflows/auto-merge-dependabot.yml @@ -0,0 +1,136 @@ +name: auto-merge-dependabot + +# Dependabot opens dependency-bump PRs across four ecosystems (root npm, +# cli npm, mcp npm, github-actions). The vast majority of those bumps are +# semver patch or minor — there is nothing for a human to review beyond +# "do the tests pass?". This workflow auto-approves and enables auto-merge +# for patch+minor; for major bumps it leaves the PR open for a human and +# adds a comment as a poke. +# +# Safety: +# - We use the OFFICIAL `dependabot/fetch-metadata` action which is the +# supported way to retrieve update-type from Dependabot's PR data. +# Trying to scrape the title / body ourselves is brittle and unsafe. +# - Trigger is pull_request_target so the GITHUB_TOKEN can comment + +# enable auto-merge on PRs opened by the Dependabot bot (whose PRs run +# with read-only GITHUB_TOKEN by default). We never check out or +# execute PR-controlled code in this workflow. +# - Author filter is the load-bearing safety check: the job only runs if +# `github.actor == 'dependabot[bot]'`. + +on: + pull_request_target: + types: [opened, reopened, synchronize, labeled, ready_for_review] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: auto-merge-dependabot-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + auto-merge: + name: Auto-merge dependabot patch/minor + runs-on: ubuntu-latest + if: github.actor == 'dependabot[bot]' + timeout-minutes: 20 + + steps: + - name: Fetch Dependabot metadata + id: meta + # pin: v2.2.0 -- dependabot/fetch-metadata + uses: dependabot/fetch-metadata@dbb049abf0d677abbd7f7eee0375145b417fdd34 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Wait for required checks to succeed + if: >- + steps.meta.outputs.update-type == 'version-update:semver-patch' || + steps.meta.outputs.update-type == 'version-update:semver-minor' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + UPDATE_TYPE: ${{ steps.meta.outputs.update-type }} + DEPENDENCY_NAMES: ${{ steps.meta.outputs.dependency-names }} + run: | + set -euo pipefail + echo "Dependabot update: type=${UPDATE_TYPE} deps=${DEPENDENCY_NAMES}" + # Same required-check list as auto-merge-release-please.yml. Some + # PRs only touch github-actions metadata and won't trigger smoke + # (smoke is path-filtered). We treat "missing" as skipped so we + # don't block on a check that intentionally didn't run. + required=("validate" "smoke / chromium" "smoke / webkit-iphone" "Analyze (javascript-typescript)") + deadline=$(( $(date +%s) + 15 * 60 )) + while :; do + now=$(date +%s) + if [ "$now" -ge "$deadline" ]; then + echo "::warning::Required checks did not all complete in 15m; will retry on next sync event." + exit 0 + fi + all_done=true + any_failed=false + for ctx in "${required[@]}"; do + conclusion=$(gh api "repos/${REPO}/commits/${HEAD_SHA}/check-runs" \ + --jq "[.check_runs[] | select(.name==\"${ctx}\")] | (.[-1].conclusion // \"missing\")") + status=$(gh api "repos/${REPO}/commits/${HEAD_SHA}/check-runs" \ + --jq "[.check_runs[] | select(.name==\"${ctx}\")] | (.[-1].status // \"missing\")") + echo "check '${ctx}': status=${status} conclusion=${conclusion}" + if [ "$status" = "missing" ]; then + continue + fi + if [ "$status" != "completed" ]; then + all_done=false + elif [ "$conclusion" != "success" ] && [ "$conclusion" != "skipped" ] && [ "$conclusion" != "neutral" ]; then + any_failed=true + fi + done + if $any_failed; then + echo "::warning::At least one required check failed; not enabling auto-merge." + exit 0 + fi + if $all_done; then + echo "All required checks completed." + break + fi + sleep 30 + done + + - name: Approve patch/minor PR + if: >- + steps.meta.outputs.update-type == 'version-update:semver-patch' || + steps.meta.outputs.update-type == 'version-update:semver-minor' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + UPDATE_TYPE: ${{ steps.meta.outputs.update-type }} + run: | + gh pr review "$PR_NUMBER" --repo "$REPO" --approve \ + --body "Auto-approved Dependabot ${UPDATE_TYPE} bump; required checks are green." + + - name: Enable auto-merge (squash) for patch/minor + if: >- + steps.meta.outputs.update-type == 'version-update:semver-patch' || + steps.meta.outputs.update-type == 'version-update:semver-minor' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + gh pr merge "$PR_NUMBER" --repo "$REPO" --auto --squash + + - name: Comment on major bumps (human review required) + if: steps.meta.outputs.update-type == 'version-update:semver-major' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + DEPENDENCY_NAMES: ${{ steps.meta.outputs.dependency-names }} + PREVIOUS_VERSION: ${{ steps.meta.outputs.previous-version }} + NEW_VERSION: ${{ steps.meta.outputs.new-version }} + run: | + gh pr comment "$PR_NUMBER" --repo "$REPO" \ + --body "Major version bump detected: ${DEPENDENCY_NAMES} ${PREVIOUS_VERSION} -> ${NEW_VERSION}. Auto-merge is disabled for major bumps; please review the changelog and merge manually if safe." diff --git a/.github/workflows/auto-merge-release-please.yml b/.github/workflows/auto-merge-release-please.yml new file mode 100644 index 0000000..835f9ff --- /dev/null +++ b/.github/workflows/auto-merge-release-please.yml @@ -0,0 +1,114 @@ +name: auto-merge-release-please + +# release-please opens a per-component Release PR (chore(): release ) +# whose contents are purely mechanical: a version bump in package.json / +# pyproject.toml, an updated .release-please-manifest.json, and an appended +# CHANGELOG.md entry. There is nothing here for a human to review beyond +# "did the bot do what the bot is supposed to do?" +# +# This workflow waits for the required status checks to land green on the +# PR head, auto-approves with the GH bot, and enables auto-merge in squash +# mode. Merging the squashed commit creates the component tag +# (e.g. `cli-v0.1.3`) which fires the existing `publish-*.yml` workflows. +# +# Safety: +# - Trigger is pull_request_target (needed so the GITHUB_TOKEN has write +# scope on PRs from forks), but we ONLY auto-approve PRs authored by +# release-please[bot] AND we never check out or execute PR-controlled +# code in this workflow. The only operations are GitHub API calls. +# - All untrusted event values (PR number, head SHA, login) are read via +# env: bindings and only referenced as quoted shell vars — never +# interpolated into a run: body directly. +# - The required status checks list matches the repo's branch-protection +# contexts (validate, smoke/chromium, smoke/webkit-iphone, codeql); we +# re-check them explicitly here as defense-in-depth in case branch +# protection is loosened in the future. + +on: + pull_request_target: + types: [opened, reopened, synchronize, labeled, ready_for_review] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: auto-merge-release-please-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + auto-merge: + name: Auto-merge release-please PR + runs-on: ubuntu-latest + # Author filter is the load-bearing safety check. release-please runs as + # `release-please[bot]` on the GitHub App, or as `github-actions[bot]` + # when triggered via workflow_dispatch through the action. We accept + # both, but we ALSO never check out PR contents in this workflow. + if: >- + github.event.pull_request.user.login == 'release-please[bot]' || + github.event.pull_request.user.login == 'github-actions[bot]' + timeout-minutes: 20 + + steps: + - name: Wait for required checks to succeed + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + # The four required checks (validate is a check-run, smoke has + # two matrix jobs, codeql is the security analysis). If any of + # these is still pending after the timeout we exit non-zero so + # the workflow re-runs on the next sync event. + required=("validate" "smoke / chromium" "smoke / webkit-iphone" "Analyze (javascript-typescript)") + deadline=$(( $(date +%s) + 15 * 60 )) + while :; do + now=$(date +%s) + if [ "$now" -ge "$deadline" ]; then + echo "::warning::Required checks did not all complete in 15m; will retry on next sync event." + exit 0 + fi + all_done=true + any_failed=false + for ctx in "${required[@]}"; do + # The latest run of each named check on the head sha. + conclusion=$(gh api "repos/${REPO}/commits/${HEAD_SHA}/check-runs" \ + --jq "[.check_runs[] | select(.name==\"${ctx}\")] | (.[-1].conclusion // \"missing\")") + status=$(gh api "repos/${REPO}/commits/${HEAD_SHA}/check-runs" \ + --jq "[.check_runs[] | select(.name==\"${ctx}\")] | (.[-1].status // \"missing\")") + echo "check '${ctx}': status=${status} conclusion=${conclusion}" + if [ "$status" != "completed" ]; then + all_done=false + elif [ "$conclusion" != "success" ] && [ "$conclusion" != "skipped" ] && [ "$conclusion" != "neutral" ]; then + any_failed=true + fi + done + if $any_failed; then + echo "::warning::At least one required check failed; not enabling auto-merge." + exit 0 + fi + if $all_done; then + echo "All required checks completed successfully." + break + fi + sleep 30 + done + + - name: Approve PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + gh pr review "$PR_NUMBER" --repo "$REPO" --approve \ + --body "Auto-approved release-please PR. Mechanical version bump + changelog only; required checks are green." + + - name: Enable auto-merge (squash) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + gh pr merge "$PR_NUMBER" --repo "$REPO" --auto --squash diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml index 13f6bc4..64c3e26 100644 --- a/.github/workflows/auto-merge.yml +++ b/.github/workflows/auto-merge.yml @@ -13,7 +13,8 @@ jobs: auto-merge: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + # pin: v6.0.0 -- actions/checkout + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 with: fetch-depth: 0 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4b1c8b2..fc9d905 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -44,11 +44,13 @@ jobs: language: ["javascript-typescript"] steps: + # pin: v6.0.0 -- actions/checkout - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + # pin: v4.35.4 -- github/codeql-action - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e with: languages: ${{ matrix.language }} queries: security-and-quality @@ -58,7 +60,8 @@ jobs: - "**/node_modules/**" - "**/dist/**" + # pin: v4.35.4 -- github/codeql-action - name: Perform CodeQL analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/docs-on-release.yml b/.github/workflows/docs-on-release.yml index 44a7096..149233d 100644 --- a/.github/workflows/docs-on-release.yml +++ b/.github/workflows/docs-on-release.yml @@ -16,11 +16,13 @@ jobs: update-docs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + # pin: v6.0.0 -- actions/checkout + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 with: token: ${{ secrets.GITHUB_TOKEN }} fetch-depth: 1 - - uses: actions/setup-node@v6 + # pin: v6.0.0 -- actions/setup-node + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 with: node-version-file: '.nvmrc' diff --git a/.github/workflows/node-matrix.yml b/.github/workflows/node-matrix.yml index a722546..b736768 100644 --- a/.github/workflows/node-matrix.yml +++ b/.github/workflows/node-matrix.yml @@ -24,9 +24,11 @@ jobs: matrix: node-version: [18.x, 20.x, 22.x] steps: - - uses: actions/checkout@v6 + # pin: v6.0.0 -- actions/checkout + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + # pin: v6.0.0 -- actions/setup-node - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v6 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 with: node-version: ${{ matrix.node-version }} cache: "npm" diff --git a/.github/workflows/outdated-watch.yml b/.github/workflows/outdated-watch.yml new file mode 100644 index 0000000..7c492c5 --- /dev/null +++ b/.github/workflows/outdated-watch.yml @@ -0,0 +1,175 @@ +name: outdated-watch + +# Nightly nudge for transitively-outdated deps that Dependabot's grouping +# might paper over. Posts (or updates) a single tracking issue summarising +# what's behind in each of the four package manifests. Fail-soft: a +# network blip or empty `npm outdated` result is not a failure, just a +# no-op run. +# +# Cost: one ubuntu-latest run, < 90 seconds. Free-tier friendly. +# Skip-condition: opens nothing if every ecosystem is up-to-date. + +on: + schedule: + - cron: '0 7 * * 1' # Monday 07:00 UTC, one day after the audit job + workflow_dispatch: + +permissions: + contents: read + issues: write + +concurrency: + group: outdated-watch + cancel-in-progress: true + +jobs: + scan: + name: Scan + post outdated summary + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + # pin: v6.0.0 -- actions/checkout + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + + # pin: v6.0.0 -- actions/setup-node + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 + with: + node-version-file: '.nvmrc' + + # pin: v6.0.0 -- actions/setup-python + - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c + with: + python-version: '3.12' + + - name: Collect npm outdated (root) + run: | + set -e + out=$(npm outdated --json --long 2>/dev/null || true) + echo "$out" > /tmp/npm-root.json + test -s /tmp/npm-root.json || echo '{}' > /tmp/npm-root.json + + - name: Collect npm outdated (cli) + working-directory: cli + run: | + set -e + out=$(npm outdated --json --long 2>/dev/null || true) + echo "$out" > /tmp/npm-cli.json + test -s /tmp/npm-cli.json || echo '{}' > /tmp/npm-cli.json + + - name: Collect npm outdated (mcp) + working-directory: mcp + run: | + set -e + out=$(npm outdated --json --long 2>/dev/null || true) + echo "$out" > /tmp/npm-mcp.json + test -s /tmp/npm-mcp.json || echo '{}' > /tmp/npm-mcp.json + + - name: Collect pip outdated (python-sdk) + working-directory: python-sdk + run: | + set -e + python -m pip install --quiet --upgrade pip + # Install the project's runtime + dev deps as defined in + # pyproject.toml, then ask pip for outdated. `|| true` so a + # missing optional extra never fails the scan. + python -m pip install --quiet -e ".[dev]" 2>/dev/null || python -m pip install --quiet -e . || true + python -m pip list --outdated --format=json > /tmp/pip-pysdk.json || echo '[]' > /tmp/pip-pysdk.json + + - name: Build summary + id: build + run: | + set -e + node -e ' + const fs = require("fs"); + function npmRows(label, path) { + let j; + try { j = JSON.parse(fs.readFileSync(path, "utf8")); } + catch (_e) { return []; } + return Object.entries(j).map(([name, info]) => ({ + eco: "npm", + scope: label, + name, + current: info.current || "", + wanted: info.wanted || "", + latest: info.latest || "", + type: info.type || "", + })); + } + function pipRows(label, path) { + let arr; + try { arr = JSON.parse(fs.readFileSync(path, "utf8")); } + catch (_e) { return []; } + return (Array.isArray(arr) ? arr : []).map(p => ({ + eco: "pip", + scope: label, + name: p.name, + current: p.version, + wanted: p.latest_version, + latest: p.latest_version, + type: p.latest_filetype || "", + })); + } + const rows = [ + ...npmRows("root", "/tmp/npm-root.json"), + ...npmRows("cli", "/tmp/npm-cli.json"), + ...npmRows("mcp", "/tmp/npm-mcp.json"), + ...pipRows("python-sdk", "/tmp/pip-pysdk.json"), + ]; + // Filter: only surface entries that are >= 1 minor behind. Patch-only + // drift is already handled by Dependabot weekly schedule. + function isAtLeastMinorBehind(cur, latest) { + if (!cur || !latest) return false; + const c = cur.replace(/[^0-9.].*$/, "").split(".").map(n => parseInt(n, 10)); + const l = latest.replace(/[^0-9.].*$/, "").split(".").map(n => parseInt(n, 10)); + if (c.length < 2 || l.length < 2) return false; + if (l[0] > c[0]) return true; + if (l[0] === c[0] && l[1] > c[1]) return true; + return false; + } + const interesting = rows.filter(r => isAtLeastMinorBehind(r.current, r.latest)); + if (interesting.length === 0) { + fs.writeFileSync("/tmp/summary.md", ""); + process.exit(0); + } + const lines = [ + "## Outdated dependency summary", + "", + "Automated weekly scan. Filtering to packages at least one **minor** version behind (patch drift is handled by Dependabot).", + "", + "| Ecosystem | Scope | Package | Current | Latest | Type |", + "|---|---|---|---|---|---|", + ]; + for (const r of interesting) { + lines.push("| " + [r.eco, r.scope, r.name, r.current, r.latest, r.type].join(" | ") + " |"); + } + lines.push(""); + lines.push("> Auto-generated by `.github/workflows/outdated-watch.yml`. Close this issue once everything in the table is bumped; the next run will reopen it if anything regresses."); + fs.writeFileSync("/tmp/summary.md", lines.join("\n")); + ' + if [ -s /tmp/summary.md ]; then + echo "has_summary=true" >> "$GITHUB_OUTPUT" + else + echo "has_summary=false" >> "$GITHUB_OUTPUT" + fi + + - name: Upsert tracking issue + if: steps.build.outputs.has_summary == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + TITLE: "chore(deps): outdated dependency summary" + run: | + set -euo pipefail + # Find an open issue with this title (use search rather than list to + # avoid pagination corner-cases on busy repos). + existing=$(gh issue list --repo "$REPO" --state open --search "in:title \"$TITLE\"" --json number --jq '.[0].number // empty') + if [ -n "$existing" ]; then + gh issue edit "$existing" --repo "$REPO" --body-file /tmp/summary.md + echo "updated #$existing" + else + gh issue create --repo "$REPO" \ + --title "$TITLE" \ + --label "dependencies" \ + --body-file /tmp/summary.md \ + || gh issue create --repo "$REPO" --title "$TITLE" --body-file /tmp/summary.md + fi diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 929d5f1..b2d1dd9 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -25,9 +25,11 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + # pin: v6.0.0 -- actions/checkout + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 - - uses: actions/setup-node@v6 + # pin: v6.0.0 -- actions/setup-node + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 with: node-version-file: '.nvmrc' @@ -83,7 +85,8 @@ jobs: [ -f "$f" ] && sed -i 's/PLACEHOLDER_TOKEN/'"$CF_BEACON_TOKEN"'/g' "$f" done - - uses: actions/upload-pages-artifact@v5 + # pin: v5.0.0 -- actions/upload-pages-artifact + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 with: path: _site @@ -94,5 +97,6 @@ jobs: name: github-pages url: ${{ steps.deploy.outputs.page_url }} steps: + # pin: v5.0.0 -- actions/deploy-pages - id: deploy - uses: actions/deploy-pages@v5 + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index 66e45f6..ac4231e 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -17,9 +17,11 @@ jobs: run: working-directory: cli steps: - - uses: actions/checkout@v6 + # pin: v6.0.0 -- actions/checkout + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 - - uses: actions/setup-node@v6 + # pin: v6.0.0 -- actions/setup-node + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 with: node-version-file: '.nvmrc' registry-url: 'https://registry.npmjs.org/' diff --git a/.github/workflows/publish-mcp.yml b/.github/workflows/publish-mcp.yml index 7cb2d81..0c33f26 100644 --- a/.github/workflows/publish-mcp.yml +++ b/.github/workflows/publish-mcp.yml @@ -17,8 +17,10 @@ jobs: run: working-directory: mcp steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + # pin: v6.0.0 -- actions/checkout + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + # pin: v6.0.0 -- actions/setup-node + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 with: node-version-file: '.nvmrc' registry-url: 'https://registry.npmjs.org/' diff --git a/.github/workflows/publish-pysdk.yml b/.github/workflows/publish-pysdk.yml index 501850a..950e6d7 100644 --- a/.github/workflows/publish-pysdk.yml +++ b/.github/workflows/publish-pysdk.yml @@ -4,6 +4,18 @@ name: publish-pysdk # `pysdk-vX.Y.Z`. Using a published-release trigger so the release # notes are already in place when artifacts upload, and so the # sdist/wheel attach to the release page itself. +# +# Publishing path: +# 1. Preferred: PyPI Trusted Publishing via OIDC. No token; the +# `pypa/gh-action-pypi-publish` step exchanges the workflow's OIDC +# ID-token for a short-lived PyPI upload token. Requires a Trusted +# Publisher entry on PyPI ("understand-quickly" project) bound to +# this exact workflow file. See docs/ops/pypi-trusted-publishing.md. +# 2. Fallback: legacy PYPI_API_TOKEN secret. Engaged only if Trusted +# Publishing is not yet configured (the OIDC step will fail with +# "Trusted publisher not configured" which we tolerate via +# continue-on-error). Once the user has provisioned Trusted +# Publishing, the fallback step can be deleted along with the secret. on: release: @@ -17,7 +29,7 @@ on: permissions: contents: write # upload assets onto the GitHub release - id-token: write # reserved if we ever switch to trusted-publishing + id-token: write # required for PyPI Trusted Publishing (OIDC) jobs: build-and-publish: @@ -30,17 +42,20 @@ jobs: working-directory: python-sdk steps: - - uses: actions/checkout@v4 + # pin: v6.0.0 -- actions/checkout + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 with: ref: ${{ github.event.inputs.ref || github.ref }} + # pin: v6.0.0 -- actions/setup-python - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c with: python-version: '3.12' + # pin: v6.0.0 -- actions/setup-node - name: Set up Node (for version check) - uses: actions/setup-node@v6 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 with: node-version-file: '.nvmrc' @@ -59,26 +74,45 @@ jobs: - name: Twine check run: python -m twine check dist/* + # pin: v4.6.2 -- actions/upload-artifact - name: Upload artifacts to workflow - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: python-sdk-dist path: python-sdk/dist/* if-no-files-found: error + # pin: v2.2.1 -- softprops/action-gh-release - name: Attach wheel + sdist to GitHub release if: github.event_name == 'release' - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda with: files: python-sdk/dist/* - - name: Publish to PyPI (skip cleanly if PYPI_API_TOKEN unset) + # Preferred path: PyPI Trusted Publishing (OIDC). The action will + # short-circuit if the project isn't configured for trusted publishing + # on PyPI's side, in which case we drop through to the token fallback. + # pin: v1.12.4 -- pypa/gh-action-pypi-publish + - name: Publish to PyPI (Trusted Publishing / OIDC) + id: pypi_oidc + if: github.event_name == 'release' + continue-on-error: true + uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc + with: + packages-dir: python-sdk/dist + skip-existing: true + + # Fallback path: legacy API token. Only runs if OIDC step failed. + # Once Trusted Publishing is provisioned on PyPI, delete this step + # and the PYPI_API_TOKEN secret. + - name: Publish to PyPI (API token fallback) + if: github.event_name == 'release' && steps.pypi_oidc.outcome != 'success' env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} run: | if [ -z "${TWINE_PASSWORD}" ]; then - echo "::warning::PYPI_API_TOKEN secret is not set; skipping PyPI publish. Set PYPI_API_TOKEN on the repo to enable publishing." + echo "::warning::OIDC trusted publishing not configured and PYPI_API_TOKEN is unset; nothing published. See docs/ops/pypi-trusted-publishing.md to enable OIDC." exit 0 fi python -m twine upload --skip-existing dist/* diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 3e456b4..7694776 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -18,7 +18,8 @@ jobs: release: runs-on: ubuntu-latest steps: - - uses: googleapis/release-please-action@v4 + # pin: v4.1.5 -- googleapis/release-please-action + - uses: googleapis/release-please-action@5792afc6b46e9bb55deda9eda973a18c226bc3fc with: config-file: release-please-config.json manifest-file: .release-please-manifest.json diff --git a/.github/workflows/render.yml b/.github/workflows/render.yml index 130ed17..dd50ec2 100644 --- a/.github/workflows/render.yml +++ b/.github/workflows/render.yml @@ -13,8 +13,10 @@ jobs: if: github.actor != 'github-actions[bot]' || !startsWith(github.event.head_commit.message, 'docs(readme):') runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + # pin: v6.0.0 -- actions/checkout + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + # pin: v6.0.0 -- actions/setup-node + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 with: node-version-file: '.nvmrc' cache: 'npm' diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml new file mode 100644 index 0000000..452d0ef --- /dev/null +++ b/.github/workflows/secret-scan.yml @@ -0,0 +1,43 @@ +name: secret-scan + +# Diff-focused secret scan via TruffleHog (free for org repos; gitleaks- +# action requires a paid license for org-owned repos). Complements Socket +# Security (supply-chain focused, not secret-in-diff focused). +# +# On PRs: scans only the commit range between base and head — fast. +# On push to main: scans full history of the new commits since last push, +# which is what GitHub passes by default. + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: secret-scan-${{ github.ref }} + cancel-in-progress: true + +jobs: + trufflehog: + name: TruffleHog OSS scan + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + # pin: v6.0.0 -- actions/checkout + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + with: + # Full history so the scan can compare base..head (PRs) or walk + # the new commits (push). TruffleHog needs both endpoints. + fetch-depth: 0 + + # pin: v3.95.2 -- trufflesecurity/trufflehog + - name: Run TruffleHog + uses: trufflesecurity/trufflehog@17456f8c7d042d8c82c9a8ca9e937231f9f42e26 + with: + # `base` is unset on push events; the action computes the + # commit range itself in that case. On PR events the action + # uses base + head from the event payload. + extra_args: --only-verified diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 6021edc..33ad702 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -16,9 +16,11 @@ jobs: playwright: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + # pin: v6.0.0 -- actions/checkout + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 - - uses: actions/setup-node@v6 + # pin: v6.0.0 -- actions/setup-node + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 with: node-version-file: '.nvmrc' @@ -31,9 +33,10 @@ jobs: - name: Run smoke suite run: npm run smoke:browser + # pin: v7.0.0 -- actions/upload-artifact - name: Upload Playwright HTML report if: failure() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f with: name: playwright-report path: tests/playwright/report diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml index 3d33b12..4f8f95d 100644 --- a/.github/workflows/sync.yml +++ b/.github/workflows/sync.yml @@ -29,8 +29,10 @@ jobs: sync: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + # pin: v6.0.0 -- actions/checkout + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 + # pin: v6.0.0 -- actions/setup-node + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 with: node-version-file: '.nvmrc' cache: 'npm' diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 2873239..343f4bb 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -16,10 +16,12 @@ jobs: validate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + # pin: v6.0.0 -- actions/checkout + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 with: fetch-depth: 2 - - uses: actions/setup-node@v6 + # pin: v6.0.0 -- actions/setup-node + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 with: node-version-file: '.nvmrc' cache: 'npm' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9812a0e..ec18803 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -126,6 +126,50 @@ A "format" is a versioned JSON Schema in `schemas/`. Adding one means downstream --- +## Commit message conventions + +This repo uses [Conventional Commits][cc] to drive automated releases via +`release-please`. The commit prefix on `main` decides which component +(if any) bumps and by how much: + +| Prefix | Bump | Use for | +|---|---|---| +| `fix(cli): ...` | cli **patch** | bug fix in `cli/` | +| `fix(mcp): ...` | mcp **patch** | bug fix in `mcp/` | +| `fix(pysdk): ...` | pysdk **patch** | bug fix in `python-sdk/` | +| `feat(cli): ...` | cli **minor** (or patch pre-1.0; see `release-please-config.json`) | new feature in `cli/` | +| `feat(mcp): ...` | mcp **minor** | new feature in `mcp/` | +| `feat(pysdk): ...` | pysdk **minor** | new feature in `python-sdk/` | +| `feat!: ...` or `BREAKING CHANGE:` footer | **major** | any backwards-incompatible change | +| `chore:`, `docs:`, `ci:`, `refactor:`, `test:`, `style:` | no bump | maintenance | + +Scopes follow the component directories: `cli`, `mcp`, `pysdk`, plus +`root` / `registry` for changes to the registry data itself. A commit +with no scope (e.g. `feat: ...`) applies to the root package only. + +**Examples:** + +``` +fix(cli): handle empty graph_url in --graph mode +feat(mcp): add list_concepts MCP tool +chore(deps-cli): bump zod from 3.22.4 to 3.23.0 +feat(pysdk)!: drop Python 3.10 support + +BREAKING CHANGE: minimum supported Python is now 3.11. +``` + +A landed commit on `main` is the trigger; the +[`release-please`](./docs/ops/release-process.md) workflow opens a per- +component Release PR within ~30s. That PR auto-merges once required +checks pass, which creates the version tag, which triggers the +`publish-*.yml` workflows. No manual `npm publish` / `twine upload` +needed. + +The `semantic-pr` workflow enforces a Conventional Commit-shaped PR +title on every PR. + +[cc]: https://www.conventionalcommits.org/en/v1.0.0/ + ## 3. Local development ```bash diff --git a/docs/ops/pypi-trusted-publishing.md b/docs/ops/pypi-trusted-publishing.md new file mode 100644 index 0000000..937180e --- /dev/null +++ b/docs/ops/pypi-trusted-publishing.md @@ -0,0 +1,70 @@ +# PyPI Trusted Publishing (OIDC) + +`publish-pysdk.yml` is configured to publish via [PyPI Trusted Publishing][tp] +— PyPI exchanges a short-lived GitHub OIDC token for an upload token, so +**no `PYPI_API_TOKEN` secret is needed** once it's set up. The workflow +keeps a token-based fallback so this transition can be done at the +maintainer's convenience. + +[tp]: https://docs.pypi.org/trusted-publishers/ + +## What you need to do once (PyPI side) + +1. Sign in to . + - If the project hasn't been published yet, you'll need to register + it first by either (a) publishing an initial release with a normal + API token, then adding the trusted publisher; or (b) using the + "Add a pending publisher" flow at + so the first release + can land via OIDC. +2. **Add a new pending publisher** (or "Add publisher" if the project + already exists) with these exact values: + + | Field | Value | + |---|---| + | PyPI Project Name | `understand-quickly` | + | Owner | `looptech-ai` | + | Repository name | `understand-quickly` | + | Workflow filename | `publish-pysdk.yml` | + | Environment name | *(leave blank)* | + +3. Save. The publisher is active immediately. + +## Verifying the wiring + +Trigger a dry-run by re-running the most recent `publish-pysdk` run from +the Actions tab (or `gh workflow run publish-pysdk.yml`). The +`Publish to PyPI (Trusted Publishing / OIDC)` step should succeed; the +fallback step should be skipped. If the OIDC step exits with +`Trusted publishing exchange failure`, the publisher binding on PyPI +isn't matching — double-check the four fields above. + +## Removing the token fallback + +Once you've confirmed a successful OIDC publish on a real release: + +1. Delete the `Publish to PyPI (API token fallback)` step from + `.github/workflows/publish-pysdk.yml`. +2. Remove the `PYPI_API_TOKEN` secret at + . +3. Remove `permissions: contents: write` if you also remove the GitHub + release asset upload (keep it if you want artifacts attached to the + Release page). + +## Why bother? + +- **No secrets to rotate.** Token rotation, accidental leak, expiry — + all gone. +- **Short-lived credentials.** The token PyPI returns lives ~15 minutes. + A leak in workflow logs is meaningfully less catastrophic than a leak + of a long-lived `pypi-AgEIcHlwaS5vcm...` token. +- **Bound to a specific workflow file.** Even if someone gets repo + write access, they can't change the workflow file path and publish + without also re-registering the publisher on PyPI. + +## References + +- PyPI Trusted Publishing docs: +- `pypa/gh-action-pypi-publish` action: +- GitHub OIDC for Actions: + diff --git a/docs/ops/release-process.md b/docs/ops/release-process.md index d5462de..f33d47d 100644 --- a/docs/ops/release-process.md +++ b/docs/ops/release-process.md @@ -38,10 +38,18 @@ drive the bump: - The version bump in `package.json` / `pyproject.toml`. - A `.release-please-manifest.json` update. - A generated `CHANGELOG.md` entry. -3. **Review + merge** the Release PR. release-please will: - - Create the tag `-v` automatically. - - For `pysdk`, also create a GitHub Release (required by - `publish-pysdk.yml` which triggers on `release: published`). +3. **Review + merge** the Release PR. As of `ci/auto-merge-bots` this is + handled automatically: + - `.github/workflows/auto-merge-release-please.yml` waits for the + required status checks (`validate`, `smoke / chromium`, + `smoke / webkit-iphone`, `Analyze (javascript-typescript)`) to land + green, then auto-approves the PR and enables **squash auto-merge**. + - Once GitHub squash-merges the PR, release-please creates the tag + `-v` automatically. + - For `pysdk`, release-please also creates a GitHub Release (required + by `publish-pysdk.yml` which triggers on `release: published`). + - If you ever need to override, manual merge still works — the + auto-merge workflow is purely additive. 4. **`publish-*.yml` fires** on the new tag/release: - `publish-cli.yml` — tag-trigger, runs `npm publish` after `scripts/check-versions.mjs --tag` regression guard. @@ -162,8 +170,41 @@ The `docs-on-release` workflow runs on every `release: published` event and on ` The workflow never fails — if any registry is unreachable, it falls back to the previous README content and exits 0. To force a regeneration manually: `gh workflow run docs-on-release.yml --repo looptech-ai/understand-quickly`. +## Dependabot auto-merge + +`.github/workflows/auto-merge-dependabot.yml` handles dependency-update +PRs from Dependabot: + +| Update type | Behaviour | +|---|---| +| `version-update:semver-patch` | auto-approve + squash auto-merge once checks pass | +| `version-update:semver-minor` | auto-approve + squash auto-merge once checks pass | +| `version-update:semver-major` | leave open; bot comments on the PR pointing at the changelog | + +The workflow uses the official `dependabot/fetch-metadata` action to +identify the update type — never scrapes the PR title. PRs from any +actor other than `dependabot[bot]` are skipped. + +## Outdated-dep watcher + +`.github/workflows/outdated-watch.yml` runs every Monday at 07:00 UTC +and posts (or updates) a single tracking issue +`chore(deps): outdated dependency summary` listing any package across +the four manifests (root npm, cli, mcp, python-sdk) that is at least one +**minor** version behind. Patch drift is intentionally filtered out — +Dependabot handles that on its weekly schedule. + +## PyPI Trusted Publishing + +`publish-pysdk.yml` prefers OIDC trusted publishing over the legacy +`PYPI_API_TOKEN` secret. See +[`pypi-trusted-publishing.md`](pypi-trusted-publishing.md) for the +one-time setup. The token fallback remains in place until you remove it +so the transition is risk-free. + ## See also - [`npm-org-setup.md`](npm-org-setup.md) — one-time npm org + token setup. +- [`pypi-trusted-publishing.md`](pypi-trusted-publishing.md) — OIDC setup for PyPI. - [`../../CHANGELOG.md`](../../CHANGELOG.md) — human-curated changelog (release-please appends). - [release-please action docs](https://github.com/googleapis/release-please-action).