Compare commits
1 Commits
codex/requ
...
codex/know
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6776c3bbdb |
39
.github/scripts/knowledge-deploy-gates.cjs
vendored
Normal file
39
.github/scripts/knowledge-deploy-gates.cjs
vendored
Normal 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}.`);
|
||||
};
|
||||
75
.github/scripts/knowledge-deploy-gates.test.cjs
vendored
Normal file
75
.github/scripts/knowledge-deploy-gates.test.cjs
vendored
Normal 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);
|
||||
});
|
||||
91
.github/workflows/deploy-knowledge.yml
vendored
91
.github/workflows/deploy-knowledge.yml
vendored
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user