From 0ca46ae273fa538a5fe6d82c2b2f5f38c422a9bd Mon Sep 17 00:00:00 2001 From: Jyong Date: Tue, 18 Aug 2026 09:26:37 -0400 Subject: [PATCH] fix(ci): fail closed on knowledge deployment --- .github/workflows/deploy-knowledge.yml | 22 +++++++++++ .github/workflows/knowledge-fs-ci.yml | 1 + api/knowledge-fs-contract.lock.json | 2 +- .../scripts/github-actions-workflow.test.mjs | 38 +++++++++++++++++++ 4 files changed, 62 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-knowledge.yml b/.github/workflows/deploy-knowledge.yml index 14364a9fa9b..e13158e3a71 100644 --- a/.github/workflows/deploy-knowledge.yml +++ b/.github/workflows/deploy-knowledge.yml @@ -68,4 +68,26 @@ jobs: username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} script: | + set -euo pipefail + + deploy_script="$(mktemp)" + normalized_script="${deploy_script}.normalized" + cleanup() { + rm -f "$deploy_script" "$normalized_script" + } + trap cleanup EXIT + + cat > "$deploy_script" <<'DIFY_KNOWLEDGE_DEPLOY_SCRIPT' ${{ vars.SSH_KNOWLEDGE_SCRIPT || secrets.SSH_KNOWLEDGE_SCRIPT }} + DIFY_KNOWLEDGE_DEPLOY_SCRIPT + + if [[ ! -s "$deploy_script" ]]; then + echo "KnowledgeFS deploy script is empty." >&2 + exit 1 + fi + + # GitHub variables and secrets can retain Windows CRLF line endings. + # Normalize them before Bash parses options such as `set -eu`. + tr -d '\r' < "$deploy_script" > "$normalized_script" + mv "$normalized_script" "$deploy_script" + bash -e -u -o pipefail "$deploy_script" diff --git a/.github/workflows/knowledge-fs-ci.yml b/.github/workflows/knowledge-fs-ci.yml index f9a98ca143f..27c6bd3d14c 100644 --- a/.github/workflows/knowledge-fs-ci.yml +++ b/.github/workflows/knowledge-fs-ci.yml @@ -107,6 +107,7 @@ jobs: - 'docker/generate_docker_compose' - 'docs/design/knowledge-fs*' - '.github/dependabot.yml' + - '.github/workflows/deploy-knowledge.yml' - '.github/workflows/knowledge-fs-ci.yml' build: diff --git a/api/knowledge-fs-contract.lock.json b/api/knowledge-fs-contract.lock.json index 08206db0149..8b248217a12 100644 --- a/api/knowledge-fs-contract.lock.json +++ b/api/knowledge-fs-contract.lock.json @@ -1,6 +1,6 @@ { "schemaVersion": 5, - "subtreeTree": "43bd8308bed1f6254fda98c6be9773fd804aa3a7", + "subtreeTree": "7e0b021106e0dd93e5a6145c062029baf4f05053", "openapiSha256": "37c8bdd6a6e7696aae3b336b0de215577ecda45535b1c2eb02d7a337b7399a95", "capabilityV2AuthManifestSha256": "fc0a47e23cce12544882f0298522b4933002e892b84ce1815df7e81d36a7a0c7", "capabilityV2AuthTestVectorSha256": "ae0de37b1ff05c40f905cf17a7b410d8971acacf64db07d5ee3d6fecfa559ce3", diff --git a/knowledge-fs/scripts/github-actions-workflow.test.mjs b/knowledge-fs/scripts/github-actions-workflow.test.mjs index ab11716aabd..231898eef21 100644 --- a/knowledge-fs/scripts/github-actions-workflow.test.mjs +++ b/knowledge-fs/scripts/github-actions-workflow.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; import test from "node:test"; import { parse } from "yaml"; @@ -12,6 +13,10 @@ const workflow = readFileSync( new URL("../../.github/workflows/knowledge-fs-ci.yml", import.meta.url), "utf8", ); +const deployWorkflow = readFileSync( + new URL("../../.github/workflows/deploy-knowledge.yml", import.meta.url), + "utf8", +); const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); const apiPackageJson = JSON.parse( readFileSync(new URL("../packages/api/package.json", import.meta.url), "utf8"), @@ -19,6 +24,7 @@ const apiPackageJson = JSON.parse( const lockfile = readFileSync(new URL("../pnpm-lock.yaml", import.meta.url), "utf8"); const dependabot = readFileSync(new URL("../../.github/dependabot.yml", import.meta.url), "utf8"); const workflowDocument = parse(workflow); +const deployWorkflowDocument = parse(deployWorkflow); const pathsFilterStep = workflowDocument.jobs["check-changes"].steps.find((step) => step.uses?.startsWith("dorny/paths-filter@"), ); @@ -76,6 +82,7 @@ test("root workflow scopes expensive checks internally", () => { assert.match(workflow, /- 'api\/pyproject\.toml'/); assert.match(workflow, /- 'api\/uv\.lock'/); assert.match(workflow, /- '\.github\/dependabot\.yml'/); + assert.match(workflow, /- '\.github\/workflows\/deploy-knowledge\.yml'/); assert.match(workflow, /needs\.check-changes\.outputs\.knowledge-fs == 'true'/); assert.match(workflow, /^ {2}skip:$/m); }); @@ -94,9 +101,40 @@ test("workflow paths stay in parity with every auditable integration touchpoint" test("root workflow and Dependabot configuration are valid YAML", () => { assert.doesNotThrow(() => parse(workflow)); + assert.doesNotThrow(() => parse(deployWorkflow)); assert.doesNotThrow(() => parse(dependabot)); }); +test("deploy workflow normalizes CRLF scripts and fails closed", () => { + const deployStep = deployWorkflowDocument.jobs.deploy.steps.find( + (step) => step.name === "Deploy to server", + ); + assert.ok(deployStep, "workflow is missing the deploy step"); + assert.match(deployStep.uses, /^appleboy\/ssh-action@[0-9a-f]{40}$/); + + const scriptExpression = "${{ vars.SSH_KNOWLEDGE_SCRIPT || secrets.SSH_KNOWLEDGE_SCRIPT }}"; + const wrapper = deployStep.with.script; + assert.equal(wrapper.split(scriptExpression).length - 1, 1); + assert.match(wrapper, /^set -euo pipefail$/m); + assert.match(wrapper, /tr -d '\\r'/); + assert.match(wrapper, /bash -e -u -o pipefail "\$deploy_script"/); + + const execute = (injectedScript) => { + assert.ok(wrapper.includes(scriptExpression)); + return spawnSync("bash", ["-c", wrapper.replace(scriptExpression, injectedScript)], { + encoding: "utf8", + }); + }; + + const success = execute("set -eu\r\nprintf 'normalized\\n'\r\n"); + assert.equal(success.status, 0, success.stderr); + assert.equal(success.stdout, "normalized\n"); + + const failure = execute("set -eu\r\nfalse\r\nprintf 'must-not-run\\n'\r\n"); + assert.notEqual(failure.status, 0); + assert.doesNotMatch(failure.stdout, /must-not-run/); +}); + test("root workflow preserves the independent KnowledgeFS pnpm workspace", () => { assert.equal(packageJson.packageManager, "pnpm@10.33.0"); assert.match(workflow, /working-directory: \.\/knowledge-fs/);