From c631a31ed6f1851aebe0c0c20cf898f8013a5fbb Mon Sep 17 00:00:00 2001 From: junwu168 Date: Sat, 29 Aug 2026 20:41:41 +0800 Subject: [PATCH 1/2] test: add reusable workflow security contracts --- actions/feishu-pr-notification/index.cjs | 27 ++- actions/openpi-release-validation/action.yml | 21 +++ actions/openpi-release-validation/index.cjs | 113 +++++++++++++ test/feishu-pr-notification.test.mjs | 169 +++++++++++++++---- test/openpi-release-validation.test.mjs | 129 ++++++++++++++ 5 files changed, 420 insertions(+), 39 deletions(-) create mode 100644 actions/openpi-release-validation/action.yml create mode 100644 actions/openpi-release-validation/index.cjs create mode 100644 test/openpi-release-validation.test.mjs 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 }); + } +}); From f11b483efea42336d14d74841e84e85881bfc68f Mon Sep 17 00:00:00 2001 From: junwu168 Date: Sat, 29 Aug 2026 20:43:58 +0800 Subject: [PATCH 2/2] ci: enforce tested reusable workflow boundaries --- .../openpi-feishu-pr-notification.yml | 21 +--- .github/workflows/openpi-release.yml | 40 +++---- README.md | 6 ++ test/workflow-contracts.test.mjs | 101 ++++++++++++++++++ 4 files changed, 120 insertions(+), 48 deletions(-) create mode 100644 test/workflow-contracts.test.mjs 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/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); + } + } +});