diff --git a/.github/workflows/openpi-feishu-pr-notification.yml b/.github/workflows/openpi-feishu-pr-notification.yml
index 5a9d3b0..b0e8c27 100644
--- a/.github/workflows/openpi-feishu-pr-notification.yml
+++ b/.github/workflows/openpi-feishu-pr-notification.yml
@@ -19,27 +19,8 @@ jobs:
timeout-minutes: 5
if: ${{ github.event_name == 'pull_request_target' && !github.event.pull_request.draft }}
steps:
- - name: Check notification configuration
- id: config
- env:
- FEISHU_PR_BOT_WEBHOOK: ${{ secrets.FEISHU_PR_BOT_WEBHOOK }}
- FEISHU_PR_BOT_SECRET: ${{ secrets.FEISHU_PR_BOT_SECRET }}
- run: |
- if test -z "${FEISHU_PR_BOT_WEBHOOK}" && test -z "${FEISHU_PR_BOT_SECRET}"; then
- echo "Feishu bot secrets are not configured; skipping notification."
- echo "enabled=false" >>"${GITHUB_OUTPUT}"
- 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
-
- echo "enabled=true" >>"${GITHUB_OUTPUT}"
- name: Send PR notification
- if: ${{ steps.config.outputs.enabled == 'true' }}
- uses: openpi-dev/automation/actions/feishu-pr-notification@2ff72314d7aa18d373490ccd615b8d69e91cf408
+ uses: openpi-dev/automation/actions/feishu-pr-notification@c631a31ed6f1851aebe0c0c20cf898f8013a5fbb
env:
FEISHU_PR_BOT_WEBHOOK: ${{ secrets.FEISHU_PR_BOT_WEBHOOK }}
FEISHU_PR_BOT_SECRET: ${{ secrets.FEISHU_PR_BOT_SECRET }}
diff --git a/.github/workflows/openpi-release.yml b/.github/workflows/openpi-release.yml
index b2832c2..17717f0 100644
--- a/.github/workflows/openpi-release.yml
+++ b/.github/workflows/openpi-release.yml
@@ -19,29 +19,12 @@ jobs:
steps:
- name: Resolve release source
id: source
- env:
- RELEASE_TAG: ${{ inputs.tag }}
- EVENT_NAME: ${{ github.event_name }}
- REF: ${{ github.ref }}
- run: |
- case "${EVENT_NAME}" in
- workflow_dispatch)
- test "${REF}" = "refs/heads/main"
- ;;
- push)
- test "${REF}" = "refs/tags/${RELEASE_TAG}"
- ;;
- *)
- echo "Unsupported release event: ${EVENT_NAME}"
- exit 1
- ;;
- esac
- 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: openpi-dev/automation/actions/openpi-release-validation@c631a31ed6f1851aebe0c0c20cf898f8013a5fbb
+ with:
+ mode: resolve
+ release_tag: ${{ inputs.tag }}
+ event_name: ${{ github.event_name }}
+ ref: ${{ github.ref }}
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
fetch-depth: 0
@@ -58,12 +41,13 @@ jobs:
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 }}
+ - name: Verify package version
+ uses: openpi-dev/automation/actions/openpi-release-validation@c631a31ed6f1851aebe0c0c20cf898f8013a5fbb
+ with:
+ mode: verify-package
+ release_tag: ${{ steps.source.outputs.tag }}
+ - name: Verify tagged release ancestry
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
diff --git a/README.md b/README.md
index b9f292a..81aae4d 100644
--- a/README.md
+++ b/README.md
@@ -19,3 +19,9 @@ remain in each caller repository.
```sh
npm test
```
+
+The suite exercises Feishu metadata sanitization, injection resistance, Unicode
+bounds, secret-pair handling, HMAC payloads, and response failures. It also
+executes release event/tag/package-version validation and locks the reusable
+workflow boundaries for draft suppression, OIDC, ancestry, and single-artifact
+publication.
diff --git a/actions/feishu-pr-notification/index.cjs b/actions/feishu-pr-notification/index.cjs
index b744ac4..ad72254 100644
--- a/actions/feishu-pr-notification/index.cjs
+++ b/actions/feishu-pr-notification/index.cjs
@@ -77,6 +77,19 @@ function isSuccessfulResponse(payload) {
);
}
+function resolveNotificationConfiguration(webhook, secret) {
+ const hasWebhook = typeof webhook === "string" && webhook.length > 0;
+ const hasSecret = typeof secret === "string" && secret.length > 0;
+
+ if (!hasWebhook && !hasSecret) return { enabled: false };
+ if (!hasWebhook || !hasSecret) {
+ throw new Error(
+ "Both FEISHU_PR_BOT_WEBHOOK and FEISHU_PR_BOT_SECRET must be configured.",
+ );
+ }
+ return { enabled: true, webhook, secret };
+}
+
async function sendNotification({
event,
repository,
@@ -119,12 +132,21 @@ async function sendNotification({
}
async function main() {
+ const configuration = resolveNotificationConfiguration(
+ process.env.FEISHU_PR_BOT_WEBHOOK,
+ process.env.FEISHU_PR_BOT_SECRET,
+ );
+ if (!configuration.enabled) {
+ console.log("Feishu bot secrets are not configured; skipping notification.");
+ return;
+ }
+
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,
+ webhook: configuration.webhook,
+ secret: configuration.secret,
});
console.log("Feishu PR notification sent.");
}
@@ -139,6 +161,7 @@ if (require.main === module) {
module.exports = {
formatNotificationText,
isSuccessfulResponse,
+ resolveNotificationConfiguration,
sanitizeFeishuField,
sendNotification,
};
diff --git a/actions/openpi-release-validation/action.yml b/actions/openpi-release-validation/action.yml
new file mode 100644
index 0000000..3da6f05
--- /dev/null
+++ b/actions/openpi-release-validation/action.yml
@@ -0,0 +1,21 @@
+name: OpenPI release validation
+description: Validate an OpenPI release event, tag, and checked-out package version
+inputs:
+ mode:
+ description: Validation phase (`resolve` or `verify-package`)
+ required: true
+ release_tag:
+ description: Existing version tag selected by the caller
+ required: true
+ event_name:
+ description: Caller event name for source resolution
+ required: false
+ ref:
+ description: Caller Git ref for source resolution
+ required: false
+outputs:
+ tag:
+ description: Validated release tag
+runs:
+ using: node24
+ main: index.cjs
diff --git a/actions/openpi-release-validation/index.cjs b/actions/openpi-release-validation/index.cjs
new file mode 100644
index 0000000..a98124a
--- /dev/null
+++ b/actions/openpi-release-validation/index.cjs
@@ -0,0 +1,113 @@
+const fs = require("node:fs");
+const path = require("node:path");
+const { spawnSync } = require("node:child_process");
+
+function requireNonempty(value, label) {
+ if (typeof value !== "string" || value.trim().length === 0) {
+ throw new Error(`${label} is required.`);
+ }
+ return value.trim();
+}
+
+function checkReleaseRef(tag) {
+ const result = spawnSync("git", ["check-ref-format", `refs/tags/${tag}`], {
+ encoding: "utf8",
+ });
+ if (result.error) throw result.error;
+ if (result.status !== 0) throw new Error(`Invalid release tag: ${tag}`);
+}
+
+function validateReleaseTag(tag, checkRef = checkReleaseRef) {
+ const normalized = requireNonempty(tag, "release_tag");
+ checkRef(normalized);
+ if (!/^v[^/]*\.[^/]*\.[^/]*$/u.test(normalized)) {
+ throw new Error(`Release tag must match v*.*.*: ${normalized}`);
+ }
+ return normalized;
+}
+
+function resolveReleaseSource({ eventName, ref, releaseTag, checkRef }) {
+ const tag = validateReleaseTag(releaseTag, checkRef);
+ const event = requireNonempty(eventName, "event_name");
+ const sourceRef = requireNonempty(ref, "ref");
+
+ if (event === "workflow_dispatch") {
+ if (sourceRef !== "refs/heads/main") {
+ throw new Error("Manual releases must be dispatched from main.");
+ }
+ } else if (event === "push") {
+ if (sourceRef !== `refs/tags/${tag}`) {
+ throw new Error("A release push must match the selected version tag.");
+ }
+ } else {
+ throw new Error(`Unsupported release event: ${event}`);
+ }
+
+ return tag;
+}
+
+function readPackageVersion(workspace) {
+ const packagePath = path.join(workspace, "package.json");
+ const parsed = JSON.parse(fs.readFileSync(packagePath, "utf8"));
+ return requireNonempty(parsed.version, "package.json version");
+}
+
+function verifyPackageVersion({ releaseTag, workspace, checkRef }) {
+ const tag = validateReleaseTag(releaseTag, checkRef);
+ const version = readPackageVersion(requireNonempty(workspace, "GITHUB_WORKSPACE"));
+ if (tag !== `v${version}`) {
+ throw new Error(`Release tag ${tag} does not match package version ${version}.`);
+ }
+ return tag;
+}
+
+function appendOutput(name, value, outputPath) {
+ fs.appendFileSync(outputPath, `${name}=${value}\n`, "utf8");
+}
+
+function input(name) {
+ return process.env[`INPUT_${name.toUpperCase()}`] ?? "";
+}
+
+function main() {
+ const mode = requireNonempty(input("mode"), "mode");
+ const releaseTag = input("release_tag");
+ let tag;
+
+ if (mode === "resolve") {
+ tag = resolveReleaseSource({
+ eventName: input("event_name"),
+ ref: input("ref"),
+ releaseTag,
+ });
+ } else if (mode === "verify-package") {
+ tag = verifyPackageVersion({
+ releaseTag,
+ workspace: process.env.GITHUB_WORKSPACE,
+ });
+ } else {
+ throw new Error(`Unsupported validation mode: ${mode}`);
+ }
+
+ appendOutput(
+ "tag",
+ tag,
+ requireNonempty(process.env.GITHUB_OUTPUT, "GITHUB_OUTPUT"),
+ );
+}
+
+if (require.main === module) {
+ try {
+ main();
+ } catch (error) {
+ console.error(error);
+ process.exit(1);
+ }
+}
+
+module.exports = {
+ readPackageVersion,
+ resolveReleaseSource,
+ validateReleaseTag,
+ verifyPackageVersion,
+};
diff --git a/test/feishu-pr-notification.test.mjs b/test/feishu-pr-notification.test.mjs
index fcf2adf..b779073 100644
--- a/test/feishu-pr-notification.test.mjs
+++ b/test/feishu-pr-notification.test.mjs
@@ -23,6 +23,28 @@ function event(overrides = {}) {
};
}
+async function withServer(response, run) {
+ let requestBody = "";
+ const server = createServer((request, result) => {
+ request.setEncoding("utf8");
+ request.on("data", (chunk) => {
+ requestBody += chunk;
+ });
+ request.on("end", () => {
+ result.writeHead(response.status, { "content-type": "application/json" });
+ result.end(response.body);
+ });
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const address = server.address();
+ assert.ok(address && typeof address !== "string");
+ try {
+ return await run(`http://127.0.0.1:${address.port}`, () => requestBody);
+ } finally {
+ await new Promise((resolve) => server.close(resolve));
+ }
+}
+
test("benign pull request metadata keeps the existing notification", () => {
assert.equal(
notification.formatNotificationText(event(), "openpi-dev/openpi"),
@@ -35,13 +57,19 @@ test("benign pull request metadata keeps the existing notification", () => {
"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> &lt;at\r\n作者:伪造\u202e\u2066',
+ '所有人 <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作者:伪造" },
@@ -52,12 +80,57 @@ test("untrusted metadata cannot inject Feishu tags or message fields", () => {
assert.equal(lines.length, 6);
assert.equal(lines.filter((line) => line.startsWith("作者:")).length, 1);
- assert.doesNotMatch(text, /<|&|[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u);
+ assert.doesNotMatch(
+ text,
+ /<|&|[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u,
+ );
+ assert.match(lines[1], /‹at user_id="all"›所有人‹\/at›/u);
assert.doesNotMatch(text.normalize("NFKC"), / {
+test("field and reviewer bounds count code points without splitting Unicode", () => {
assert.equal(notification.sanitizeFeishuField("🙂".repeat(10), 4), "🙂🙂🙂…");
+
+ const reviewers = 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(reviewers.slice("审阅人:".length)).length <= 512);
+ assert.ok(reviewers.endsWith("…"));
+});
+
+test("missing PR metadata keeps bounded fallback fields", () => {
+ const text = notification.formatNotificationText(
+ event({ user: undefined, requested_reviewers: [], requested_teams: [] }),
+ "openpi-dev/openpi",
+ );
+ assert.match(text, /^作者:unknown$/mu);
+ assert.match(text, /^审阅人:未指定$/mu);
+});
+
+test("the two Feishu secrets are optional only as a pair", () => {
+ assert.deepEqual(notification.resolveNotificationConfiguration("", ""), {
+ enabled: false,
+ });
+ assert.deepEqual(
+ notification.resolveNotificationConfiguration("webhook", "secret"),
+ { enabled: true, webhook: "webhook", secret: "secret" },
+ );
+ assert.throws(
+ () => notification.resolveNotificationConfiguration("webhook", ""),
+ /Both FEISHU_PR_BOT_WEBHOOK and FEISHU_PR_BOT_SECRET/u,
+ );
+ assert.throws(
+ () => notification.resolveNotificationConfiguration("", "secret"),
+ /Both FEISHU_PR_BOT_WEBHOOK and FEISHU_PR_BOT_SECRET/u,
+ );
});
test("current and legacy Feishu success responses remain accepted", () => {
@@ -67,42 +140,64 @@ test("current and legacy Feishu success responses remain accepted", () => {
assert.equal(notification.isSuccessfulResponse(null), false);
});
-test("the HTTP payload uses sanitized text and the 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 }));
+test("the HTTP payload uses sanitized text and the expected signature", async () => {
+ await withServer({ status: 200, body: '{"code":0}' }, async (webhook, body) => {
+ const now = 1_700_000_000_000;
+ const secret = "test-secret";
+ await notification.sendNotification({
+ event: event({ title: '所有人 <at>' }),
+ repository: "openpi-dev/openpi",
+ webhook,
+ secret,
+ now: () => now,
});
- });
- 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>' }),
- repository: "openpi-dev/openpi",
- webhook: `http://127.0.0.1:${address.port}`,
- secret,
- now: () => now,
- });
+ const payload = JSON.parse(body());
+ const timestamp = String(now / 1_000);
+ const expectedSign = createHmac("sha256", `${timestamp}\n${secret}`)
+ .update("")
+ .digest("base64");
- const payload = JSON.parse(requestBody);
- 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.normalize("NFKC"), / {
+ await withServer({ status: 500, body: '{"code":0}' }, async (webhook) => {
+ await assert.rejects(
+ notification.sendNotification({
+ event: event(),
+ repository: "openpi-dev/openpi",
+ webhook,
+ secret: "secret",
+ }),
+ /Feishu webhook returned 500/u,
+ );
+ });
+ await withServer({ status: 200, body: "not-json" }, async (webhook) => {
+ await assert.rejects(
+ notification.sendNotification({
+ event: event(),
+ repository: "openpi-dev/openpi",
+ webhook,
+ secret: "secret",
+ }),
+ /invalid JSON response/u,
+ );
+ });
+ await withServer({ status: 200, body: '{"code":19021}' }, async (webhook) => {
+ await assert.rejects(
+ notification.sendNotification({
+ event: event(),
+ repository: "openpi-dev/openpi",
+ webhook,
+ secret: "secret",
+ }),
+ /Feishu webhook failed/u,
+ );
+ });
});
diff --git a/test/openpi-release-validation.test.mjs b/test/openpi-release-validation.test.mjs
new file mode 100644
index 0000000..ba15709
--- /dev/null
+++ b/test/openpi-release-validation.test.mjs
@@ -0,0 +1,129 @@
+import assert from "node:assert/strict";
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { createRequire } from "node:module";
+import { spawnSync } from "node:child_process";
+import test from "node:test";
+
+const require = createRequire(import.meta.url);
+const validation = require("../actions/openpi-release-validation/index.cjs");
+const actionPath = join(
+ process.cwd(),
+ "actions/openpi-release-validation/index.cjs",
+);
+
+function withWorkspace(packageJson, run) {
+ const workspace = mkdtempSync(join(tmpdir(), "openpi-release-"));
+ try {
+ writeFileSync(join(workspace, "package.json"), JSON.stringify(packageJson));
+ return run(workspace);
+ } finally {
+ rmSync(workspace, { recursive: true, force: true });
+ }
+}
+
+test("release source accepts only a matching tag push or main dispatch", () => {
+ assert.equal(
+ validation.resolveReleaseSource({
+ eventName: "push",
+ ref: "refs/tags/v0.5.0",
+ releaseTag: "v0.5.0",
+ }),
+ "v0.5.0",
+ );
+ assert.equal(
+ validation.resolveReleaseSource({
+ eventName: "workflow_dispatch",
+ ref: "refs/heads/main",
+ releaseTag: "v0.5.0",
+ }),
+ "v0.5.0",
+ );
+});
+
+test("release source rejects wrong refs, events, and malformed tags", () => {
+ assert.throws(
+ () =>
+ validation.resolveReleaseSource({
+ eventName: "push",
+ ref: "refs/tags/v0.5.1",
+ releaseTag: "v0.5.0",
+ }),
+ /must match the selected version tag/u,
+ );
+ assert.throws(
+ () =>
+ validation.resolveReleaseSource({
+ eventName: "workflow_dispatch",
+ ref: "refs/heads/release",
+ releaseTag: "v0.5.0",
+ }),
+ /dispatched from main/u,
+ );
+ assert.throws(
+ () =>
+ validation.resolveReleaseSource({
+ eventName: "pull_request",
+ ref: "refs/heads/main",
+ releaseTag: "v0.5.0",
+ }),
+ /Unsupported release event/u,
+ );
+ for (const tag of ["0.5.0", "v0.5", "v..", "v0/5/0", ""]) {
+ assert.throws(() => validation.validateReleaseTag(tag));
+ }
+});
+
+test("the release tag must match the checked-out package version", () => {
+ withWorkspace({ version: "0.5.0" }, (workspace) => {
+ assert.equal(
+ validation.verifyPackageVersion({ releaseTag: "v0.5.0", workspace }),
+ "v0.5.0",
+ );
+ assert.throws(
+ () =>
+ validation.verifyPackageVersion({
+ releaseTag: "v0.5.1",
+ workspace,
+ }),
+ /does not match package version/u,
+ );
+ });
+});
+
+test("the action entry point writes the resolved tag and fails closed", () => {
+ const outputDirectory = mkdtempSync(join(tmpdir(), "openpi-output-"));
+ const outputPath = join(outputDirectory, "output");
+ try {
+ const success = spawnSync(process.execPath, [actionPath], {
+ encoding: "utf8",
+ env: {
+ ...process.env,
+ GITHUB_OUTPUT: outputPath,
+ INPUT_MODE: "resolve",
+ INPUT_RELEASE_TAG: "v0.5.0",
+ INPUT_EVENT_NAME: "workflow_dispatch",
+ INPUT_REF: "refs/heads/main",
+ },
+ });
+ assert.equal(success.status, 0, success.stderr);
+ assert.equal(readFileSync(outputPath, "utf8"), "tag=v0.5.0\n");
+
+ const failure = spawnSync(process.execPath, [actionPath], {
+ encoding: "utf8",
+ env: {
+ ...process.env,
+ GITHUB_OUTPUT: outputPath,
+ INPUT_MODE: "resolve",
+ INPUT_RELEASE_TAG: "v0.5.0",
+ INPUT_EVENT_NAME: "workflow_dispatch",
+ INPUT_REF: "refs/heads/not-main",
+ },
+ });
+ assert.notEqual(failure.status, 0);
+ assert.match(failure.stderr, /dispatched from main/u);
+ } finally {
+ rmSync(outputDirectory, { recursive: true, force: true });
+ }
+});
diff --git a/test/workflow-contracts.test.mjs b/test/workflow-contracts.test.mjs
new file mode 100644
index 0000000..d0fde11
--- /dev/null
+++ b/test/workflow-contracts.test.mjs
@@ -0,0 +1,101 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+const ACTION_SHA = "c631a31ed6f1851aebe0c0c20cf898f8013a5fbb";
+
+function workflow(name) {
+ return readFileSync(`.github/workflows/${name}.yml`, "utf8");
+}
+
+test("Feishu suppresses drafts and passes only the optional secret pair", () => {
+ const source = workflow("openpi-feishu-pr-notification");
+
+ assert.match(
+ source,
+ /if: \$\{\{ github\.event_name == 'pull_request_target' && !github\.event\.pull_request\.draft \}\}/u,
+ );
+ assert.match(source, /FEISHU_PR_BOT_WEBHOOK:\n\s+required: false/u);
+ assert.match(source, /FEISHU_PR_BOT_SECRET:\n\s+required: false/u);
+ assert.match(
+ source,
+ new RegExp(
+ `uses: openpi-dev/automation/actions/feishu-pr-notification@${ACTION_SHA}`,
+ ),
+ );
+ 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,
+ /actions\/checkout|pull_request\.head|github\.head_ref/u,
+ );
+});
+
+test("release resolves and verifies the exact tag through tested actions", () => {
+ const source = workflow("openpi-release");
+ const validationCalls = source.match(
+ /uses: openpi-dev\/automation\/actions\/openpi-release-validation@[0-9a-f]{40}/gu,
+ );
+
+ assert.equal(validationCalls?.length, 2);
+ for (const call of validationCalls ?? []) {
+ assert.match(call, new RegExp(ACTION_SHA));
+ }
+ assert.match(source, /mode: resolve/u);
+ assert.match(source, /release_tag: \$\{\{ inputs\.tag \}\}/u);
+ assert.match(source, /event_name: \$\{\{ github\.event_name \}\}/u);
+ assert.match(source, /ref: \$\{\{ github\.ref \}\}/u);
+ assert.match(source, /ref: \$\{\{ steps\.source\.outputs\.tag \}\}/u);
+ assert.match(source, /mode: verify-package/u);
+ assert.match(
+ source,
+ /release_tag: \$\{\{ steps\.source\.outputs\.tag \}\}/u,
+ );
+ assert.match(source, /git merge-base --is-ancestor HEAD origin\/main/u);
+});
+
+test("release validates and transfers one package artifact before publishing", () => {
+ const source = workflow("openpi-release");
+ const publish = source.slice(source.indexOf(" publish:"));
+
+ assert.match(source, /bun install --frozen-lockfile/u);
+ assert.match(source, /bun run check/u);
+ assert.match(source, /bun run test/u);
+ assert.match(source, /npm pack --dry-run --ignore-scripts/u);
+ assert.match(
+ source,
+ /npm pack --ignore-scripts --pack-destination release-artifact/u,
+ );
+ assert.match(source, /actions\/upload-artifact@[0-9a-f]{40}/u);
+ assert.match(source, /if-no-files-found: error/u);
+
+ assert.match(publish, /needs: validate/u);
+ assert.match(publish, /environment: npm/u);
+ assert.match(publish, /id-token: write/u);
+ assert.match(publish, /actions\/download-artifact@[0-9a-f]{40}/u);
+ assert.match(publish, /test "\$\{#packages\[@\]\}" -eq 1/u);
+ assert.match(
+ publish,
+ /npm publish "\$\{packages\[0\]\}" --ignore-scripts --access public/u,
+ );
+ assert.doesNotMatch(publish, /actions\/checkout|bun install|npm pack --dry-run/u);
+});
+
+test("every external action reference is pinned to a full commit SHA", () => {
+ for (const name of [
+ "automation-ci",
+ "openpi-feishu-pr-notification",
+ "openpi-release",
+ ]) {
+ const source = workflow(name);
+ for (const line of source.match(/^\s*-?\s*uses:\s+.+$/gmu) ?? []) {
+ assert.match(line, /@[0-9a-f]{40}(?:\s+#.*)?$/u);
+ }
+ }
+});