Skip to content

docs: sync Fallow 3.16 examples #566

docs: sync Fallow 3.16 examples

docs: sync Fallow 3.16 examples #566

Workflow file for this run

name: Validate
on:
workflow_dispatch:
push:
branches: [main]
tags: ["v*.*.*"]
pull_request:
branches: [main]
permissions: {}
jobs:
validate:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
fetch-depth: 0
persist-credentials: false
# PyYAML reaches the runner image transitively rather than as a declared
# package, so treat it as a job dependency instead of a side effect of
# whichever step happens to need it first. Installing into the user site
# leaves the system distribution in place: it ships no RECORD file, so
# pip cannot uninstall it and an upgrade in place would fail the job.
- name: Install Python dependencies
run: |
python3 -c 'import yaml' 2>/dev/null ||
python3 -m pip install --quiet --disable-pip-version-check \
--user --break-system-packages pyyaml
python3 -c 'import yaml; print("PyYAML", yaml.__version__, "from", yaml.__file__)'
- name: Validate JSON files
run: |
echo "Validating JSON files..."
while IFS= read -r -d '' f; do
echo " Checking $f"
python3 -c 'import json, sys; json.load(open(sys.argv[1]))' "$f"
done < <(find . -name "*.json" -not -path "./node_modules/*" -print0)
echo "All JSON files are valid."
- name: Validate SKILL.md frontmatter
run: python3 scripts/validate_skill_frontmatter.py .
- name: Validate skills.sh discovery
run: |
mkdir -p "$RUNNER_TEMP/skills-home" "$RUNNER_TEMP/skills-config" "$RUNNER_TEMP/skills-cache"
HOME="$RUNNER_TEMP/skills-home" \
XDG_CONFIG_HOME="$RUNNER_TEMP/skills-config" \
XDG_CACHE_HOME="$RUNNER_TEMP/skills-cache" \
npx -y skills@1.5.3 add . --list
- name: Validate Claude plugin
run: npx -y @anthropic-ai/claude-code@2.1.218 plugin validate . --strict
- name: Validate Codex plugin
run: |
python3 - <<'PY'
import json
import struct
from pathlib import Path
root = Path("fallow")
manifest = json.loads((root / ".codex-plugin/plugin.json").read_text())
interface = manifest["interface"]
assert manifest["name"] == "fallow"
assert manifest["skills"] == "./skills/"
assert len(manifest["name"]) <= 64
assert len(manifest["description"]) <= 1024
assert len(manifest["author"]["name"]) <= 120
assert len(interface["displayName"]) <= 30
assert "\n" not in interface["shortDescription"]
assert len(interface["shortDescription"]) <= 30
assert len(interface["longDescription"]) <= 4000
assert len(interface["developerName"]) <= 80
assert interface["category"] == "Developer Tools"
assert len(interface["capabilities"]) <= 20
assert 1 <= len(interface["defaultPrompt"]) <= 3
assert all(len(prompt) <= 128 for prompt in interface["defaultPrompt"])
for field in ("composerIcon", "logo"):
asset = root / interface[field]
assert asset.is_file(), f"Missing {field}: {asset}"
assert asset.stat().st_size <= 5 * 1024 * 1024
with asset.open("rb") as handle:
assert handle.read(8) == b"\x89PNG\r\n\x1a\n"
handle.read(8)
width, height = struct.unpack(">II", handle.read(8))
assert width == height
assert 48 <= width <= 4096
marketplace = json.loads(Path(".agents/plugins/marketplace.json").read_text())
entry = marketplace["plugins"][0]
assert entry["name"] == manifest["name"]
assert entry["source"] == {"source": "local", "path": "./fallow"}
assert entry["policy"]["installation"] == "AVAILABLE"
unsupported = [
path for path in root.rglob("*")
if path.name in {".mcp.json", ".app.json"}
]
assert not unsupported, f"Unsupported skills-only files: {unsupported}"
for skill in ("fallow", "fallow-review"):
skill_root = root / "skills" / skill
frontmatter = (skill_root / "SKILL.md").read_text().split("---", 2)[1]
assert "\nmetadata:" not in frontmatter
openai_config = skill_root / "agents/openai.yaml"
assert openai_config.is_file(), f"Missing Codex skill interface: {openai_config}"
config_text = openai_config.read_text()
for field in ("interface:", "display_name:", "short_description:", "default_prompt:"):
assert field in config_text, f"Missing {field} in {openai_config}"
PY
- name: Test plugin release tooling
run: python3 -m unittest discover -s scripts -p 'test_*.py'
- name: Build and inspect OpenAI submission archive
id: package
env:
REF_NAME: ${{ github.ref_name }}
REF_TYPE: ${{ github.ref_type }}
run: |
expected_args=()
if [ "$REF_TYPE" = "tag" ]; then
expected_args=(--expected-version "${REF_NAME#v}")
fi
archive=$(python3 scripts/plugin_release.py package "${expected_args[@]}")
archive_name=$(basename "$archive" .zip)
echo "archive=$archive" >> "$GITHUB_OUTPUT"
echo "archive_name=$archive_name" >> "$GITHUB_OUTPUT"
ARCHIVE="$archive" python3 - <<'PY'
import os
from pathlib import Path
from zipfile import ZipFile
archive = Path(os.environ["ARCHIVE"])
with ZipFile(archive) as bundle:
names = bundle.namelist()
assert ".codex-plugin/plugin.json" in names
assert any(name.startswith("skills/") and name.endswith("/SKILL.md") for name in names)
assert ".claude-plugin/plugin.json" not in names
assert not any(name.startswith(("bin/", "commands/")) for name in names)
assert not any(name.startswith("/") or ".." in Path(name).parts for name in names)
assert not any(name.endswith((".mcp.json", ".app.json")) for name in names)
assert archive.stat().st_size <= 100_000_000
PY
- name: Check for hardcoded paths
run: |
echo "Checking for hardcoded paths..."
if grep -rn "/Users/" --include="*.md" --include="*.json" .; then
echo "ERROR: Found hardcoded user paths"
exit 1
fi
echo "No hardcoded paths found."
- name: Check version sync
run: python3 scripts/plugin_release.py check
- name: Require a version bump for plugin content changes
if: github.ref_type != 'tag' && github.event_name != 'workflow_dispatch'
env:
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
run: |
set -euo pipefail
if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then
echo "No comparison commit is available; skipping content version check."
exit 0
fi
if git diff --quiet "$BASE_SHA" "$GITHUB_SHA" -- \
fallow .claude-plugin/marketplace.json; then
echo "No public plugin content changed."
exit 0
fi
base_version=$(git show "$BASE_SHA:fallow/.codex-plugin/plugin.json" |
python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])')
current_version=$(python3 scripts/plugin_release.py newer-than "$base_version")
echo "Plugin version changed from $base_version to $current_version."
- name: Test JavaScript tooling
run: node --test scripts/*.test.mjs
- name: Read pinned Fallow source revision
id: source-lock
run: |
node -e 'process.stdout.write("commit=" + require("./source-lock.json").commit + "\n")' \
>> "$GITHUB_OUTPUT"
- name: Checkout pinned public Fallow source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
repository: fallow-rs/fallow
ref: ${{ steps.source-lock.outputs.commit }}
path: .source/fallow
persist-credentials: false
- name: Verify public skill source and privacy boundary
env:
FALLOW_SOURCE_DIR: ${{ github.workspace }}/.source/fallow
run: node scripts/check-source-contract.mjs
- name: Upload OpenAI submission archive
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ steps.package.outputs.archive_name }}
path: ${{ steps.package.outputs.archive }}
if-no-files-found: error
compression-level: 0
retention-days: 30
publish-release:
if: startsWith(github.ref, 'refs/tags/v')
needs: validate
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Verify tag belongs to main
run: |
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
echo "::error::Release tags must point to a commit on main"
exit 1
fi
- name: Build tagged OpenAI submission archive
id: package
env:
RELEASE_TAG: ${{ github.ref_name }}
run: |
archive=$(python3 scripts/plugin_release.py package \
--expected-version "${RELEASE_TAG#v}")
echo "archive=$archive" >> "$GITHUB_OUTPUT"
echo "archive_file=$(basename "$archive")" >> "$GITHUB_OUTPUT"
- name: Publish immutable-by-convention release asset
env:
ARCHIVE: ${{ steps.package.outputs.archive }}
ARCHIVE_FILE: ${{ steps.package.outputs.archive_file }}
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
if ! gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
release_args=()
if [ "$(python3 scripts/plugin_release.py \
is-prerelease "${RELEASE_TAG#v}")" = "true" ]; then
release_args+=(--prerelease)
fi
gh release create "$RELEASE_TAG" \
"$ARCHIVE#OpenAI skills-only submission ZIP" \
--verify-tag \
--title "$RELEASE_TAG" \
--generate-notes \
"${release_args[@]}"
exit 0
fi
if gh release view "$RELEASE_TAG" --json assets \
--jq '.assets[].name' | grep -Fxq "$ARCHIVE_FILE"; then
existing_dir=$(mktemp -d)
gh release download "$RELEASE_TAG" \
--pattern "$ARCHIVE_FILE" \
--dir "$existing_dir"
cmp "$ARCHIVE" "$existing_dir/$ARCHIVE_FILE" || {
echo "::error::Existing release asset differs from the tagged source"
exit 1
}
echo "Release asset already exists and is byte-identical."
else
gh release upload "$RELEASE_TAG" \
"$ARCHIVE#OpenAI skills-only submission ZIP"
fi