diff --git a/.github/actions/feishu-pr-notification/index.cjs b/.github/actions/feishu-pr-notification/index.cjs deleted file mode 100644 index 3cc6abfe..00000000 --- a/.github/actions/feishu-pr-notification/index.cjs +++ /dev/null @@ -1,151 +0,0 @@ -const crypto = require("node:crypto"); -const fs = require("node:fs"); - -const FIELD_LIMIT = 256; -const REVIEWER_LIMIT = 128; -const REVIEWERS_LIMIT = 512; -const URL_LIMIT = 512; -const CONTROL_OR_LINE_SEPARATOR = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u; -const BIDI_CONTROL = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u; - -function truncateCodePoints(value, maxCodePoints) { - const points = Array.from(value); - if (points.length <= maxCodePoints) return value; - if (maxCodePoints <= 1) return "…"; - return `${points.slice(0, maxCodePoints - 1).join("")}…`; -} - -function isEncodedAngleBracket(value) { - return /^&(?:lt;?|gt;?|#0*(?:60|62)(?:;|(?![0-9]))|#x0*(?:3c|3e)(?:;|(?![0-9a-f])))/iu.test( - value, - ); -} - -/** Project untrusted PR metadata into one bounded Feishu plain-text field. */ -function sanitizeFeishuField(value, maxCodePoints = FIELD_LIMIT) { - const points = Array.from(String(value ?? "")) - .filter((point) => !BIDI_CONTROL.test(point)) - .map((point) => (CONTROL_OR_LINE_SEPARATOR.test(point) ? " " : point)); - // Keep benign Unicode and ordinary ampersands byte-for-byte. The parallel - // NFKC projection is used only to identify compatibility characters that a - // downstream renderer could turn into Feishu markup or an angle-bracket - // entity. Replacement characters remain non-ASCII under NFKC/NFKD. - const compatibility = points.map((point) => point.normalize("NFKC")); - const safe = points.map((point, index) => { - if (compatibility[index] === "<") return "‹"; - if (compatibility[index] === ">") return "›"; - if ( - compatibility[index] === "&" && - isEncodedAngleBracket(compatibility.slice(index).join("")) - ) { - return "⅋"; - } - return point; - }); - return truncateCodePoints( - safe.join("").replace(/\s+/gu, " ").trim(), - maxCodePoints, - ); -} - -function formatNotificationText(event, repository) { - const pr = event.pull_request; - const author = sanitizeFeishuField(pr.user?.login ?? "unknown"); - const reviewers = [ - ...(pr.requested_reviewers ?? []).map((reviewer) => - sanitizeFeishuField(reviewer.login, REVIEWER_LIMIT), - ), - ...(pr.requested_teams ?? []).map( - (team) => `team/${sanitizeFeishuField(team.slug, REVIEWER_LIMIT)}`, - ), - ].filter(Boolean); - const reviewerText = - reviewers.length > 0 - ? sanitizeFeishuField(reviewers.join(", "), REVIEWERS_LIMIT) - : "未指定"; - const lines = [ - `${sanitizeFeishuField(repository)} 有新的 PR`, - `#${pr.number} ${sanitizeFeishuField(pr.title)}`, - `作者:${author}`, - `审阅人:${reviewerText}`, - `分支:${sanitizeFeishuField(pr.head.label)} -> ${sanitizeFeishuField(pr.base.ref)}`, - `PR 链接:${sanitizeFeishuField(pr.html_url, URL_LIMIT)}`, - ]; - return lines.join("\n"); -} - -function isSuccessfulResponse(payload) { - return ( - payload !== null && - typeof payload === "object" && - (payload.code === 0 || payload.StatusCode === 0) - ); -} - -async function sendNotification({ - event, - repository, - webhook, - secret, - now = Date.now, -}) { - const timestamp = String(Math.floor(now() / 1_000)); - const stringToSign = `${timestamp}\n${secret}`; - const sign = crypto - .createHmac("sha256", stringToSign) - .update("") - .digest("base64"); - const response = await fetch(webhook, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - timestamp, - sign, - msg_type: "text", - content: { text: formatNotificationText(event, repository) }, - }), - }); - const text = await response.text(); - - if (!response.ok) { - throw new Error(`Feishu webhook returned ${response.status}: ${text}`); - } - - let payload; - try { - payload = JSON.parse(text); - } catch { - throw new Error( - `Feishu webhook returned an invalid JSON response: ${text}`, - ); - } - - if (!isSuccessfulResponse(payload)) { - throw new Error(`Feishu webhook failed: ${text}`); - } -} - -async function main() { - const event = JSON.parse(fs.readFileSync(process.env.EVENT_PATH, "utf8")); - await sendNotification({ - event, - repository: process.env.REPOSITORY, - webhook: process.env.FEISHU_PR_BOT_WEBHOOK, - secret: process.env.FEISHU_PR_BOT_SECRET, - }); - console.log("Feishu PR notification sent."); -} - -if (require.main === module) { - main().catch((error) => { - console.error(error); - process.exit(1); - }); -} - -module.exports = { - formatNotificationText, - isSuccessfulResponse, - sanitizeFeishuField, - sendNotification, -}; diff --git a/.github/workflows/feishu-pr-notification.yml b/.github/workflows/feishu-pr-notification.yml index 01519d0e..4a295680 100644 --- a/.github/workflows/feishu-pr-notification.yml +++ b/.github/workflows/feishu-pr-notification.yml @@ -11,30 +11,7 @@ permissions: jobs: notify: name: Notify Feishu - runs-on: ubuntu-latest - timeout-minutes: 5 - if: ${{ !github.event.pull_request.draft }} - steps: - - name: Check out trusted workflow source - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - with: - ref: ${{ github.workflow_sha }} - persist-credentials: false - - name: Send PR notification - env: - FEISHU_PR_BOT_WEBHOOK: ${{ secrets.FEISHU_PR_BOT_WEBHOOK }} - FEISHU_PR_BOT_SECRET: ${{ secrets.FEISHU_PR_BOT_SECRET }} - REPOSITORY: ${{ github.repository }} - EVENT_PATH: ${{ github.event_path }} - run: | - if test -z "${FEISHU_PR_BOT_WEBHOOK}" && test -z "${FEISHU_PR_BOT_SECRET}"; then - echo "Feishu bot secrets are not configured; skipping notification." - exit 0 - fi - - if test -z "${FEISHU_PR_BOT_WEBHOOK}" || test -z "${FEISHU_PR_BOT_SECRET}"; then - echo "Both FEISHU_PR_BOT_WEBHOOK and FEISHU_PR_BOT_SECRET must be configured." - exit 1 - fi - - node .github/actions/feishu-pr-notification/index.cjs + uses: openpi-dev/automation/.github/workflows/openpi-feishu-pr-notification.yml@main + secrets: + FEISHU_PR_BOT_WEBHOOK: ${{ secrets.FEISHU_PR_BOT_WEBHOOK }} + FEISHU_PR_BOT_SECRET: ${{ secrets.FEISHU_PR_BOT_SECRET }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f8c61854..84e56d06 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,91 +17,11 @@ concurrency: permissions: contents: read + id-token: write jobs: - validate: - name: Validate release - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - name: Resolve release source - id: source - env: - DISPATCH_TAG: ${{ inputs.tag }} - EVENT_NAME: ${{ github.event_name }} - REF_NAME: ${{ github.ref_name }} - REF: ${{ github.ref }} - run: | - if test "${EVENT_NAME}" = "workflow_dispatch"; then - test "${REF}" = "refs/heads/main" - release_tag="${DISPATCH_TAG}" - else - release_tag="${REF_NAME}" - fi - git check-ref-format "refs/tags/${release_tag}" - case "${release_tag}" in - v*.*.*) ;; - *) exit 1 ;; - esac - printf 'tag=%s\n' "${release_tag}" >>"${GITHUB_OUTPUT}" - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - with: - fetch-depth: 0 - ref: ${{ steps.source.outputs.tag }} - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 - with: - node-version: 24 - registry-url: https://registry.npmjs.org - package-manager-cache: false - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - with: - bun-version: 1.3.14 - - name: Install dependencies - run: bun install --frozen-lockfile - - run: bun run check - - run: bun run test - - name: Verify tagged release source - env: - RELEASE_TAG: ${{ steps.source.outputs.tag }} - run: | - version="$(node -p "require('./package.json').version")" - test "${RELEASE_TAG}" = "v${version}" - git fetch --no-tags origin main:refs/remotes/origin/main - git merge-base --is-ancestor HEAD origin/main - - name: Verify package contents - run: npm pack --dry-run --ignore-scripts - - name: Build release artifact - run: | - mkdir release-artifact - npm pack --ignore-scripts --pack-destination release-artifact - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: npm-package - path: release-artifact/*.tgz - if-no-files-found: error - retention-days: 1 - - publish: - name: Publish to npm - needs: validate - runs-on: ubuntu-latest - timeout-minutes: 5 - environment: npm - permissions: - contents: read - id-token: write - steps: - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 - with: - node-version: 24 - registry-url: https://registry.npmjs.org - package-manager-cache: false - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: npm-package - path: release-artifact - - name: Publish package - run: | - mapfile -t packages < <(find release-artifact -maxdepth 1 -type f -name '*.tgz' -print) - test "${#packages[@]}" -eq 1 - npm publish "${packages[0]}" --ignore-scripts --access public + release: + name: Release + uses: openpi-dev/automation/.github/workflows/openpi-release.yml@main + with: + tag: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} diff --git a/RELEASING.md b/RELEASING.md index 8600040d..c7b1659f 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,6 +1,6 @@ # Releasing OpenPI to npm -OpenPI releases `@tt-a1i/openpi` through [the Release workflow](.github/workflows/release.yml). Do not publish from a local checkout. +OpenPI releases `@tt-a1i/openpi` through [the Release workflow](.github/workflows/release.yml). The repository workflow keeps the release triggers and OIDC permission while following the reusable implementation on the [`openpi-dev/automation`](https://github.com/openpi-dev/automation) `main` branch. Do not publish from a local checkout. ## One-time repository setup diff --git a/docs/contributing/feishu-pr-notifications.md b/docs/contributing/feishu-pr-notifications.md index 74e9b42d..6eab1d23 100644 --- a/docs/contributing/feishu-pr-notifications.md +++ b/docs/contributing/feishu-pr-notifications.md @@ -1,6 +1,6 @@ # Feishu PR notifications -The Feishu group bot notification is handled by `.github/workflows/feishu-pr-notification.yml`. +The Feishu group bot notification is triggered by `.github/workflows/feishu-pr-notification.yml`. The caller follows the reusable implementation on the [`openpi-dev/automation`](https://github.com/openpi-dev/automation) `main` branch. To enable it: @@ -11,6 +11,6 @@ To enable it: The workflow skips notifications when neither secret exists and fails when only one is configured. The webhook URL and signing secret are never included in the message or logs. -The workflow runs when a pull request is opened or marked ready for review. It uses `pull_request_target` so the repository secret is available for PRs from forks. It checks out only `github.workflow_sha`, the trusted commit that supplied the workflow, and never checks out or executes pull request code. +The workflow runs when a pull request is opened or marked ready for review. It uses `pull_request_target` so the repository secret is available for PRs from forks. The repository caller passes only the two Feishu secrets to the reusable workflow; neither workflow checks out or executes pull request code. Notifications are event-driven: if an author moves a pull request back to draft and then marks it ready again, the group receives another notification. diff --git a/tests/github/automation-workflows.test.ts b/tests/github/automation-workflows.test.ts new file mode 100644 index 00000000..d096ecc1 --- /dev/null +++ b/tests/github/automation-workflows.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +function workflow(name: string) { + return readFileSync(`.github/workflows/${name}.yml`, "utf8"); +} + +test("shared repository workflows follow the automation main branch", () => { + const workflows = [workflow("feishu-pr-notification"), workflow("release")]; + + for (const source of workflows) { + assert.match(source, /uses: openpi-dev\/automation\/.+@main/u); + assert.doesNotMatch(source, /^\s+(?:run|steps|runs-on):/mu); + } +}); + +test("the privileged PR caller passes only its two notification secrets", () => { + const source = workflow("feishu-pr-notification"); + + assert.match(source, /^\s*pull_request_target:/mu); + assert.match(source, /^\s*pull-requests: read$/mu); + assert.match( + source, + /FEISHU_PR_BOT_WEBHOOK: \$\{\{ secrets\.FEISHU_PR_BOT_WEBHOOK \}\}/u, + ); + assert.match( + source, + /FEISHU_PR_BOT_SECRET: \$\{\{ secrets\.FEISHU_PR_BOT_SECRET \}\}/u, + ); + assert.doesNotMatch( + source, + /secrets: inherit|pull_request\.head|github\.head_ref/u, + ); +}); + +test("release keeps its trigger, concurrency, and OIDC authority in the caller", () => { + const source = workflow("release"); + + assert.match(source, /^\s*workflow_dispatch:/mu); + assert.match(source, /^\s*tags:/mu); + assert.match(source, /^\s*id-token: write$/mu); + assert.match(source, /^concurrency:/mu); + assert.match(source, /^\s*tag: \$\{\{ github\.event_name/mu); +}); + +test("project-specific CI remains defined in this repository", () => { + const source = workflow("ci"); + + assert.match(source, /^\s*matrix:/mu); + assert.match(source, /Smoke-test Pi package discovery/u); + assert.match(source, /Background terminals \(Windows\)/u); + assert.doesNotMatch(source, /openpi-dev\/automation/u); +}); diff --git a/tests/github/feishu-pr-notification.test.ts b/tests/github/feishu-pr-notification.test.ts deleted file mode 100644 index efe8a2bc..00000000 --- a/tests/github/feishu-pr-notification.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -import assert from "node:assert/strict"; -import { createHmac } from "node:crypto"; -import { readFileSync } from "node:fs"; -import { createServer } from "node:http"; -import { createRequire } from "node:module"; -import test from "node:test"; - -const require = createRequire(import.meta.url); - -interface PullRequestEvent { - pull_request: { - number: number; - title: string; - user?: { login: string }; - requested_reviewers?: Array<{ login: string }>; - requested_teams?: Array<{ slug: string }>; - head: { label: string }; - base: { ref: string }; - html_url: string; - }; -} - -interface NotificationModule { - formatNotificationText(event: PullRequestEvent, repository: string): string; - sanitizeFeishuField(value: unknown, maxCodePoints?: number): string; - isSuccessfulResponse(payload: unknown): boolean; - sendNotification(options: { - event: PullRequestEvent; - repository: string; - webhook: string; - secret: string; - now?: () => number; - }): Promise; -} - -const notification = - require("../../.github/actions/feishu-pr-notification/index.cjs") as NotificationModule; - -test("the privileged workflow executes only its trusted workflow commit", () => { - const workflow = readFileSync( - ".github/workflows/feishu-pr-notification.yml", - "utf8", - ); - assert.match( - workflow, - /actions\/checkout@[0-9a-f]{40} # v6/u, - "checkout stays pinned to an immutable commit", - ); - assert.match(workflow, /ref: \$\{\{ github\.workflow_sha \}\}/u); - assert.match(workflow, /persist-credentials: false/u); - assert.doesNotMatch(workflow, /pull_request\.head\.sha|github\.head_ref/u); -}); - -function event(overrides: Partial = {}) { - return { - pull_request: { - number: 270, - title: "feat(ci): notify Feishu for new pull requests", - user: { login: "contributor" }, - requested_reviewers: [{ login: "reviewer" }], - requested_teams: [{ slug: "release-managers" }], - head: { label: "contributor:feature" }, - base: { ref: "main" }, - html_url: "https://github.com/openpi-dev/openpi/pull/270", - ...overrides, - }, - } satisfies PullRequestEvent; -} - -test("benign pull request metadata keeps the existing notification", () => { - assert.equal( - notification.formatNotificationText(event(), "openpi-dev/openpi"), - [ - "openpi-dev/openpi 有新的 PR", - "#270 feat(ci): notify Feishu for new pull requests", - "作者:contributor", - "审阅人:reviewer, team/release-managers", - "分支:contributor:feature -> main", - "PR 链接:https://github.com/openpi-dev/openpi/pull/270", - ].join("\n"), - ); - assert.equal( - notification.sanitizeFeishuField( - "fix A & B, AT&T · 支持①号 ffi ligature ɘ ɬ ϊ Ϡ", - ), - "fix A & B, AT&T · 支持①号 ffi ligature ɘ ɬ ϊ Ϡ", - ); -}); - -test("untrusted metadata cannot inject Feishu tags or message fields", () => { - const text = notification.formatNotificationText( - event({ - title: - '所有人 <at> <at> </at> <at> </at> <at> &lt;at\r\n作者:伪造\u202e\u2066', - user: { login: "evil\n审阅人:伪造" }, - requested_reviewers: [{ login: "reviewer\u2028PR 链接:伪造" }], - head: { label: "fork\u2029作者:伪造" }, - }), - "openpi-dev/openpi", - ); - const lines = text.split("\n"); - - assert.equal(lines.length, 6, "untrusted metadata cannot add message lines"); - assert.equal( - lines.filter((line) => line.startsWith("作者:")).length, - 1, - "the canonical author field stays unique", - ); - assert.doesNotMatch( - text, - /<|&|[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u, - ); - assert.match(lines[1]!, /‹at user_id="all"›所有人‹\/at›/); - assert.doesNotMatch(text.normalize("NFKC"), / { - assert.equal(notification.sanitizeFeishuField("🙂".repeat(10), 4), "🙂🙂🙂…"); -}); - -test("reviewer aggregates stay bounded and missing metadata keeps its fallback", () => { - const bounded = notification - .formatNotificationText( - event({ - requested_reviewers: Array.from({ length: 100 }, (_, index) => ({ - login: `reviewer-${index}-${"🙂".repeat(20)}`, - })), - requested_teams: [], - }), - "openpi-dev/openpi", - ) - .split("\n")[3]!; - assert.ok(Array.from(bounded.slice("审阅人:".length)).length <= 512); - assert.ok(bounded.endsWith("…")); - - const fallback = notification.formatNotificationText( - event({ - user: undefined, - requested_reviewers: [], - requested_teams: [], - }), - "openpi-dev/openpi", - ); - assert.match(fallback, /^\u4f5c\u8005:unknown$/mu); - assert.match(fallback, /^\u5ba1\u9605\u4eba:未指定$/mu); -}); - -test("current and legacy Feishu success responses remain accepted", () => { - assert.equal(notification.isSuccessfulResponse({ code: 0 }), true); - assert.equal(notification.isSuccessfulResponse({ StatusCode: 0 }), true); - assert.equal(notification.isSuccessfulResponse({ code: 19021 }), false); - assert.equal(notification.isSuccessfulResponse(null), false); -}); - -test("the HTTP payload uses the sanitized formatter and expected signature", async (t) => { - let requestBody = ""; - const server = createServer((request, response) => { - request.setEncoding("utf8"); - request.on("data", (chunk) => { - requestBody += chunk; - }); - request.on("end", () => { - response.writeHead(200, { "content-type": "application/json" }); - response.end(JSON.stringify({ code: 0 })); - }); - }); - t.after(() => server.close()); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const address = server.address(); - assert.ok(address && typeof address !== "string"); - - const now = 1_700_000_000_000; - const secret = "test-secret"; - await notification.sendNotification({ - event: event({ - title: - '所有人 <at user_id="all"> </at> <at> </at>', - }), - repository: "openpi-dev/openpi", - webhook: `http://127.0.0.1:${address.port}`, - secret, - now: () => now, - }); - - const payload = JSON.parse(requestBody) as { - timestamp: string; - sign: string; - msg_type: string; - content: { text: string }; - }; - const timestamp = String(now / 1_000); - const expectedSign = createHmac("sha256", `${timestamp}\n${secret}`) - .update("") - .digest("base64"); - - assert.equal(payload.timestamp, timestamp); - assert.equal(payload.sign, expectedSign); - assert.equal(payload.msg_type, "text"); - assert.doesNotMatch(payload.content.text, /