[Spec] introduced pass key (scaffold) #1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
name: Spec Lint | |
on: | |
pull_request: | |
paths: | |
- "_specs/**" | |
permissions: | |
contents: read | |
jobs: | |
lint: | |
runs-on: ubuntu-latest | |
steps: | |
- uses: actions/checkout@v4 | |
with: { fetch-depth: 0 } | |
- name: Get changed spec files | |
id: changed | |
uses: tj-actions/changed-files@v45 | |
with: | |
files: | | |
_specs/**.md | |
separator: "\n" | |
- name: Lint front-matter | |
if: steps.changed.outputs.all_changed_files != '' | |
run: | | |
set -euo pipefail | |
files="${{ steps.changed.outputs.all_changed_files }}" | |
echo "Changed files:"; printf '%s\n' "$files" | |
# helpers | |
get_fm() { awk 'NR==1&&$0=="---"{in=1;next} in{ if($0=="---"){exit} print }' "$1"; } | |
val() { printf '%s\n' "$2" | awk -F':[ \t]*' -v k="$1" 'BEGIN{IGNORECASE=1} $1==k{print $2; exit}'; } | |
err=0 | |
idx_changed=() # index.md files to check Epic uniqueness for | |
while IFS= read -r f; do | |
[ -f "$f" ] || continue | |
fm="$(get_fm "$f")" | |
layout=$(val layout "$fm") | |
[ "$layout" = "spec" ] || { echo "::error file=$f::layout must be 'spec'"; err=1; } | |
nav_title=$(val nav_title "$fm") | |
nav_order=$(val nav_order "$fm") | |
if [[ "$f" == _specs/*/*/index.md ]]; then | |
epic=$(val epic "$fm") | |
status=$(val status "$fm") | |
owner=$(val owner "$fm") | |
created_at=$(val created_at "$fm") | |
updated_at=$(val updated_at "$fm") | |
stage=$(val stage "$fm") | |
specv=$(val spec_version "$fm") | |
version_fm=$(val version "$fm") | |
# --- Required fields & formats --- | |
# Epic must exist and match MXOP-1234 | |
if ! [[ "$epic" =~ ^MXOP-[0-9]{4}$ ]]; then | |
echo "::error file=$f::epic missing/invalid (expected MXOP-1234)"; err=1; | |
fi | |
# status must be one of these (post-merge job will flip in-progress→active on first merge) | |
case "$status" in in-progress|active|deprecated) ;; *) | |
echo "::error file=$f::status must be in-progress|active|deprecated"; err=1;; | |
esac | |
# stage must be one of dev|stg|prod | |
case "$stage" in dev|stg|prod) ;; *) | |
echo "::error file=$f::stage must be one of dev|stg|prod"; err=1;; | |
esac | |
# spec_version must be SemVer X.Y.Z (no pre-release/build here) | |
if ! [[ "$specv" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then | |
echo "::error file=$f::spec_version must be SemVer like 1.0.0"; err=1; | |
fi | |
# owner required | |
[ -n "$owner" ] || { echo "::error file=$f::owner missing"; err=1; } | |
# dates: created_at required; updated_at may be blank pre-merge | |
if ! [[ "$created_at" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then | |
echo "::error file=$f::created_at must be YYYY-MM-DD"; err=1; | |
fi | |
if [ -n "$updated_at" ] && ! [[ "$updated_at" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then | |
echo "::error file=$f::updated_at must be YYYY-MM-DD or empty pre-merge"; err=1; | |
fi | |
# index.md must be Overview @ order 0 | |
[ "$nav_title" = "Overview" ] || { echo "::error file=$f::index.md nav_title must be 'Overview'"; err=1; } | |
[ "$nav_order" = "0" ] || { echo "::error file=$f::index.md nav_order must be 0"; err=1; } | |
# (Optional but recommended) Ensure folder major matches front-matter version | |
dir_ver=$(echo "$f" | sed -n 's#^_specs/[^/]\+/\(v[0-9]\+\)/index\.md$#\1#p') | |
if [ -n "$version_fm" ] && [ -n "$dir_ver" ] && [ "$version_fm" != "$dir_ver" ]; then | |
echo "::error file=$f::version in front-matter ('$version_fm') must match folder ('$dir_ver')"; err=1; | |
fi | |
# Soft consistency warnings (non-fatal) | |
if [ "$status" = "in-progress" ] && [ "$stage" != "dev" ]; then | |
echo "::warning file=$f::status is in-progress but stage is '$stage' (usually 'dev')"; | |
fi | |
if [ "$status" = "active" ] && [ "$stage" = "dev" ]; then | |
echo "::warning file=$f::status is active but stage is dev; consider stg/prod when appropriate"; | |
fi | |
idx_changed+=("$f") | |
else | |
# Non-index pages | |
[ -n "$nav_title" ] || { echo "::error file=$f::nav_title missing"; err=1; } | |
[[ "$nav_order" =~ ^[0-9]+$ ]] || { echo "::error file=$f::nav_order must be integer"; err=1; } | |
fi | |
done <<< "$files" | |
# Directory-level: unique nav_order within each spec space | |
for dir in $(echo "$files" | sed -n 's@^\(_specs/[^/]\+/[^/]\+\)/.*@\1@p' | sort -u); do | |
orders=$(grep -RhoE '^nav_order:\s*[0-9]+' "$dir"/*.md 2>/dev/null | awk '{print $2}') | |
dups=$(echo "$orders" | sort -n | uniq -d || true) | |
if [ -n "$dups" ]; then | |
echo "::error file=$dir::Duplicate nav_order values in $dir → $dups" | |
err=1 | |
fi | |
done | |
# Repo-level: Epic uniqueness against base branch (ignores same file path) | |
if [ ${#idx_changed[@]} -gt 0 ]; then | |
git fetch origin ${{ github.base_ref }} --depth=1 | |
for f in "${idx_changed[@]}"; do | |
fm="$(get_fm "$f")" | |
epic=$(val epic "$fm") | |
[ -z "$epic" ] && continue | |
matches=$(git grep -n --fixed-strings -e "epic: ${epic}" "origin/${{ github.base_ref }}" -- _specs/ | cut -d: -f2 | sort -u || true) | |
for m in $matches; do | |
if [ "$m" != "$f" ]; then | |
echo "::error file=$f::Epic '${epic}' already exists at '$m' on ${{ github.base_ref }}"; err=1 | |
fi | |
done | |
done | |
fi | |
exit $err | |