fix: streamline pr and release workflow (#289)

Co-authored-by: sck_0 <samujackson1337@gmail.com>
This commit is contained in:
sickn33
2026-03-13 14:20:49 +01:00
committed by GitHub
parent 5655f9b0a8
commit e325b0ee30
17 changed files with 1100 additions and 172 deletions

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env node
const { getManagedFiles, loadWorkflowContract } = require("../lib/workflow-contract");
function parseArgs(argv) {
return {
includeMixed: argv.includes("--include-mixed"),
includeReleaseManaged: argv.includes("--include-release-managed"),
json: argv.includes("--json"),
shell: argv.includes("--shell"),
};
}
function main() {
const args = parseArgs(process.argv.slice(2));
const contract = loadWorkflowContract(__dirname);
const files = getManagedFiles(contract, {
includeMixed: args.includeMixed,
includeReleaseManaged: args.includeReleaseManaged,
});
if (args.json) {
process.stdout.write(`${JSON.stringify(files, null, 2)}\n`);
return;
}
if (args.shell) {
process.stdout.write(`${files.join(" ")}\n`);
return;
}
process.stdout.write(`${files.join("\n")}\n`);
}
main();

View File

@@ -0,0 +1,250 @@
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const { spawnSync } = require("child_process");
const { findProjectRoot } = require("../lib/project-root");
const {
classifyChangedFiles,
getDirectDerivedChanges,
hasIssueLink,
hasQualityChecklist,
loadWorkflowContract,
normalizeRepoPath,
requiresReferencesValidation,
} = require("../lib/workflow-contract");
function parseArgs(argv) {
const args = {
base: null,
head: "HEAD",
eventPath: null,
checkPolicy: false,
noRun: false,
writeGithubOutput: false,
writeStepSummary: false,
json: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--base") {
args.base = argv[index + 1];
index += 1;
} else if (arg === "--head") {
args.head = argv[index + 1];
index += 1;
} else if (arg === "--event-path") {
args.eventPath = argv[index + 1];
index += 1;
} else if (arg === "--check-policy") {
args.checkPolicy = true;
} else if (arg === "--no-run") {
args.noRun = true;
} else if (arg === "--write-github-output") {
args.writeGithubOutput = true;
} else if (arg === "--write-step-summary") {
args.writeStepSummary = true;
} else if (arg === "--json") {
args.json = true;
}
}
return args;
}
function runGit(args, options = {}) {
const result = spawnSync("git", args, {
cwd: options.cwd,
encoding: "utf8",
stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit",
});
if (result.error) {
throw result.error;
}
if (typeof result.status !== "number" || result.status !== 0) {
const stderr = options.capture ? result.stderr.trim() : "";
throw new Error(stderr || `git ${args.join(" ")} failed with status ${result.status}`);
}
return options.capture ? result.stdout.trim() : "";
}
function runCommand(command, args, cwd) {
console.log(`[pr:preflight] ${command} ${args.join(" ")}`);
const result = spawnSync(command, args, {
cwd,
stdio: "inherit",
shell: process.platform === "win32",
});
if (result.error) {
throw result.error;
}
if (typeof result.status !== "number" || result.status !== 0) {
process.exit(result.status || 1);
}
}
function resolveBaseRef(projectRoot) {
for (const candidate of ["origin/main", "main"]) {
const result = spawnSync("git", ["rev-parse", "--verify", candidate], {
cwd: projectRoot,
stdio: "ignore",
});
if (result.status === 0) {
return candidate;
}
}
return "HEAD";
}
function getChangedFiles(projectRoot, baseRef, headRef) {
if (baseRef === headRef) {
return [];
}
const diffOutput = runGit(["diff", "--name-only", `${baseRef}...${headRef}`], {
cwd: projectRoot,
capture: true,
});
return [...new Set(diffOutput.split(/\r?\n/).map(normalizeRepoPath).filter(Boolean))];
}
function loadPullRequestBody(eventPath) {
if (!eventPath) {
return null;
}
const rawEvent = fs.readFileSync(path.resolve(eventPath), "utf8");
const event = JSON.parse(rawEvent);
return event.pull_request?.body || "";
}
function appendGithubOutput(result) {
const outputPath = process.env.GITHUB_OUTPUT;
if (!outputPath) {
return;
}
const lines = [
`primary_category=${result.primaryCategory}`,
`categories=${result.categories.join(",")}`,
`requires_references=${String(result.requiresReferencesValidation)}`,
`direct_derived_changes_count=${String(result.directDerivedChanges.length)}`,
`direct_derived_changes=${JSON.stringify(result.directDerivedChanges)}`,
`changed_files_count=${String(result.changedFiles.length)}`,
`has_quality_checklist=${String(result.prBody.hasQualityChecklist)}`,
`has_issue_link=${String(result.prBody.hasIssueLink)}`,
];
fs.appendFileSync(outputPath, `${lines.join("\n")}\n`, "utf8");
}
function appendStepSummary(result) {
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
if (!summaryPath) {
return;
}
const derivedSummary =
result.directDerivedChanges.length === 0
? "none"
: result.directDerivedChanges.map((filePath) => `\`${filePath}\``).join(", ");
const lines = [
"## PR Workflow Intake",
"",
`- Primary change: \`${result.primaryCategory}\``,
`- Categories: ${result.categories.length > 0 ? result.categories.map((category) => `\`${category}\``).join(", ") : "\`none\`"}`,
`- Changed files: ${result.changedFiles.length}`,
`- Direct derived-file edits: ${derivedSummary}`,
`- \`validate:references\` required: ${result.requiresReferencesValidation ? "yes" : "no"}`,
`- PR template checklist: ${result.prBody.hasQualityChecklist ? "present" : "missing"}`,
`- Issue auto-close link: ${result.prBody.hasIssueLink ? "detected" : "not detected"}`,
"",
"> Generated drift is reported separately in the artifact preview job and remains informational on pull requests.",
];
fs.appendFileSync(summaryPath, `${lines.join("\n")}\n`, "utf8");
}
function main() {
const args = parseArgs(process.argv.slice(2));
const projectRoot = findProjectRoot(__dirname);
const contract = loadWorkflowContract(__dirname);
const baseRef = args.base || resolveBaseRef(projectRoot);
const changedFiles = getChangedFiles(projectRoot, baseRef, args.head);
const classification = classifyChangedFiles(changedFiles, contract);
const directDerivedChanges = getDirectDerivedChanges(changedFiles, contract);
const pullRequestBody = loadPullRequestBody(args.eventPath);
const result = {
baseRef,
headRef: args.head,
changedFiles,
categories: classification.categories,
primaryCategory: classification.primaryCategory,
directDerivedChanges,
requiresReferencesValidation: requiresReferencesValidation(changedFiles, contract),
prBody: {
available: pullRequestBody !== null,
hasQualityChecklist: hasQualityChecklist(pullRequestBody),
hasIssueLink: hasIssueLink(pullRequestBody),
},
};
if (args.writeGithubOutput) {
appendGithubOutput(result);
}
if (args.writeStepSummary) {
appendStepSummary(result);
}
if (args.json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
} else {
console.log(`[pr:preflight] Base ref: ${baseRef}`);
console.log(`[pr:preflight] Changed files: ${changedFiles.length}`);
console.log(
`[pr:preflight] Classification: ${result.categories.length > 0 ? result.categories.join(", ") : "none"}`,
);
}
if (args.checkPolicy) {
if (directDerivedChanges.length > 0) {
console.error(
[
"Pull requests are source-only.",
"Remove derived files from the PR and let main regenerate them after merge.",
`Derived files detected: ${directDerivedChanges.join(", ")}`,
].join(" "),
);
process.exit(1);
}
if (pullRequestBody !== null && !result.prBody.hasQualityChecklist) {
console.error("PR body must include the Quality Bar Checklist section from the template.");
process.exit(1);
}
}
if (!args.noRun) {
runCommand("npm", ["run", "validate"], projectRoot);
if (result.requiresReferencesValidation) {
runCommand("npm", ["run", "validate:references"], projectRoot);
}
runCommand("npm", ["run", "test"], projectRoot);
}
}
main();

View File

@@ -1,66 +1,8 @@
#!/bin/bash
# Release Cycle Automation Script
# Enforces protocols from .github/MAINTENANCE.md
set -e
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo "release_cycle.sh is now a thin wrapper around the scripted release workflow."
echo "Use \`npm run release:preflight\` directly for the supported entrypoint."
echo -e "${YELLOW}🤖 Initiating Antigravity Release Protocol...${NC}"
# 1. Validation Chain
echo -e "\n${YELLOW}Step 1: Running Validation Chain...${NC}"
echo "Running validate_skills.py..."
python3 tools/scripts/validate_skills.py
echo "Running generate_index.py..."
python3 tools/scripts/generate_index.py
echo "Running update_readme.py..."
python3 tools/scripts/update_readme.py
# 2. Catalog (required for CI)
echo -e "\n${YELLOW}Step 2: Build catalog...${NC}"
npm run catalog
# 3. Stats Consistency Check
echo -e "\n${YELLOW}Step 3: Verifying Stats Consistency...${NC}"
JSON_COUNT=$(python3 -c "import json; print(len(json.load(open('skills_index.json'))))")
echo "Skills in Registry (JSON): $JSON_COUNT"
# Check README Intro
README_CONTENT=$(cat README.md)
if [[ "$README_CONTENT" != *"$JSON_COUNT high-performance"* ]]; then
echo -e "${RED}❌ ERROR: README.md intro consistency failure!${NC}"
echo "Expected: '$JSON_COUNT high-performance'"
echo "Found mismatch. Please grep for 'high-performance' in README.md and fix it."
exit 1
fi
echo -e "${GREEN}✅ Stats Consistent.${NC}"
# 4. Version check (package.json is source of truth for npm)
echo -e "\n${YELLOW}Step 4: Version check${NC}"
PKG_VERSION=$(node -p "require('./package.json').version")
echo "package.json version: $PKG_VERSION"
echo "Ensure this version is bumped before 'npm publish' (npm forbids republishing the same version)."
# 5. Contributor Check
echo -e "\n${YELLOW}Step 5: Contributor Check${NC}"
echo "Recent commits by author (check against README 'Repo Contributors'):"
git shortlog -sn --since="1 month ago" --all --no-merges | head -n 10
echo -e "${YELLOW}⚠️ MANUAL VERIFICATION REQUIRED:${NC}"
echo "1. Are all PR authors above listed in 'Repo Contributors'?"
echo "2. Are all External Sources listed in 'Credits & Sources'?"
read -p "Type 'yes' to confirm you have verified contributors: " CONFIRM_CONTRIB
if [ "$CONFIRM_CONTRIB" != "yes" ]; then
echo -e "${RED}❌ Verification failed. Aborting.${NC}"
exit 1
fi
echo -e "\n${GREEN}✅ Release Cycle Checks Passed. You may now commit and push.${NC}"
echo -e "${YELLOW}After tagging a release: run \`npm publish\` from repo root (or use GitHub Release + NPM_TOKEN for CI).${NC}"
exit 0
node tools/scripts/release_workflow.js preflight

View File

@@ -0,0 +1,252 @@
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const { spawnSync } = require("child_process");
const { findProjectRoot } = require("../lib/project-root");
const {
extractChangelogSection,
getManagedFiles,
loadWorkflowContract,
} = require("../lib/workflow-contract");
function parseArgs(argv) {
const [command, version] = argv;
return {
command,
version: version || null,
};
}
function runCommand(command, args, cwd, options = {}) {
const result = spawnSync(command, args, {
cwd,
encoding: "utf8",
stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit",
shell: options.shell ?? process.platform === "win32",
});
if (result.error) {
throw result.error;
}
if (typeof result.status !== "number" || result.status !== 0) {
const stderr = options.capture ? result.stderr.trim() : "";
throw new Error(stderr || `${command} ${args.join(" ")} failed with status ${result.status}`);
}
return options.capture ? result.stdout.trim() : "";
}
function ensureOnMain(projectRoot) {
const currentBranch = runCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"], projectRoot, {
capture: true,
});
if (currentBranch !== "main") {
throw new Error(`Release workflow must run from main. Current branch: ${currentBranch}`);
}
}
function ensureCleanWorkingTree(projectRoot, message) {
const status = runCommand("git", ["status", "--porcelain", "--untracked-files=no"], projectRoot, {
capture: true,
});
if (status) {
throw new Error(message || "Working tree has tracked changes. Commit or stash them first.");
}
}
function ensureTagMissing(projectRoot, tagName) {
const result = spawnSync("git", ["rev-parse", "--verify", tagName], {
cwd: projectRoot,
stdio: "ignore",
});
if (result.status === 0) {
throw new Error(`Tag ${tagName} already exists.`);
}
}
function ensureTagExists(projectRoot, tagName) {
const result = spawnSync("git", ["rev-parse", "--verify", tagName], {
cwd: projectRoot,
stdio: "ignore",
});
if (result.status !== 0) {
throw new Error(`Tag ${tagName} does not exist. Run release:prepare first.`);
}
}
function ensureGithubReleaseMissing(projectRoot, tagName) {
const result = spawnSync("gh", ["release", "view", tagName], {
cwd: projectRoot,
stdio: "ignore",
});
if (result.status === 0) {
throw new Error(`GitHub release ${tagName} already exists.`);
}
}
function readPackageVersion(projectRoot) {
const packagePath = path.join(projectRoot, "package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
return packageJson.version;
}
function ensureChangelogSection(projectRoot, version) {
const changelogPath = path.join(projectRoot, "CHANGELOG.md");
const changelogContent = fs.readFileSync(changelogPath, "utf8");
return extractChangelogSection(changelogContent, version);
}
function writeReleaseNotes(projectRoot, version, sectionContent) {
const releaseNotesDir = path.join(projectRoot, ".tmp", "releases");
const notesPath = path.join(releaseNotesDir, `v${version}.md`);
fs.mkdirSync(releaseNotesDir, { recursive: true });
fs.writeFileSync(notesPath, sectionContent, "utf8");
return notesPath;
}
function runReleaseSuite(projectRoot) {
runCommand("npm", ["run", "validate"], projectRoot);
runCommand("npm", ["run", "validate:references"], projectRoot);
runCommand("npm", ["run", "sync:all"], projectRoot);
runCommand("npm", ["run", "test"], projectRoot);
runCommand("npm", ["run", "app:build"], projectRoot);
}
function runReleasePreflight(projectRoot) {
ensureOnMain(projectRoot);
ensureCleanWorkingTree(projectRoot, "release:preflight requires a clean tracked working tree.");
const version = readPackageVersion(projectRoot);
ensureChangelogSection(projectRoot, version);
runReleaseSuite(projectRoot);
ensureCleanWorkingTree(
projectRoot,
"release:preflight left tracked changes. Sync and commit them before releasing.",
);
console.log(`[release] Preflight passed for version ${version}.`);
}
function stageReleaseFiles(projectRoot, contract) {
const filesToStage = getManagedFiles(contract, {
includeMixed: true,
includeReleaseManaged: true,
});
runCommand("git", ["add", ...filesToStage], projectRoot);
}
function prepareRelease(projectRoot, version) {
if (!version) {
throw new Error("Usage: npm run release:prepare -- X.Y.Z");
}
ensureOnMain(projectRoot);
ensureCleanWorkingTree(projectRoot, "release:prepare requires a clean tracked working tree.");
ensureTagMissing(projectRoot, `v${version}`);
ensureChangelogSection(projectRoot, version);
const currentVersion = readPackageVersion(projectRoot);
if (currentVersion !== version) {
runCommand("npm", ["version", version, "--no-git-tag-version"], projectRoot);
} else {
console.log(`[release] package.json already set to ${version}; keeping current version.`);
}
runReleaseSuite(projectRoot);
runCommand(
"npm",
["run", "sync:metadata", "--", "--refresh-volatile"],
projectRoot,
);
const refreshedReleaseNotes = ensureChangelogSection(projectRoot, version);
const notesPath = writeReleaseNotes(projectRoot, version, refreshedReleaseNotes);
const contract = loadWorkflowContract(projectRoot);
stageReleaseFiles(projectRoot, contract);
const stagedFiles = runCommand("git", ["diff", "--cached", "--name-only"], projectRoot, {
capture: true,
});
if (!stagedFiles) {
throw new Error("release:prepare did not stage any files. Nothing to commit.");
}
runCommand("git", ["commit", "-m", `chore: release v${version}`], projectRoot);
runCommand("git", ["tag", `v${version}`], projectRoot);
console.log(`[release] Prepared v${version}.`);
console.log(`[release] Notes file: ${notesPath}`);
console.log(`[release] Next step: npm run release:publish -- ${version}`);
}
function publishRelease(projectRoot, version) {
if (!version) {
throw new Error("Usage: npm run release:publish -- X.Y.Z");
}
ensureOnMain(projectRoot);
ensureCleanWorkingTree(projectRoot, "release:publish requires a clean tracked working tree.");
const packageVersion = readPackageVersion(projectRoot);
if (packageVersion !== version) {
throw new Error(`package.json version ${packageVersion} does not match requested release ${version}.`);
}
const tagName = `v${version}`;
ensureTagExists(projectRoot, tagName);
ensureGithubReleaseMissing(projectRoot, tagName);
const tagCommit = runCommand("git", ["rev-list", "-n", "1", tagName], projectRoot, {
capture: true,
});
const headCommit = runCommand("git", ["rev-parse", "HEAD"], projectRoot, {
capture: true,
});
if (tagCommit !== headCommit) {
throw new Error(`${tagName} does not point at HEAD. Refusing to publish.`);
}
const notesPath = writeReleaseNotes(projectRoot, version, ensureChangelogSection(projectRoot, version));
runCommand("git", ["push", "origin", "main"], projectRoot);
runCommand("git", ["push", "origin", tagName], projectRoot);
runCommand("gh", ["release", "create", tagName, "--title", tagName, "--notes-file", notesPath], projectRoot);
console.log(`[release] Published ${tagName}.`);
}
function main() {
const args = parseArgs(process.argv.slice(2));
const projectRoot = findProjectRoot(__dirname);
if (args.command === "preflight") {
runReleasePreflight(projectRoot);
return;
}
if (args.command === "prepare") {
prepareRelease(projectRoot, args.version);
return;
}
if (args.command === "publish") {
publishRelease(projectRoot, args.version);
return;
}
throw new Error(
"Usage: node tools/scripts/release_workflow.js <preflight|prepare|publish> [X.Y.Z]",
);
}
try {
main();
} catch (error) {
console.error(`[release] ${error.message}`);
process.exit(1);
}

View File

@@ -10,6 +10,7 @@ const TOOL_TESTS = path.join(TOOL_SCRIPTS, "tests");
const LOCAL_TEST_COMMANDS = [
[path.join(TOOL_TESTS, "jetski_gemini_loader.test.js")],
[path.join(TOOL_TESTS, "validate_skills_headings.test.js")],
[path.join(TOOL_TESTS, "workflow_contracts.test.js")],
[path.join(TOOL_SCRIPTS, "run-python.js"), path.join(TOOL_TESTS, "test_validate_skills_headings.py")],
];
const NETWORK_TEST_COMMANDS = [

View File

@@ -0,0 +1,68 @@
const assert = require("assert");
const {
classifyChangedFiles,
extractChangelogSection,
getDirectDerivedChanges,
hasIssueLink,
hasQualityChecklist,
requiresReferencesValidation,
} = require("../../lib/workflow-contract");
const contract = {
derivedFiles: [
"CATALOG.md",
"skills_index.json",
"data/skills_index.json",
"data/catalog.json",
"data/bundles.json",
"data/aliases.json",
],
mixedFiles: ["README.md"],
releaseManagedFiles: ["CHANGELOG.md", "package.json", "package-lock.json", "README.md"],
};
const skillOnly = classifyChangedFiles(["skills/example/SKILL.md"], contract);
assert.deepStrictEqual(skillOnly.categories, ["skill"]);
assert.strictEqual(skillOnly.primaryCategory, "skill");
assert.strictEqual(requiresReferencesValidation(["skills/example/SKILL.md"], contract), false);
const docsOnly = classifyChangedFiles(["README.md", "docs/users/faq.md"], contract);
assert.deepStrictEqual(docsOnly.categories, ["docs"]);
assert.strictEqual(docsOnly.primaryCategory, "docs");
assert.strictEqual(requiresReferencesValidation(["README.md"], contract), true);
const infraChange = classifyChangedFiles([".github/workflows/ci.yml", "tools/scripts/pr_preflight.js"], contract);
assert.deepStrictEqual(infraChange.categories, ["infra"]);
assert.strictEqual(infraChange.primaryCategory, "infra");
assert.strictEqual(requiresReferencesValidation(["tools/scripts/pr_preflight.js"], contract), true);
const mixedChange = classifyChangedFiles(["skills/example/SKILL.md", "README.md"], contract);
assert.deepStrictEqual(mixedChange.categories, ["skill", "docs"]);
assert.strictEqual(mixedChange.primaryCategory, "skill");
assert.deepStrictEqual(
getDirectDerivedChanges(["skills/example/SKILL.md", "data/catalog.json"], contract),
["data/catalog.json"],
);
const changelog = [
"## [7.7.0] - 2026-03-13 - \"Merge Friction Reduction\"",
"",
"- Line one",
"",
"## [7.6.0] - 2026-03-01 - \"Older Release\"",
"",
"- Older line",
"",
].join("\n");
assert.strictEqual(
extractChangelogSection(changelog, "7.7.0"),
"## [7.7.0] - 2026-03-13 - \"Merge Friction Reduction\"\n\n- Line one\n",
);
assert.strictEqual(hasQualityChecklist("## Quality Bar Checklist\n- [x] Standards"), true);
assert.strictEqual(hasQualityChecklist("No template here"), false);
assert.strictEqual(hasIssueLink("Fixes #123"), true);
assert.strictEqual(hasIssueLink("Related to #123"), false);