ci(gate): skip the staging gate for docs-only pushes without releasing stale code
Twenty-four of the last sixty staging pushes were pure documentation, yet each one ran (and cancelled) the full gate and image publish. Introduce deploy/gated-paths.txt as the single source of truth for what must rerun the gate: every Dockerfile COPY source, the Python package inputs, the workflow and build-context files, and the repository files frontend/tests read at gate time. Both triggers of backend-quality-gate.yml now carry that exact list; pushes that touch none of it neither run the gate nor cancel a running code gate. Because staging head may then legitimately sit ahead of the last tested SHA, add deploy/is-docs-only-range.sh: it proves <base> is an ancestor of <head> and that no changed path matches a gated glob, from local history when it is available and otherwise from the Gitea compare API (per-commit `files`, parent walk for ancestry, total_commits cross-checked). The publish dispatch and the deploy-staging head checks accept an advanced head only when that script succeeds; diverged, older, or code-bearing heads are still refused. In deploy-staging the check runs after the gate-attested controller bundle is extracted so only the tested checker and path list are ever executed; the manual rollback branch is unchanged. AGENTS.md §6.3/§6.4 describe the new contract: `.deployment.gitCommit` must equal the latest staging commit that touched a gated path, not staging head. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VawU7Xfd5jS9wUEXz1XYmS
This commit is contained in:
@@ -1250,3 +1250,217 @@ test("runner disk reclaim keeps BuildKit cache unless the runner is actually sho
|
||||
// The final threshold check still fails the job instead of silently proceeding.
|
||||
assert.match(script, /if \[ "\$AFTER_GIB" -lt "\$MINIMUM_FREE_GIB" \]; then\n\s+echo[^\n]+\n\s+exit 1/);
|
||||
});
|
||||
|
||||
const gatedPathsFile = new URL("../../deploy/gated-paths.txt", import.meta.url);
|
||||
const docsOnlyRangeScript = new URL("../../deploy/is-docs-only-range.sh", import.meta.url);
|
||||
|
||||
function gatedGlobs(): string[] {
|
||||
return read(gatedPathsFile)
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0 && !line.startsWith("#"));
|
||||
}
|
||||
|
||||
function triggerPaths(workflow: string, trigger: "pull_request" | "push"): string[] {
|
||||
const block = workflow.match(new RegExp(`\\n ${trigger}:\\n(?: branches: \\[staging\\]\\n)? paths:\\n((?: - '[^'\\n]+'\\n)+)`));
|
||||
assert.ok(block, `${trigger} trigger has no paths list`);
|
||||
return [...block[1].matchAll(/ - '([^'\n]+)'\n/g)].map((match) => match[1]);
|
||||
}
|
||||
|
||||
// Same semantics as GitHub/Gitea path filters and deploy/is-docs-only-range.sh:
|
||||
// `*` and `?` stop at `/`, `**` crosses directories.
|
||||
function globMatches(glob: string, path: string): boolean {
|
||||
let source = "";
|
||||
for (let i = 0; i < glob.length; i += 1) {
|
||||
if (glob.startsWith("**/", i) && (i === 0 || glob[i - 1] === "/")) {
|
||||
source += "(?:.*/)?";
|
||||
i += 2;
|
||||
} else if (glob.startsWith("**", i)) {
|
||||
source += ".*";
|
||||
i += 1;
|
||||
} else if (glob[i] === "*") {
|
||||
source += "[^/]*";
|
||||
} else if (glob[i] === "?") {
|
||||
source += "[^/]";
|
||||
} else {
|
||||
source += glob[i].replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
}
|
||||
return new RegExp(`^${source}$`).test(path);
|
||||
}
|
||||
|
||||
function gated(path: string, globs: string[]): boolean {
|
||||
return globs.some((glob) => globMatches(glob, path) || globMatches(glob, `${path}/_`));
|
||||
}
|
||||
|
||||
test("gated paths are one list shared by both quality-gate triggers", () => {
|
||||
const workflow = read(giteaQualityWorkflow);
|
||||
const globs = gatedGlobs();
|
||||
|
||||
assert.ok(globs.length > 0, "deploy/gated-paths.txt must not be empty");
|
||||
assert.deepEqual(triggerPaths(workflow, "pull_request"), globs);
|
||||
assert.deepEqual(triggerPaths(workflow, "push"), globs);
|
||||
assert.match(workflow, /push:\n\s+branches: \[staging\]\n\s+paths:\n/);
|
||||
assert.deepEqual(new Set(globs).size, globs.length, "gated globs must be unique");
|
||||
});
|
||||
|
||||
test("gated paths cover every image input, package input, and gate-read repository file", () => {
|
||||
const globs = gatedGlobs();
|
||||
for (const required of [
|
||||
"frontend/**",
|
||||
"jyotish_vedic/**",
|
||||
"deploy/**",
|
||||
"scripts/**",
|
||||
"tests/**",
|
||||
"references/**",
|
||||
"skills/**",
|
||||
"assets/**",
|
||||
"SKILL.md",
|
||||
"mcp_server.py",
|
||||
"pyproject.toml",
|
||||
"MANIFEST.in",
|
||||
"requirements*.txt",
|
||||
".dockerignore",
|
||||
".gitea/**",
|
||||
"contracts/**",
|
||||
]) {
|
||||
assert.ok(globs.includes(required), `${required} missing from deploy/gated-paths.txt`);
|
||||
}
|
||||
|
||||
// Every COPY source in both Dockerfiles (stage-to-stage copies excluded) must be gated.
|
||||
const copySources = [read(apiDockerfile), read(railwayWebDockerfile)].flatMap((dockerfile) =>
|
||||
[...dockerfile.matchAll(/^COPY (?!--from=)(.+)$/gm)].flatMap((match) => match[1].trim().split(/\s+/).slice(0, -1)),
|
||||
);
|
||||
assert.ok(copySources.length >= 20, `expected the Dockerfiles to declare COPY sources, saw ${copySources.length}`);
|
||||
for (const source of copySources) {
|
||||
assert.ok(gated(source, globs), `Dockerfile COPY source ${source} is not covered by deploy/gated-paths.txt`);
|
||||
}
|
||||
// Repository files the gate's own tests read at run time.
|
||||
for (const source of [
|
||||
"skills/jyotish-birth-time-rectification/SKILL.md",
|
||||
"references/rectification_sealed_holdout.v1.json",
|
||||
"contracts/probe-question-v1.json",
|
||||
"tests/fixtures/personal_report_document.v2.json",
|
||||
".gitea/actions/upload-artifact/dist/index.js",
|
||||
".github/workflows/deploy-production.yml",
|
||||
"deploy/gated-paths.txt",
|
||||
"deploy/is-docs-only-range.sh",
|
||||
]) {
|
||||
assert.ok(gated(source, globs), `${source} is read by the gate but not covered`);
|
||||
}
|
||||
// Pure record files stay docs-only.
|
||||
for (const docsOnly of [
|
||||
"docs/BUG_HISTORY.md",
|
||||
"docs/research/anything.md",
|
||||
"TASK-example-20260901.md",
|
||||
"PROGRESS-example-20260901.md",
|
||||
"CHANGELOG.md",
|
||||
"progress.md",
|
||||
"task_plan.md",
|
||||
"findings.md",
|
||||
"BLOCKED.md",
|
||||
"CONTEXT.md",
|
||||
"AGENTS.md",
|
||||
"README.md",
|
||||
]) {
|
||||
assert.equal(gated(docsOnly, globs), false, `${docsOnly} should be docs-only`);
|
||||
}
|
||||
});
|
||||
|
||||
test("publish dispatch and staging deploy accept docs-only advances only through the attested checker", () => {
|
||||
const quality = read(giteaQualityWorkflow);
|
||||
const deploy = read(giteaDeployWorkflow);
|
||||
|
||||
const dispatch = quality.match(/- name: Dispatch exact-SHA staging deployment[\s\S]*?(?=\n\s+- name: Logout ACR registry)/)?.[0] ?? "";
|
||||
assert.match(dispatch, /if \[\[ "\$current_staging_sha" != "\$DEPLOY_SHA" \]\]; then/);
|
||||
assert.match(dispatch, /bash deploy\/is-docs-only-range\.sh --api "\$DEPLOY_SHA" "\$current_staging_sha"/);
|
||||
assert.match(dispatch, /staging advanced before deployment dispatch; refusing stale release/);
|
||||
assert.match(dispatch, /--arg deploy_sha "\$DEPLOY_SHA"/);
|
||||
|
||||
// deploy-staging never checks out a branch: the checker comes from the
|
||||
// gate-attested controller bundle and decides via the Gitea compare API.
|
||||
assert.match(deploy, /checker=artifacts\/staging-image\/extracted\/deploy\/is-docs-only-range\.sh\n\s+\[\[ -f "\$checker" \]\] \|\| \{ echo "gate-attested controller bundle lacks deploy\/is-docs-only-range\.sh/);
|
||||
assert.match(deploy, /if bash "\$checker" --api "\$DEPLOY_SHA" "\$staging_head"; then/);
|
||||
assert.match(deploy, /\[\[ "\$current_head" == "\$DEPLOY_SHA" \]\] && return\n[^\n]*\n\s+bash artifacts\/staging-image\/extracted\/deploy\/is-docs-only-range\.sh --api "\$DEPLOY_SHA" "\$current_head" \|\|\n\s+\{ echo "staging advanced during deployment; refusing stale mutation"/);
|
||||
assert.equal((deploy.match(/is-docs-only-range\.sh/g) ?? []).length, 4);
|
||||
assert.doesNotMatch(deploy, /bash deploy\/is-docs-only-range\.sh/);
|
||||
assertOrder(deploy, [
|
||||
"Validate tested revision and gate run",
|
||||
"deferring the docs-only range check to the attested controller",
|
||||
"head_check=$head_check",
|
||||
"Download exact staging gate artifact",
|
||||
"Validate gate-attested staging controller and immutable image manifest",
|
||||
"Refuse stale staging revision unless only docs advanced",
|
||||
'if [[ "$ALLOW_ROLLBACK" == true ]]; then',
|
||||
"stale staging revision refused; use explicit manual rollback only when intended",
|
||||
"Deploy exact image digests under pinned SSH identity",
|
||||
"staging advanced during deployment; refusing stale mutation",
|
||||
]);
|
||||
// The manual rollback branch is untouched.
|
||||
assert.match(deploy, /if \[\[ "\$allow_rollback" == true && "\$REQUESTED_SHA" != "\$staging_head" \]\]; then\n\s+comparison="\$\(curl/);
|
||||
assert.match(deploy, /rollback revision is not in current staging history/);
|
||||
assert.doesNotMatch(deploy, /if \[\[ "\$allow_rollback" == false && "\$REQUESTED_SHA" != "\$staging_head" \]\]; then\n\s+echo "stale staging revision refused/);
|
||||
});
|
||||
|
||||
test("is-docs-only-range.sh decides from local history and refuses non-ancestor ranges", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "jyotisha-docs-only-range-"));
|
||||
const script = fileURLToPath(docsOnlyRangeScript);
|
||||
const gatedPaths = fileURLToPath(gatedPathsFile);
|
||||
const env = {
|
||||
...process.env,
|
||||
GATED_PATHS_FILE: gatedPaths,
|
||||
GIT_CONFIG_GLOBAL: "/dev/null",
|
||||
GIT_CONFIG_NOSYSTEM: "1",
|
||||
GIT_AUTHOR_NAME: "t",
|
||||
GIT_AUTHOR_EMAIL: "t@example.invalid",
|
||||
GIT_COMMITTER_NAME: "t",
|
||||
GIT_COMMITTER_EMAIL: "t@example.invalid",
|
||||
};
|
||||
const git = (...args: string[]): string => {
|
||||
const result = spawnSync("git", args, { cwd: root, env, encoding: "utf8" });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
return result.stdout.trim();
|
||||
};
|
||||
const commit = (relative: string, message: string): string => {
|
||||
mkdirSync(join(root, relative, ".."), { recursive: true });
|
||||
writeFileSync(join(root, relative), `${message}\n`);
|
||||
git("add", "-A");
|
||||
git("commit", "-q", "-m", message);
|
||||
return git("rev-parse", "HEAD");
|
||||
};
|
||||
const run = (...args: string[]) => spawnSync("bash", [script, ...args], { cwd: root, env, encoding: "utf8" });
|
||||
|
||||
try {
|
||||
git("init", "-q", "-b", "staging");
|
||||
const base = commit("frontend/src/app.ts", "code base");
|
||||
const docs = commit("docs/notes.md", "docs one");
|
||||
const task = commit("TASK-example-20260901.md", "docs two");
|
||||
const code = commit("jyotish_vedic/engine.py", "code after docs");
|
||||
git("checkout", "-q", "-b", "side", base);
|
||||
const diverged = commit("docs/side.md", "diverged docs");
|
||||
|
||||
const docsOnly = run(base, task);
|
||||
assert.equal(docsOnly.status, 0, docsOnly.stderr);
|
||||
assert.match(docsOnly.stdout, /docs-only: 2 changed path\(s\)/);
|
||||
|
||||
const gatedRange = run(base, code);
|
||||
assert.equal(gatedRange.status, 1, gatedRange.stderr);
|
||||
assert.match(gatedRange.stderr, /jyotish_vedic\/engine\.py \(matches jyotish_vedic\/\*\*\)/);
|
||||
|
||||
const behind = run(task, base);
|
||||
assert.equal(behind.status, 2, behind.stderr);
|
||||
assert.match(behind.stderr, /is not an ancestor of/);
|
||||
|
||||
const forked = run(docs, diverged);
|
||||
assert.equal(forked.status, 2, forked.stderr);
|
||||
|
||||
const same = run(task, task);
|
||||
assert.equal(same.status, 0, same.stderr);
|
||||
|
||||
const malformed = run("abc", task);
|
||||
assert.equal(malformed.status, 2);
|
||||
assert.match(malformed.stderr, /lowercase full commit SHA/);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user