Skip to content

Prepare WinUI plugin for WinApp CLI 0.7 #211

Prepare WinUI plugin for WinApp CLI 0.7

Prepare WinUI plugin for WinApp CLI 0.7 #211

Workflow file for this run

name: Release policy
# Mechanical gates (Tier 1) for the PR / staging / main release flow.
# See CONTRIBUTING.md and RELEASING.md for the human-side flow.
#
# Job matrix:
# pr-target-policy PR → main | head_ref must be staging, release/*, or hotfix/*
# version-sync PR → staging | NO version field changed (skipped for backmerge/*)
# version-bump PR → main | all 5 version fields bumped, in sync, valid semver, strictly greater
# changelog-entry PR → main | top-most CHANGELOG section matches new version, has bullets
# staging-up-to-date PR → staging | PR head contains every commit on main
# (passes naturally for backmerge/* PRs)
on:
pull_request:
branches: [main, staging]
types: [opened, synchronize, reopened, edited]
permissions:
contents: read
pull-requests: read
jobs:
pr-target-policy:
name: PR target policy
if: github.event.pull_request.base.ref == 'main'
runs-on: ubuntu-latest
steps:
- name: Verify head branch is staging, release/*, or hotfix/*
env:
HEAD_REF: ${{ github.event.pull_request.head.ref }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
BASE_REPO: ${{ github.repository }}
shell: bash
run: |
set -euo pipefail
# Lock down: only branches in the same repo (no fork PRs) may target main.
# Reason: prevents a fork from naming its branch "staging" to bypass.
if [[ "$HEAD_REPO" != "$BASE_REPO" ]]; then
echo "::error::PRs targeting 'main' must originate from a branch in this repo, not a fork ($HEAD_REPO)."
exit 1
fi
if [[ "$HEAD_REF" == "staging" || "$HEAD_REF" == release/* || "$HEAD_REF" == hotfix/* ]]; then
echo "::notice::Head branch '$HEAD_REF' is allowed to target main."
exit 0
fi
echo "::error::PRs targeting 'main' must come from 'staging', 'release/*', or 'hotfix/*'."
echo "::error::This PR's head branch is '$HEAD_REF'. Please re-target the PR at 'staging'."
echo "::error::See CONTRIBUTING.md for the branch model."
exit 1
version-sync:
name: No version bump in feature PR
# Skip on backmerge/* PRs: those legitimately carry main's version-bump
# commit forward into staging. See CONTRIBUTING.md § Back-merge path.
if: >
github.event.pull_request.base.ref == 'staging' &&
!startsWith(github.event.pull_request.head.ref, 'backmerge/')
runs-on: ubuntu-latest
steps:
- name: Checkout (full depth)
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }}
- name: Diff version fields against staging
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
shell: bash
run: |
set -euo pipefail
# Compare parsed version values rather than changed lines. This keeps
# package-layout migrations from looking like version bumps when a
# manifest moves without changing its version.
read_base_plugin_version() {
if git cat-file -e "$BASE_SHA:plugins/winui/agent-plugin/plugin.json" 2>/dev/null; then
git show "$BASE_SHA:plugins/winui/agent-plugin/plugin.json" | jq -r '.version'
else
# One-time migration fallback for PRs whose base still uses the
# pre-Agent-Plugins package root.
git show "$BASE_SHA:plugins/winui/plugin.json" | jq -r '.version'
fi
}
HEAD_VERSIONS=(
"$(jq -r '.version' plugins/winui/agent-plugin/plugin.json)"
"$(jq -r '.metadata.version' .github/plugin/marketplace.json)"
"$(jq -r '.plugins[0].version' .github/plugin/marketplace.json)"
"$(jq -r '.version' .claude-plugin/marketplace.json)"
"$(jq -r '.plugins[0].version' .claude-plugin/marketplace.json)"
"$(jq -r '.version' plugins/winui/.claude-plugin/plugin.json)"
"$(jq -r '.version' plugins/winui/.codex-plugin/plugin.json)"
)
BASE_VERSIONS=(
"$(read_base_plugin_version)"
"$(git show "$BASE_SHA":.github/plugin/marketplace.json | jq -r '.metadata.version')"
"$(git show "$BASE_SHA":.github/plugin/marketplace.json | jq -r '.plugins[0].version')"
"$(git show "$BASE_SHA":.claude-plugin/marketplace.json | jq -r '.version')"
"$(git show "$BASE_SHA":.claude-plugin/marketplace.json | jq -r '.plugins[0].version')"
"$(git show "$BASE_SHA":plugins/winui/.claude-plugin/plugin.json | jq -r '.version')"
"$(git show "$BASE_SHA":plugins/winui/.codex-plugin/plugin.json | jq -r '.version')"
)
LABELS=(
"portable plugin"
"GitHub marketplace metadata"
"GitHub marketplace plugin"
"Claude marketplace"
"Claude marketplace plugin"
"Claude adapter plugin"
"Codex adapter plugin"
)
FAILED=0
for i in "${!HEAD_VERSIONS[@]}"; do
if [[ "${HEAD_VERSIONS[$i]}" != "${BASE_VERSIONS[$i]}" ]]; then
echo "::error::${LABELS[$i]} version changed from '${BASE_VERSIONS[$i]}' to '${HEAD_VERSIONS[$i]}'. Versioning is release-only — only the staging → main promotion PR may bump versions."
FAILED=1
fi
done
if [[ "$FAILED" -ne 0 ]]; then
echo "::error::See CONTRIBUTING.md § 'Things you should NOT do in a feature PR'."
exit 1
fi
echo "::notice::No version fields modified — OK."
staging-up-to-date:
name: staging is caught up with main
if: github.event.pull_request.base.ref == 'staging'
runs-on: ubuntu-latest
steps:
- name: Checkout (full depth)
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Verify PR head contains every commit on main
env:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
shell: bash
run: |
set -euo pipefail
git fetch origin main --quiet
# We check the PR head (not origin/staging) so back-merge PRs — which
# bring main into staging — pass naturally: after they merge, staging
# will contain main. Any other PR that doesn't already include main
# will fail here exactly as it would have under the old "staging vs
# main" check.
MISSING=$(git rev-list --count origin/main ^"$HEAD_SHA")
if [[ "$MISSING" -gt 0 ]]; then
echo "::error::PR head ('$HEAD_REF') is missing $MISSING commit(s) from 'main'."
echo "::error::This usually means a hotfix landed on main and was not back-merged into staging,"
echo "::error::and this PR was branched from a stale staging."
echo "::error::Fix: open a back-merge PR first."
echo "::error:: git checkout -b backmerge/<topic> origin/staging"
echo "::error:: git merge origin/main"
echo "::error:: git push -u origin backmerge/<topic>"
echo "::error:: gh pr create --base staging --head backmerge/<topic>"
echo "::error::Then rebase this PR on the updated staging. See CONTRIBUTING.md § Back-merge path."
exit 1
fi
echo "::notice::PR head contains every commit on 'main' — OK."
version-bump:
name: Version bump (5 fields in sync)
if: github.event.pull_request.base.ref == 'main'
runs-on: ubuntu-latest
steps:
- name: Checkout (full depth)
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }}
- name: Read PR-side and main-side versions and validate
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
shell: bash
run: |
set -euo pipefail
# Extract every version field from PR head and from main.
# PR-head versions:
PR_PLUGIN=$(jq -r '.version' plugins/winui/agent-plugin/plugin.json)
PR_GH_META=$(jq -r '.metadata.version' .github/plugin/marketplace.json)
PR_GH_PLUGIN=$(jq -r '.plugins[0].version' .github/plugin/marketplace.json)
PR_CLAUDE_TOP=$(jq -r '.version' .claude-plugin/marketplace.json)
PR_CLAUDE_PLUGIN=$(jq -r '.plugins[0].version' .claude-plugin/marketplace.json)
PR_CLAUDE_ADAPTER=$(jq -r '.version' plugins/winui/.claude-plugin/plugin.json)
PR_CODEX_ADAPTER=$(jq -r '.version' plugins/winui/.codex-plugin/plugin.json)
# main-side versions: read all 7 and require they're already in sync
# (defends against a previously-bad release leaving main inconsistent).
if git cat-file -e "$BASE_SHA:plugins/winui/agent-plugin/plugin.json" 2>/dev/null; then
MAIN_PLUGIN=$(git show "$BASE_SHA":plugins/winui/agent-plugin/plugin.json | jq -r '.version')
else
# The first promotion after the package-layout migration still has
# the portable manifest at its former path on main.
MAIN_PLUGIN=$(git show "$BASE_SHA":plugins/winui/plugin.json | jq -r '.version')
fi
MAIN_GH_META=$(git show "$BASE_SHA":.github/plugin/marketplace.json | jq -r '.metadata.version')
MAIN_GH_PLUGIN=$(git show "$BASE_SHA":.github/plugin/marketplace.json | jq -r '.plugins[0].version')
MAIN_CLAUDE_TOP=$(git show "$BASE_SHA":.claude-plugin/marketplace.json | jq -r '.version')
MAIN_CLAUDE_PLUGIN=$(git show "$BASE_SHA":.claude-plugin/marketplace.json | jq -r '.plugins[0].version')
MAIN_CLAUDE_ADAPTER=$(git show "$BASE_SHA":plugins/winui/.claude-plugin/plugin.json | jq -r '.version')
MAIN_CODEX_ADAPTER=$(git show "$BASE_SHA":plugins/winui/.codex-plugin/plugin.json | jq -r '.version')
MAIN_ALL=("$MAIN_PLUGIN" "$MAIN_GH_META" "$MAIN_GH_PLUGIN" "$MAIN_CLAUDE_TOP" "$MAIN_CLAUDE_PLUGIN" "$MAIN_CLAUDE_ADAPTER" "$MAIN_CODEX_ADAPTER")
# Advisory only, intentionally non-fatal: Rule 1 below already forces
# every PR-head field into sync, so a corrective hotfix PR (which by
# definition targets an already-inconsistent main) must still be able
# to pass this job. Failing here would deadlock exactly the PR meant
# to fix the drift it detects.
for v in "${MAIN_ALL[@]}"; do
if [[ "$v" != "$MAIN_PLUGIN" ]]; then
echo "::warning::main-side version fields are out of sync: ${MAIN_ALL[*]}"
echo "::warning::main was left in an inconsistent state by a previous release. This PR's Rule 1 check (below) still requires all 7 PR-head fields to match, so merging this PR will fix it."
break
fi
done
echo "PR head: plugin=$PR_PLUGIN gh.meta=$PR_GH_META gh.plugin=$PR_GH_PLUGIN claude.top=$PR_CLAUDE_TOP claude.plugin=$PR_CLAUDE_PLUGIN claude.adapter=$PR_CLAUDE_ADAPTER codex.adapter=$PR_CODEX_ADAPTER"
echo "main: plugin=$MAIN_PLUGIN gh.meta=$MAIN_GH_META gh.plugin=$MAIN_GH_PLUGIN claude.top=$MAIN_CLAUDE_TOP claude.plugin=$MAIN_CLAUDE_PLUGIN claude.adapter=$MAIN_CLAUDE_ADAPTER codex.adapter=$MAIN_CODEX_ADAPTER"
# Rule 1: all 7 PR-head versions identical.
ALL=("$PR_PLUGIN" "$PR_GH_META" "$PR_GH_PLUGIN" "$PR_CLAUDE_TOP" "$PR_CLAUDE_PLUGIN" "$PR_CLAUDE_ADAPTER" "$PR_CODEX_ADAPTER")
for v in "${ALL[@]}"; do
if [[ "$v" != "$PR_PLUGIN" ]]; then
echo "::error::Version fields are out of sync. All 7 fields must match. Got: ${ALL[*]}"
echo "::error::Files to fix: plugins/winui/agent-plugin/plugin.json, .github/plugin/marketplace.json, .claude-plugin/marketplace.json, plugins/winui/.claude-plugin/plugin.json, plugins/winui/.codex-plugin/plugin.json"
exit 1
fi
done
# Rule 2: valid semver (X.Y.Z). Prereleases are NOT supported in this
# release flow — they require real SemVer precedence comparison and
# we don't need them yet. If you need one, extend this check first.
if [[ ! "$PR_PLUGIN" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Version '$PR_PLUGIN' is not valid semver (X.Y.Z). Prereleases are not supported by this flow."
exit 1
fi
# Rule 3: strictly greater than main.
# Compare via sort -V; if the larger of the two equals the PR version
# AND the two differ, the PR is greater.
if [[ "$PR_PLUGIN" == "$MAIN_PLUGIN" ]]; then
echo "::error::Version was not bumped: still '$PR_PLUGIN' (same as main)."
echo "::error::Promotion PRs MUST bump the version. See RELEASING.md."
exit 1
fi
LARGER=$(printf '%s\n%s\n' "$PR_PLUGIN" "$MAIN_PLUGIN" | sort -V | tail -n1)
if [[ "$LARGER" != "$PR_PLUGIN" ]]; then
echo "::error::Version '$PR_PLUGIN' is not strictly greater than main's '$MAIN_PLUGIN'."
exit 1
fi
echo "VERSION=$PR_PLUGIN" >> "$GITHUB_ENV"
echo "::notice::Version bump $MAIN_PLUGIN → $PR_PLUGIN, all 5 fields in sync."
changelog-entry:
name: CHANGELOG entry matches new version
if: github.event.pull_request.base.ref == 'main'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Verify CHANGELOG has a top-most section for the bumped version
shell: bash
run: |
set -euo pipefail
if [[ ! -f CHANGELOG.md ]]; then
echo "::error file=CHANGELOG.md::CHANGELOG.md not found."
exit 1
fi
VERSION=$(jq -r '.version' plugins/winui/agent-plugin/plugin.json)
# The first H2 that is NOT '## [Unreleased]' must match the bumped
# version, and have at least one bullet line under it.
# Extract sections in order.
SECTION=$(awk '
/^## \[Unreleased\]/ { in_unreleased=1; next }
/^## \[/ && !in_unreleased { print; exit }
/^## \[/ && in_unreleased { in_unreleased=0; print; exit }
' CHANGELOG.md)
if [[ -z "$SECTION" ]]; then
echo "::error file=CHANGELOG.md::No '## [X.Y.Z]' section found below '## [Unreleased]'."
echo "::error::Add: ## [$VERSION] — $(date -u +%Y-%m-%d)"
exit 1
fi
# SECTION looks like: ## [0.4.0] — 2026-05-13
# Accept hyphen, double-hyphen (helper script ASCII fallback),
# en dash, or em dash as the separator.
if ! grep -qE "^## \[$(echo "$VERSION" | sed 's/\./\\./g')\][[:space:]]+(--?|—|–)[[:space:]]+[0-9]{4}-[0-9]{2}-[0-9]{2}" <<< "$SECTION"; then
echo "::error file=CHANGELOG.md::Top-most release section is '$SECTION' but expected '## [$VERSION] — YYYY-MM-DD' (or '-', '--', '–' as separator)."
echo "::error::Either fix the CHANGELOG header or fix the version bump."
exit 1
fi
# Verify there's at least one non-empty bullet under the new version
# before the next H2.
BULLETS=$(awk -v ver="$VERSION" '
$0 == "## [" ver "]" || $0 ~ "^## \\[" ver "\\][[:space:]]" { in_v=1; next }
in_v && /^## \[/ { exit }
in_v && /^[[:space:]]*-[[:space:]]+\S/ { count++ }
END { print count+0 }
' CHANGELOG.md)
if [[ "$BULLETS" -lt 1 ]]; then
echo "::error file=CHANGELOG.md::Section '## [$VERSION]' has no bullet entries."
echo "::error::Add at least one '- ...' line describing what changed."
exit 1
fi
echo "::notice::CHANGELOG section '## [$VERSION]' present with $BULLETS bullet(s)."