Skip to content

[Spec] introduced pass key (scaffold) #1

[Spec] introduced pass key (scaffold)

[Spec] introduced pass key (scaffold) #1

Workflow file for this run

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