Compare commits

...

1 Commits

Author SHA1 Message Date
Jyong
6776c3bbdb fix(ci): deploy only the tested current Knowledge revision 2026-09-17 04:01:42 -04:00
3 changed files with 162 additions and 43 deletions

View File

@@ -0,0 +1,39 @@
// A workflow_run event is privileged: accept only trusted pushes and the exact tested revision.
module.exports = async function knowledgeDeployGates({ github, context, core, now = Date.now, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }) {
const source = context.payload.workflow_run;
const repository = `${context.repo.owner}/${context.repo.repo}`;
const branch = 'deploy/konwledge';
if (source?.event !== 'push' || source.conclusion !== 'success' || source.head_branch !== branch || source.head_repository?.full_name !== repository || !/^[0-9a-f]{40}$/.test(source.head_sha)) {
throw new Error('Deployment requires a successful trusted branch push.');
}
const isCurrent = async () => {
const { data } = await github.rest.repos.getBranch({ ...context.repo, branch });
return data.commit.sha === source.head_sha;
};
const skip = () => {
core.info('Skipping a superseded deployment revision.');
core.setOutput('current', 'false');
};
if (!(await isCurrent())) return skip();
const deadline = now() + 30 * 60 * 1000;
while (now() < deadline) {
const { data } = await github.rest.actions.listWorkflowRuns({
...context.repo, workflow_id: 'knowledge-fs-ci.yml', branch, event: 'push',
head_sha: source.head_sha, per_page: 100,
});
const run = data.workflow_runs
.filter((candidate) => candidate.head_sha === source.head_sha && candidate.head_branch === branch && candidate.event === 'push' && candidate.head_repository?.full_name === repository)
.sort((a, b) => b.run_number - a.run_number || b.run_attempt - a.run_attempt)[0];
if (run?.status === 'completed') {
if (run.conclusion !== 'success') throw new Error(`KnowledgeFS CI did not succeed for ${source.head_sha}: ${run.conclusion}.`);
if (!(await isCurrent())) return skip();
core.info(`KnowledgeFS CI and image build succeeded for ${source.head_sha}.`);
core.setOutput('current', 'true');
return;
}
if (!(await isCurrent())) return skip();
core.info(`Waiting for KnowledgeFS CI at ${source.head_sha}.`);
await sleep(15000);
}
throw new Error(`Timed out waiting for KnowledgeFS CI for ${source.head_sha}.`);
};

View File

@@ -0,0 +1,75 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const gate = require('./knowledge-deploy-gates.cjs');
const sha = 'a'.repeat(40);
const otherSha = 'b'.repeat(40);
function fixture() {
const source = { event: 'push', conclusion: 'success', head_branch: 'deploy/konwledge', head_repository: { full_name: 'langgenius/dify' }, head_sha: sha };
let time = 0;
let branchSha = sha;
let runs = [{ ...source, status: 'completed', run_number: 2, run_attempt: 1 }];
const outputs = {};
const requests = [];
return {
source, outputs, requests,
setRuns: (value) => { runs = value; },
setBranch: (value) => { branchSha = value; },
args: {
context: { repo: { owner: 'langgenius', repo: 'dify' }, payload: { workflow_run: source } },
github: { rest: {
repos: { getBranch: async () => ({ data: { commit: { sha: branchSha } } }) },
actions: { listWorkflowRuns: async (request) => { requests.push(request); return { data: { workflow_runs: runs } }; } },
} },
core: { info: () => {}, setOutput: (key, value) => { outputs[key] = value; } },
now: () => time,
sleep: async (ms) => { time += ms; },
},
};
}
test('only a successful CI run from the same branch, repo, event, and SHA admits deployment', async () => {
const f = fixture();
await gate(f.args);
assert.equal(f.outputs.current, 'true');
assert.equal(f.requests[0].head_sha, sha);
assert.equal(f.requests[0].workflow_id, 'knowledge-fs-ci.yml');
});
for (const [name, mutation] of [
['pull request', { event: 'pull_request' }], ['failed build', { conclusion: 'failure' }],
['foreign branch', { head_branch: 'main' }], ['foreign repo', { head_repository: { full_name: 'attacker/fork' } }],
['invalid revision', { head_sha: 'main' }],
]) test(`rejects ${name} workflow events before any API query`, async () => {
const f = fixture(); Object.assign(f.source, mutation);
await assert.rejects(gate(f.args), /trusted branch push/);
assert.deepEqual(f.requests, []);
});
for (const conclusion of ['failure', 'cancelled', 'skipped', 'timed_out', 'neutral']) test(`CI ${conclusion} fails closed`, async () => {
const f = fixture(); f.setRuns([{ ...f.source, status: 'completed', conclusion }]);
await assert.rejects(gate(f.args), /did not succeed/);
assert.notEqual(f.outputs.current, 'true');
});
for (const mutation of [{ head_sha: otherSha }, { head_branch: 'main' }, { event: 'pull_request' }, { head_repository: { full_name: 'attacker/fork' } }]) test(`ignores unrelated successful CI run ${JSON.stringify(mutation)}`, async () => {
const f = fixture(); f.setRuns([{ ...f.source, ...mutation, status: 'completed' }]);
await assert.rejects(gate(f.args), /Timed out/);
assert.notEqual(f.outputs.current, 'true');
});
test('latest failed attempt cannot be hidden by an earlier successful run', async () => {
const f = fixture(); f.setRuns([
{ ...f.source, status: 'completed', run_number: 1, run_attempt: 1 },
{ ...f.source, status: 'completed', run_number: 2, run_attempt: 1, conclusion: 'failure' },
]);
await assert.rejects(gate(f.args), /did not succeed/);
});
test('stale revision skips without waiting or deploying', async () => {
const f = fixture(); f.setBranch(otherSha); await gate(f.args);
assert.equal(f.outputs.current, 'false'); assert.deepEqual(f.requests, []);
});
test('branch advancing during CI wait prevents deployment', async () => {
const f = fixture(); f.setRuns([{ ...f.source, status: 'in_progress' }]);
f.args.sleep = async () => { f.setBranch(otherSha); f.setRuns([{ ...f.source, status: 'completed' }]); };
await gate(f.args); assert.equal(f.outputs.current, 'false');
});
test('running CI is awaited before reporting success', async () => {
const f = fixture(); f.setRuns([]);
f.args.sleep = async () => { f.setRuns([{ ...f.source, status: 'completed' }]); };
await gate(f.args); assert.equal(f.outputs.current, 'true'); assert.equal(f.requests.length, 2);
});

View File

@@ -7,67 +7,72 @@ permissions:
on:
workflow_run:
workflows: ['Build and Push API & Web']
branches:
- 'deploy/konwledge'
types:
- completed
branches: ['deploy/konwledge']
types: [completed]
# A second workflow used to deploy the same server after KnowledgeFS CI. This is the single
# Dify deployment owner; the independent KnowledgeFS release retains its own deployment lock.
concurrency:
group: deploy-knowledge-dify-test
cancel-in-progress: false
jobs:
deploy:
runs-on: depot-ubuntu-24.04
timeout-minutes: 340
if: |
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'deploy/konwledge'
github.event.workflow_run.head_branch == 'deploy/konwledge' &&
github.event.workflow_run.head_repository.full_name == github.repository
steps:
- name: Wait for KnowledgeFS CI
- name: Checkout tested deployment gates
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.workflow_run.head_sha }}
persist-credentials: false
sparse-checkout: .github/scripts
- name: Require same-revision KnowledgeFS CI and current branch head
id: gates
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
timeout-minutes: 35
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const workflowId = "knowledge-fs-ci.yml";
const headBranch = context.payload.workflow_run.head_branch;
const headSha = context.payload.workflow_run.head_sha;
const deadline = Date.now() + 30 * 60 * 1000;
const pollIntervalMs = 15 * 1000;
await require('./.github/scripts/knowledge-deploy-gates.cjs')({ github, context, core });
while (Date.now() < deadline) {
const { data } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
branch: headBranch,
event: "push",
head_sha: headSha,
per_page: 10,
});
const run = data.workflow_runs[0];
if (!run) {
core.info(`Waiting for ${workflowId} to start for ${headSha}.`);
} else if (run.status !== "completed") {
core.info(`Waiting for ${run.html_url}; current status is ${run.status}.`);
} else if (run.conclusion !== "success") {
throw new Error(
`${workflowId} did not succeed for ${headSha}: ${run.conclusion} (${run.html_url})`,
);
} else {
core.info(`KnowledgeFS CI succeeded for ${headSha}: ${run.html_url}`);
return;
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
throw new Error(`Timed out waiting for ${workflowId} to succeed for ${headSha}.`);
- name: Deploy to server
- name: Deploy tested Dify revision while preserving KnowledgeFS
if: steps.gates.outputs.current == 'true'
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
env:
EXPECTED_DIFY_REVISION: ${{ github.event.workflow_run.head_sha }}
DEPLOY_DIR: /home/ubuntu/langgenius
with:
host: ${{ secrets.SSH_NEW_RAG_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
# Allow the shared deployment lock and Celery warm shutdown to finish.
command_timeout: 300m
envs: EXPECTED_DIFY_REVISION,DEPLOY_DIR
script: |
${{ vars.SSH_SCRIPT || secrets.SSH_SCRIPT }}
set -euo pipefail
[[ "$EXPECTED_DIFY_REVISION" =~ ^[0-9a-f]{40}$ ]] || {
echo 'Expected a full tested Dify revision.' >&2; exit 1;
}
# Verify the installed bridge forwards revision arguments before invoking it. Never
# overwrite an operator's custom entrypoint or fall back to an unverified script.
python3 - "$DEPLOY_DIR" <<'PY'
import shlex
import sys
from pathlib import Path
project = Path(sys.argv[1]).resolve()
controller = project / 'knowledge-fs-independent-deploy.py'
expected = ('#!/usr/bin/env bash\nset -euo pipefail\nexec python3 ' +
shlex.quote(str(controller)) + ' --dify --project-dir ' +
shlex.quote(str(project)) + ' "$@"\n')
if not controller.is_file() or (project / 'deploy.sh').read_text() != expected:
raise SystemExit('Expected the installed KnowledgeFS-preserving Dify release bridge.')
PY
"$DEPLOY_DIR/deploy.sh" --expected-revision "$EXPECTED_DIFY_REVISION"