Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions .github/scripts/check-codestory-release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
from __future__ import annotations

import argparse
import re
import sys
import tomllib
from pathlib import Path


SEMVER_RE = re.compile(
r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
r"(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?"
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
)


def read_toml(path: Path) -> dict:
with path.open("rb") as handle:
return tomllib.load(handle)


def workspace_members(root: Path) -> list[Path]:
manifest = read_toml(root / "Cargo.toml")
members = manifest.get("workspace", {}).get("members", [])
return [root / member / "Cargo.toml" for member in members]


def package_info(manifest_path: Path) -> tuple[str, str]:
manifest = read_toml(manifest_path)
package = manifest.get("package")
if not package:
raise ValueError(f"{manifest_path} does not contain a [package] section")
name = package.get("name")
version = package.get("version")
if not name or not version:
raise ValueError(f"{manifest_path} must declare package.name and package.version")
return name, version


def lock_packages(root: Path) -> dict[str, set[str]]:
lock = read_toml(root / "Cargo.lock")
packages: dict[str, set[str]] = {}
for package in lock.get("package", []):
name = package.get("name")
version = package.get("version")
if name and version and name.startswith("codestory-"):
packages.setdefault(name, set()).add(version)
return packages


def fail(message: str) -> None:
print(f"error: {message}", file=sys.stderr)
raise SystemExit(1)


def main() -> None:
parser = argparse.ArgumentParser(
description="Validate synchronized CodeStory release version surfaces.",
)
parser.add_argument("--version", required=True, help="Expected release version, without v prefix.")
parser.add_argument(
"--project-root",
default=".",
help="Repository root containing Cargo.toml and Cargo.lock.",
)
args = parser.parse_args()

expected = args.version.removeprefix("v")
if not SEMVER_RE.fullmatch(expected):
fail(f"version must be strict semver like 0.7.0, got {args.version!r}")

root = Path(args.project_root).resolve()
cli_manifest = root / "crates" / "codestory-cli" / "Cargo.toml"
cli_name, cli_version = package_info(cli_manifest)
if cli_name != "codestory-cli":
fail(f"{cli_manifest} package.name is {cli_name!r}, expected 'codestory-cli'")
if cli_version != expected:
fail(f"codestory-cli version is {cli_version}, expected {expected}")

workspace_versions: dict[str, str] = {}
for manifest_path in workspace_members(root):
name, version = package_info(manifest_path)
if not name.startswith("codestory-"):
continue
workspace_versions[name] = version
if version != expected:
fail(f"{manifest_path.relative_to(root)} is {version}, expected {expected}")

if "codestory-cli" not in workspace_versions:
fail("workspace members do not include codestory-cli")

lock_versions = lock_packages(root)
for name in sorted(workspace_versions):
versions = lock_versions.get(name)
if not versions:
fail(f"Cargo.lock does not contain package entry for {name}")
if versions != {expected}:
fail(f"Cargo.lock package {name} versions are {sorted(versions)}, expected {expected}")

extra_lock_mismatches = {
name: versions
for name, versions in lock_versions.items()
if name.startswith("codestory-") and versions != {expected}
}
if extra_lock_mismatches:
details = ", ".join(
f"{name}={sorted(versions)}" for name, versions in sorted(extra_lock_mismatches.items())
)
fail(f"Cargo.lock has CodeStory version mismatches: {details}")

print(
f"CodeStory release version {expected} is synchronized across "
f"{len(workspace_versions)} workspace crates and Cargo.lock."
)


if __name__ == "__main__":
main()
46 changes: 46 additions & 0 deletions .github/scripts/check-workflow-policy.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";

const workflowRoot = path.join(".github", "workflows");
const trustedOwners = new Set(["actions", "github"]);
const shaPattern = /^[0-9a-f]{40}$/i;
const violations = [];

for (const file of fs
.readdirSync(workflowRoot)
.filter((name) => name.endsWith(".yml") || name.endsWith(".yaml"))) {
const workflowPath = path.join(workflowRoot, file);
const content = fs.readFileSync(workflowPath, "utf8");

content.split(/\r?\n/).forEach((line, index) => {
const match = line.match(/\buses:\s*['"]?([^'"\s#]+)['"]?/);
if (!match) return;

const spec = match[1];
if (spec.startsWith("./")) return;

const at = spec.lastIndexOf("@");
if (at === -1) {
violations.push(`${file}:${index + 1} ${spec} is missing a ref`);
return;
}

const action = spec.slice(0, at);
const ref = spec.slice(at + 1);
const owner = action.split("/")[0];

if (!trustedOwners.has(owner) && !shaPattern.test(ref)) {
violations.push(
`${file}:${index + 1} ${spec} must pin third-party actions to a full-length SHA`,
);
}
});
}

if (violations.length > 0) {
console.error(violations.join("\n"));
process.exit(1);
}

console.log("Workflow policy passed: third-party actions are SHA-pinned.");
101 changes: 101 additions & 0 deletions .github/scripts/package-codestory-release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
from __future__ import annotations

import argparse
import hashlib
import shutil
import tarfile
import tempfile
import zipfile
from pathlib import Path


def copy_required_file(root: Path, relative: str, destination_root: Path) -> None:
source = root / relative
if not source.is_file():
raise FileNotFoundError(f"required release file is missing: {relative}")
destination = destination_root / relative
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, destination)


def copy_required_dir(root: Path, relative: str, destination_root: Path) -> None:
source = root / relative
if not source.is_dir():
raise FileNotFoundError(f"required release directory is missing: {relative}")
destination = destination_root / relative
if destination.exists():
shutil.rmtree(destination)
shutil.copytree(source, destination)


def archive_zip(source_dir: Path, archive_path: Path) -> None:
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
for path in sorted(source_dir.rglob("*")):
if path.is_file():
archive.write(path, path.relative_to(source_dir.parent).as_posix())


def archive_tar_gz(source_dir: Path, archive_path: Path) -> None:
with tarfile.open(archive_path, "w:gz") as archive:
archive.add(source_dir, arcname=source_dir.name)


def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()


def main() -> None:
parser = argparse.ArgumentParser(description="Package a CodeStory CLI release binary.")
parser.add_argument("--version", required=True, help="Release version without v prefix.")
parser.add_argument("--target", required=True, help="Asset target label.")
parser.add_argument("--binary", required=True, help="Built codestory-cli binary path.")
parser.add_argument("--out-dir", required=True, help="Directory for archive and checksum outputs.")
parser.add_argument("--project-root", default=".", help="Repository root.")
args = parser.parse_args()

root = Path(args.project_root).resolve()
binary = Path(args.binary).resolve()
if not binary.is_file():
raise FileNotFoundError(f"binary does not exist: {binary}")

out_dir = Path(args.out_dir).resolve()
out_dir.mkdir(parents=True, exist_ok=True)

archive_base = f"codestory-cli-v{args.version}-{args.target}"
archive_ext = ".zip" if "windows" in args.target.lower() else ".tar.gz"
archive_path = out_dir / f"{archive_base}{archive_ext}"

with tempfile.TemporaryDirectory(prefix="codestory-release-", dir=out_dir) as temp_dir:
stage_root = Path(temp_dir) / archive_base
stage_root.mkdir(parents=True)

binary_name = "codestory-cli.exe" if binary.suffix.lower() == ".exe" else "codestory-cli"
shutil.copy2(binary, stage_root / binary_name)

copy_required_file(root, "README.md", stage_root)
copy_required_file(root, "LICENSE", stage_root)
copy_required_file(root, "docs/usage.md", stage_root)
copy_required_dir(root, ".agents/skills/codestory-grounding", stage_root)

if archive_ext == ".zip":
archive_zip(stage_root, archive_path)
else:
archive_tar_gz(stage_root, archive_path)

checksum = sha256_file(archive_path)
checksum_line = f"{checksum} {archive_path.name}\n"
checksum_path = out_dir / f"{archive_path.name}.sha256"
checksum_path.write_text(checksum_line, encoding="utf-8", newline="\n")
(out_dir / "SHA256SUMS.txt").write_text(checksum_line, encoding="utf-8", newline="\n")

print(f"archive={archive_path}")
print(f"checksum={checksum_path}")


if __name__ == "__main__":
main()
121 changes: 121 additions & 0 deletions .github/workflows/auto-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
name: Auto Release

on:
push:
branches:
- main
paths:
- crates/**/Cargo.toml
- Cargo.lock
- CHANGELOG.md
- AGENTS.md
- docs/contributors/testing-matrix.md
- .github/workflows/auto-release.yml
- .github/workflows/release.yml
- .github/scripts/**

permissions:
contents: read

concurrency:
group: auto-release-${{ github.ref }}
cancel-in-progress: false

jobs:
workflow-policy:
name: Workflow policy
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Enforce third-party action pinning
run: node .github/scripts/check-workflow-policy.mjs

detect-version:
name: Detect codestory-cli version change
needs: workflow-policy
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
should_release: ${{ steps.version.outputs.should_release }}
version: ${{ steps.version.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Compare codestory-cli versions
id: version
env:
BEFORE_SHA: ${{ github.event.before }}
run: |
set -euo pipefail

python - <<'PY'
import os
import re
import subprocess
import sys
import tomllib
from pathlib import Path

semver = re.compile(
r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
r"(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?"
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
)
package_path = "crates/codestory-cli/Cargo.toml"

def read_version_bytes(data: bytes) -> str:
package = tomllib.loads(data.decode("utf-8")).get("package", {})
return str(package.get("version", ""))

new_version = read_version_bytes(Path(package_path).read_bytes())
before = os.environ.get("BEFORE_SHA", "")
old_version = ""
if before and not re.fullmatch(r"0+", before):
result = subprocess.run(
["git", "show", f"{before}:{package_path}"],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
)
if result.returncode == 0:
old_version = read_version_bytes(result.stdout)

print(f"Previous codestory-cli version: {old_version or '<missing>'}")
print(f"Current codestory-cli version: {new_version}")

with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
output.write(f"version={new_version}\n")
if old_version == new_version:
print("codestory-cli version did not change; skipping release.")
output.write("should_release=false\n")
sys.exit(0)

if not semver.fullmatch(new_version):
print(
f"::error::codestory-cli version must be strict semver, got {new_version!r}.",
file=sys.stderr,
)
sys.exit(1)

output.write("should_release=true\n")
PY

- name: Validate synchronized release version
if: steps.version.outputs.should_release == 'true'
run: python .github/scripts/check-codestory-release.py --version "${{ steps.version.outputs.version }}"

release:
name: Release
needs: detect-version
if: needs.detect-version.outputs.should_release == 'true'
permissions:
contents: write
uses: ./.github/workflows/release.yml
with:
version: ${{ needs.detect-version.outputs.version }}
Loading
Loading