agex-cli 0.11.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agex_cli-0.11.0/.flake8 +8 -0
- agex_cli-0.11.0/.github/workflows/docs.yml +130 -0
- agex_cli-0.11.0/.github/workflows/publish.yml +376 -0
- agex_cli-0.11.0/.github/workflows/test.yml +106 -0
- agex_cli-0.11.0/.gitignore +213 -0
- agex_cli-0.11.0/.python-version +1 -0
- agex_cli-0.11.0/CHANGELOG.md +289 -0
- agex_cli-0.11.0/CLAUDE.md +92 -0
- agex_cli-0.11.0/LICENSE +21 -0
- agex_cli-0.11.0/PKG-INFO +56 -0
- agex_cli-0.11.0/README.md +29 -0
- agex_cli-0.11.0/docs/.gitignore +6 -0
- agex_cli-0.11.0/docs/Gemfile +8 -0
- agex_cli-0.11.0/docs/_config.yml +39 -0
- agex_cli-0.11.0/docs/_sass/color_schemes/anthropic.scss +34 -0
- agex_cli-0.11.0/docs/_sass/color_schemes/dark-terminal.scss +40 -0
- agex_cli-0.11.0/docs/_sass/custom/custom.scss +348 -0
- agex_cli-0.11.0/docs/commands/explain.md +27 -0
- agex_cli-0.11.0/docs/commands/gamify.md +32 -0
- agex_cli-0.11.0/docs/commands/hook.md +32 -0
- agex_cli-0.11.0/docs/commands/index.md +10 -0
- agex_cli-0.11.0/docs/commands/learn.md +22 -0
- agex_cli-0.11.0/docs/commands/overview.md +32 -0
- agex_cli-0.11.0/docs/getting-started.md +39 -0
- agex_cli-0.11.0/docs/index.md +22 -0
- agex_cli-0.11.0/docs/superpowers/plans/2026-04-18-agex-v0.1.md +3920 -0
- agex_cli-0.11.0/docs/superpowers/specs/2026-04-18-agex-design.md +442 -0
- agex_cli-0.11.0/pyproject.toml +60 -0
- agex_cli-0.11.0/scripts/sync_skill_md.py +80 -0
- agex_cli-0.11.0/sonar-project.properties +46 -0
- agex_cli-0.11.0/src/agent_experience/__init__.py +3 -0
- agex_cli-0.11.0/src/agent_experience/__main__.py +4 -0
- agex_cli-0.11.0/src/agent_experience/backends/__init__.py +0 -0
- agex_cli-0.11.0/src/agent_experience/backends/acp/__init__.py +0 -0
- agex_cli-0.11.0/src/agent_experience/backends/acp/probe.py +9 -0
- agex_cli-0.11.0/src/agent_experience/backends/capabilities/acp.yaml +7 -0
- agex_cli-0.11.0/src/agent_experience/backends/capabilities/claude-code.yaml +4 -0
- agex_cli-0.11.0/src/agent_experience/backends/capabilities/codex.yaml +7 -0
- agex_cli-0.11.0/src/agent_experience/backends/capabilities/copilot.yaml +7 -0
- agex_cli-0.11.0/src/agent_experience/backends/claude_code/__init__.py +0 -0
- agex_cli-0.11.0/src/agent_experience/backends/claude_code/probe.py +97 -0
- agex_cli-0.11.0/src/agent_experience/backends/codex/__init__.py +0 -0
- agex_cli-0.11.0/src/agent_experience/backends/codex/probe.py +16 -0
- agex_cli-0.11.0/src/agent_experience/backends/copilot/__init__.py +0 -0
- agex_cli-0.11.0/src/agent_experience/backends/copilot/probe.py +9 -0
- agex_cli-0.11.0/src/agent_experience/cli.py +170 -0
- agex_cli-0.11.0/src/agent_experience/commands/__init__.py +0 -0
- agex_cli-0.11.0/src/agent_experience/commands/explain/SKILL.md +26 -0
- agex_cli-0.11.0/src/agent_experience/commands/explain/__init__.py +0 -0
- agex_cli-0.11.0/src/agent_experience/commands/explain/assets/topics/agex.md +35 -0
- agex_cli-0.11.0/src/agent_experience/commands/explain/references/.gitkeep +0 -0
- agex_cli-0.11.0/src/agent_experience/commands/explain/scripts/__init__.py +0 -0
- agex_cli-0.11.0/src/agent_experience/commands/explain/scripts/explain.py +63 -0
- agex_cli-0.11.0/src/agent_experience/commands/gamify/SKILL.md +31 -0
- agex_cli-0.11.0/src/agent_experience/commands/gamify/__init__.py +0 -0
- agex_cli-0.11.0/src/agent_experience/commands/gamify/assets/hooks/claude-code.json +28 -0
- agex_cli-0.11.0/src/agent_experience/commands/gamify/references/.gitkeep +0 -0
- agex_cli-0.11.0/src/agent_experience/commands/gamify/scripts/__init__.py +0 -0
- agex_cli-0.11.0/src/agent_experience/commands/gamify/scripts/install.py +196 -0
- agex_cli-0.11.0/src/agent_experience/commands/hook/SKILL.md +31 -0
- agex_cli-0.11.0/src/agent_experience/commands/hook/__init__.py +0 -0
- agex_cli-0.11.0/src/agent_experience/commands/hook/assets/table.md.j2 +17 -0
- agex_cli-0.11.0/src/agent_experience/commands/hook/references/.gitkeep +0 -0
- agex_cli-0.11.0/src/agent_experience/commands/hook/scripts/__init__.py +0 -0
- agex_cli-0.11.0/src/agent_experience/commands/hook/scripts/read.py +38 -0
- agex_cli-0.11.0/src/agent_experience/commands/hook/scripts/write.py +24 -0
- agex_cli-0.11.0/src/agent_experience/commands/learn/SKILL.md +21 -0
- agex_cli-0.11.0/src/agent_experience/commands/learn/__init__.py +0 -0
- agex_cli-0.11.0/src/agent_experience/commands/learn/assets/menu.md.j2 +7 -0
- agex_cli-0.11.0/src/agent_experience/commands/learn/assets/topics/gamify/SKILL.md +35 -0
- agex_cli-0.11.0/src/agent_experience/commands/learn/assets/topics/gamify/assets/skill-template/claude-code/SKILL.md +22 -0
- agex_cli-0.11.0/src/agent_experience/commands/learn/assets/topics/introspect/SKILL.md +41 -0
- agex_cli-0.11.0/src/agent_experience/commands/learn/assets/topics/introspect/assets/skill-template/claude-code/SKILL.md +22 -0
- agex_cli-0.11.0/src/agent_experience/commands/learn/assets/topics/levelup/SKILL.md +31 -0
- agex_cli-0.11.0/src/agent_experience/commands/learn/assets/topics/levelup/assets/skill-template/claude-code/SKILL.md +22 -0
- agex_cli-0.11.0/src/agent_experience/commands/learn/assets/topics/visualize/SKILL.md +27 -0
- agex_cli-0.11.0/src/agent_experience/commands/learn/assets/topics/visualize/assets/skill-template/claude-code/SKILL.md +19 -0
- agex_cli-0.11.0/src/agent_experience/commands/learn/references/.gitkeep +0 -0
- agex_cli-0.11.0/src/agent_experience/commands/learn/scripts/__init__.py +0 -0
- agex_cli-0.11.0/src/agent_experience/commands/learn/scripts/learn.py +72 -0
- agex_cli-0.11.0/src/agent_experience/commands/overview/SKILL.md +31 -0
- agex_cli-0.11.0/src/agent_experience/commands/overview/__init__.py +0 -0
- agex_cli-0.11.0/src/agent_experience/commands/overview/assets/backends/acp.yaml +7 -0
- agex_cli-0.11.0/src/agent_experience/commands/overview/assets/backends/claude-code.yaml +7 -0
- agex_cli-0.11.0/src/agent_experience/commands/overview/assets/backends/codex.yaml +7 -0
- agex_cli-0.11.0/src/agent_experience/commands/overview/assets/backends/copilot.yaml +7 -0
- agex_cli-0.11.0/src/agent_experience/commands/overview/assets/sections.md.j2 +52 -0
- agex_cli-0.11.0/src/agent_experience/commands/overview/references/.gitkeep +0 -0
- agex_cli-0.11.0/src/agent_experience/commands/overview/scripts/__init__.py +0 -0
- agex_cli-0.11.0/src/agent_experience/commands/overview/scripts/overview.py +40 -0
- agex_cli-0.11.0/src/agent_experience/core/__init__.py +0 -0
- agex_cli-0.11.0/src/agent_experience/core/backend.py +16 -0
- agex_cli-0.11.0/src/agent_experience/core/capabilities.py +44 -0
- agex_cli-0.11.0/src/agent_experience/core/config.py +42 -0
- agex_cli-0.11.0/src/agent_experience/core/hook_io.py +95 -0
- agex_cli-0.11.0/src/agent_experience/core/paths.py +26 -0
- agex_cli-0.11.0/src/agent_experience/core/render.py +27 -0
- agex_cli-0.11.0/src/agent_experience/core/skill_loader.py +36 -0
- agex_cli-0.11.0/tester-agents/claude/.claude/settings.json +5 -0
- agex_cli-0.11.0/tester-agents/claude/CLAUDE.md +30 -0
- agex_cli-0.11.0/tester-agents/claude/README.md +23 -0
- agex_cli-0.11.0/tester-agents/claude/culture.yaml +18 -0
- agex_cli-0.11.0/tests/__init__.py +0 -0
- agex_cli-0.11.0/tests/backends/__init__.py +0 -0
- agex_cli-0.11.0/tests/backends/test_claude_code_probe.py +63 -0
- agex_cli-0.11.0/tests/backends/test_stub_probes.py +21 -0
- agex_cli-0.11.0/tests/commands/__init__.py +0 -0
- agex_cli-0.11.0/tests/commands/test_explain.py +33 -0
- agex_cli-0.11.0/tests/commands/test_gamify.py +173 -0
- agex_cli-0.11.0/tests/commands/test_hook.py +63 -0
- agex_cli-0.11.0/tests/commands/test_learn.py +57 -0
- agex_cli-0.11.0/tests/commands/test_overview.py +55 -0
- agex_cli-0.11.0/tests/core/__init__.py +0 -0
- agex_cli-0.11.0/tests/core/test_backend.py +27 -0
- agex_cli-0.11.0/tests/core/test_capabilities.py +35 -0
- agex_cli-0.11.0/tests/core/test_config.py +25 -0
- agex_cli-0.11.0/tests/core/test_hook_io.py +138 -0
- agex_cli-0.11.0/tests/core/test_paths.py +31 -0
- agex_cli-0.11.0/tests/core/test_render.py +21 -0
- agex_cli-0.11.0/tests/core/test_skill_loader.py +45 -0
- agex_cli-0.11.0/tests/fixtures/claude-code/empty/.gitkeep +0 -0
- agex_cli-0.11.0/tests/fixtures/claude-code/malformed/.claude/hooks.json +1 -0
- agex_cli-0.11.0/tests/fixtures/claude-code/malformed/.claude/settings.json +1 -0
- agex_cli-0.11.0/tests/fixtures/claude-code/malformed/.claude/skills/bad/SKILL.md +1 -0
- agex_cli-0.11.0/tests/fixtures/claude-code/malformed/.claude/skills/broken-yaml/SKILL.md +6 -0
- agex_cli-0.11.0/tests/fixtures/claude-code/typical/.claude/hooks.json +5 -0
- agex_cli-0.11.0/tests/fixtures/claude-code/typical/.claude/settings.json +5 -0
- agex_cli-0.11.0/tests/fixtures/claude-code/typical/.claude/skills/example/SKILL.md +6 -0
- agex_cli-0.11.0/tests/fixtures/claude-code/typical/CLAUDE.md +3 -0
- agex_cli-0.11.0/tests/test_cli_errors.py +150 -0
- agex_cli-0.11.0/tests/test_cli_smoke.py +11 -0
- agex_cli-0.11.0/tests/test_skill_md_consistency.py +80 -0
agex_cli-0.11.0/.flake8
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
[flake8]
|
|
2
|
+
# Match pyproject.toml's [tool.black] line-length (100) and skip E203
|
|
3
|
+
# ("whitespace before ':'") because black's preferred slice style conflicts
|
|
4
|
+
# with PEP 8 on that one rule. The combination is the documented "black +
|
|
5
|
+
# flake8" compatibility settings per black's own docs.
|
|
6
|
+
max-line-length = 100
|
|
7
|
+
extend-ignore =
|
|
8
|
+
E203,
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
name: docs
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
paths:
|
|
7
|
+
- "docs/**"
|
|
8
|
+
- "src/agent_experience/commands/**/SKILL.md"
|
|
9
|
+
- "src/agent_experience/core/skill_loader.py"
|
|
10
|
+
- "scripts/sync_skill_md.py"
|
|
11
|
+
- ".github/workflows/docs.yml"
|
|
12
|
+
pull_request:
|
|
13
|
+
paths:
|
|
14
|
+
- "docs/**"
|
|
15
|
+
- "src/agent_experience/commands/**/SKILL.md"
|
|
16
|
+
- "src/agent_experience/core/skill_loader.py"
|
|
17
|
+
- "scripts/sync_skill_md.py"
|
|
18
|
+
- ".github/workflows/docs.yml"
|
|
19
|
+
workflow_dispatch:
|
|
20
|
+
|
|
21
|
+
permissions:
|
|
22
|
+
contents: read
|
|
23
|
+
deployments: write
|
|
24
|
+
pull-requests: write # for posting the preview URL comment on PRs
|
|
25
|
+
|
|
26
|
+
jobs:
|
|
27
|
+
build-and-deploy:
|
|
28
|
+
# Pull requests from forks cannot see the Cloudflare secrets (GitHub
|
|
29
|
+
# masks them for security), so the deploy step would fail. Skip the
|
|
30
|
+
# whole job for fork PRs and let reviewers rely on local `bundle exec
|
|
31
|
+
# jekyll serve`. This repo is single-owner today, so in practice
|
|
32
|
+
# every PR comes from a branch within OriNachum/agex and hits the
|
|
33
|
+
# fast path.
|
|
34
|
+
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
|
35
|
+
runs-on: ubuntu-latest
|
|
36
|
+
steps:
|
|
37
|
+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
38
|
+
|
|
39
|
+
# Refresh docs/commands/*.md from the ground-truth SKILL.md files so
|
|
40
|
+
# the deployed site cannot drift from what the CLI actually ships.
|
|
41
|
+
- uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3
|
|
42
|
+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
|
43
|
+
with:
|
|
44
|
+
python-version: "3.11"
|
|
45
|
+
# Match the install pattern used by .github/workflows/build.yml -- the
|
|
46
|
+
# repo does not commit a uv.lock, so `uv venv` + `uv pip install -e .`
|
|
47
|
+
# is the reproducible shape across every workflow.
|
|
48
|
+
- run: uv venv
|
|
49
|
+
- run: uv pip install -e "."
|
|
50
|
+
- run: uv run python scripts/sync_skill_md.py
|
|
51
|
+
|
|
52
|
+
# Jekyll build.
|
|
53
|
+
- uses: ruby/setup-ruby@7372622e62b60b3cb750dcd2b9e32c247ffec26a # v1
|
|
54
|
+
with:
|
|
55
|
+
ruby-version: "3.2"
|
|
56
|
+
bundler-cache: true
|
|
57
|
+
working-directory: docs
|
|
58
|
+
- run: bundle exec jekyll build
|
|
59
|
+
working-directory: docs
|
|
60
|
+
|
|
61
|
+
# Compute the Cloudflare Pages branch name from the PR's head ref.
|
|
62
|
+
# CF Pages uses the branch name as a subdomain component, so only
|
|
63
|
+
# `[a-zA-Z0-9-]` are safe: `/`, `.`, whitespace, and other chars
|
|
64
|
+
# produce a failing branch-alias URL. Replace unsafe chars with `-`,
|
|
65
|
+
# collapse consecutive dashes, and strip leading/trailing dashes.
|
|
66
|
+
# Only used on pull_request events -- push and workflow_dispatch
|
|
67
|
+
# always deploy `--branch=main` (the production branch).
|
|
68
|
+
- name: Compute preview branch
|
|
69
|
+
id: branch
|
|
70
|
+
if: github.event_name == 'pull_request'
|
|
71
|
+
run: |
|
|
72
|
+
raw="${{ github.head_ref }}"
|
|
73
|
+
safe=$(echo "$raw" | sed 's/[^a-zA-Z0-9-]/-/g; s/--*/-/g; s/^-//; s/-$//')
|
|
74
|
+
echo "name=$safe" >> "$GITHUB_OUTPUT"
|
|
75
|
+
|
|
76
|
+
# Deploy to Cloudflare Pages.
|
|
77
|
+
# - push / workflow_dispatch -> `--branch=main` = production
|
|
78
|
+
# - pull_request -> `--branch=<sanitized-head-ref>` = preview
|
|
79
|
+
# The project `agex` is a Direct Upload project, so CF won't auto-
|
|
80
|
+
# build on its own; this step IS the build+deploy pipeline.
|
|
81
|
+
- name: Deploy to Cloudflare Pages
|
|
82
|
+
id: deploy
|
|
83
|
+
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3
|
|
84
|
+
with:
|
|
85
|
+
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
86
|
+
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
87
|
+
command: pages deploy docs/_site --project-name=agex --branch=${{ github.event_name == 'pull_request' && steps.branch.outputs.name || 'main' }}
|
|
88
|
+
|
|
89
|
+
# Post the preview URL back on the PR so reviewers can click through
|
|
90
|
+
# without digging into the workflow logs. Uses a marker
|
|
91
|
+
# (`<!-- agex-docs-preview -->`) to update the existing comment on
|
|
92
|
+
# subsequent pushes instead of spamming the PR thread with a new
|
|
93
|
+
# comment per workflow run. `gh` is preinstalled on GitHub runners
|
|
94
|
+
# so no extra SHA-pinned action is needed. Runs only on PR events.
|
|
95
|
+
- name: Post (or update) preview URL comment
|
|
96
|
+
if: github.event_name == 'pull_request' && steps.deploy.outputs.deployment-url != ''
|
|
97
|
+
env:
|
|
98
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
99
|
+
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
100
|
+
DEPLOY_URL: ${{ steps.deploy.outputs.deployment-url }}
|
|
101
|
+
BRANCH: ${{ steps.branch.outputs.name }}
|
|
102
|
+
REPO: ${{ github.repository }}
|
|
103
|
+
run: |
|
|
104
|
+
set -e
|
|
105
|
+
MARKER='<!-- agex-docs-preview -->'
|
|
106
|
+
ALIAS_URL="https://${BRANCH}.agex.pages.dev"
|
|
107
|
+
# Two URLs intentionally:
|
|
108
|
+
# * deploy URL (hash) -- immutable pin for THIS deploy, ideal for
|
|
109
|
+
# review bookmarks that should not drift.
|
|
110
|
+
# * branch-alias URL -- stays the same as you push new commits;
|
|
111
|
+
# always reflects the latest preview.
|
|
112
|
+
BODY="$MARKER"$'\n'"📘 **docs preview** for this PR:"$'\n\n'"- **Latest deploy:** $DEPLOY_URL _(immutable — this specific commit)_"$'\n'"- **Branch alias:** $ALIAS_URL _(always latest — updates on each push)_"
|
|
113
|
+
# `gh api --paginate` streams one JSON array per page; `jq -s`
|
|
114
|
+
# ("slurp") collects them into `[[page1], [page2], ...]`, `add`
|
|
115
|
+
# flattens to a single array, then the filter picks our marker.
|
|
116
|
+
# `gh api --slurp --jq` itself is mutually exclusive (see
|
|
117
|
+
# https://github.com/cli/cli/issues/8615), so the slurp has to
|
|
118
|
+
# happen inside jq. Without the pagination + slurp, PRs with
|
|
119
|
+
# more than 30 comments would miss the marker and post
|
|
120
|
+
# duplicate comments on each run.
|
|
121
|
+
EXISTING=$(gh api --paginate "repos/$REPO/issues/$PR_NUMBER/comments" \
|
|
122
|
+
| jq -rs "add | [.[] | select(.body | contains(\"$MARKER\")) | .id] | first // empty")
|
|
123
|
+
if [ -n "$EXISTING" ]; then
|
|
124
|
+
gh api --method PATCH "repos/$REPO/issues/comments/$EXISTING" \
|
|
125
|
+
-f body="$BODY" > /dev/null
|
|
126
|
+
echo "Updated existing preview comment #$EXISTING"
|
|
127
|
+
else
|
|
128
|
+
gh pr comment "$PR_NUMBER" --body "$BODY"
|
|
129
|
+
echo "Created new preview comment"
|
|
130
|
+
fi
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
name: publish
|
|
2
|
+
|
|
3
|
+
# Publishes sdist + wheel with the shape:
|
|
4
|
+
#
|
|
5
|
+
# - PR → TestPyPI (dev version -- per-PR installable preview)
|
|
6
|
+
# - Push to main → TestPyPI (stable version, canary)
|
|
7
|
+
# ↓ if v<version> tag doesn't exist yet
|
|
8
|
+
# → autotag (creates + pushes the tag)
|
|
9
|
+
# ↓
|
|
10
|
+
# → PyPI (real release, only when autotag succeeded)
|
|
11
|
+
# ↓
|
|
12
|
+
# → GitHub Release with the matching CHANGELOG section
|
|
13
|
+
#
|
|
14
|
+
# Manual tagging is NOT required -- the `autotag` job turns the version field
|
|
15
|
+
# in `pyproject.toml` into the release signal. Forgetting to bump is caught
|
|
16
|
+
# on the PR by the `version-check` job in test.yml, which fails with a sticky
|
|
17
|
+
# comment pointing at `/version-bump`.
|
|
18
|
+
#
|
|
19
|
+
# The `v*` tag trigger is deliberately absent: tags are created by this
|
|
20
|
+
# workflow (via GITHUB_TOKEN, which by design does not retrigger workflows
|
|
21
|
+
# to prevent loops), and PyPI publish runs inline in the same workflow run.
|
|
22
|
+
# A tag pushed manually from a laptop would therefore not fire a publish --
|
|
23
|
+
# if you ever need that, use `gh workflow run` on this file's main branch
|
|
24
|
+
# or add a `workflow_dispatch:` trigger.
|
|
25
|
+
#
|
|
26
|
+
# Trusted Publishing (OIDC) is used in every publish job -- no API tokens.
|
|
27
|
+
# One-time setup on pypi.org / test.pypi.org and in the repo's Environments
|
|
28
|
+
# (`pypi`, `testpypi`) is documented in the original header below:
|
|
29
|
+
#
|
|
30
|
+
# - PyPI project `agex-cli` → Publishing → Add a GitHub pending publisher
|
|
31
|
+
# Owner: OriNachum Repo: agex
|
|
32
|
+
# Workflow: publish.yml Environment: pypi
|
|
33
|
+
#
|
|
34
|
+
# - TestPyPI project `agex-cli` → same shape, Environment: testpypi
|
|
35
|
+
#
|
|
36
|
+
# - GitHub repo → Settings → Environments → create `pypi` and `testpypi`
|
|
37
|
+
# (any required reviewers / wait timers live there)
|
|
38
|
+
#
|
|
39
|
+
# The PR job rewrites `version = "X.Y.Z"` to `"X.Y.Z.devN"` where N is
|
|
40
|
+
# the workflow run number. PEP 440's dev segment is a single integer
|
|
41
|
+
# (internal dots aren't allowed), so run_attempt is NOT encoded in the
|
|
42
|
+
# version -- instead `skip-existing: true` on the TestPyPI upload makes
|
|
43
|
+
# reruns idempotent. PEP 440 orders `.devN` strictly below the real
|
|
44
|
+
# release, so dev wheels never overshadow stable ones on TestPyPI. The
|
|
45
|
+
# PR job comments the exact install command back on the PR (sticky:
|
|
46
|
+
# edits the comment in place on reruns).
|
|
47
|
+
|
|
48
|
+
on:
|
|
49
|
+
push:
|
|
50
|
+
branches: [main]
|
|
51
|
+
pull_request:
|
|
52
|
+
paths:
|
|
53
|
+
- "pyproject.toml"
|
|
54
|
+
- "src/**"
|
|
55
|
+
- ".github/workflows/publish.yml"
|
|
56
|
+
|
|
57
|
+
# Workflow-level default: least-privilege read-only. Publish jobs ADD
|
|
58
|
+
# `id-token: write` for OIDC on top; `contents: read` propagates so
|
|
59
|
+
# `actions/checkout` and `actions/download-artifact` both have the scopes
|
|
60
|
+
# they need. Setting `permissions:` at job level REPLACES the default
|
|
61
|
+
# entirely -- the workflow-level block ensures the replacement starts
|
|
62
|
+
# from a usable baseline rather than from nothing.
|
|
63
|
+
permissions:
|
|
64
|
+
contents: read
|
|
65
|
+
|
|
66
|
+
jobs:
|
|
67
|
+
# --------------------------------------------------------------------------
|
|
68
|
+
# Shared build job for the main-push path. Produces a single `dist`
|
|
69
|
+
# artifact consumed by publish-testpypi (canary) and publish-pypi (real
|
|
70
|
+
# release, inline after autotag). Skipped on pull_request -- the PR path
|
|
71
|
+
# has its own build inside `publish-pr-testpypi` because it needs to
|
|
72
|
+
# rewrite the version before building.
|
|
73
|
+
# --------------------------------------------------------------------------
|
|
74
|
+
build:
|
|
75
|
+
if: github.event_name == 'push'
|
|
76
|
+
runs-on: ubuntu-latest
|
|
77
|
+
steps:
|
|
78
|
+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
79
|
+
- uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3
|
|
80
|
+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
|
81
|
+
with:
|
|
82
|
+
python-version: "3.11"
|
|
83
|
+
|
|
84
|
+
# Build sdist + wheel via PEP 517. `build` is the canonical frontend;
|
|
85
|
+
# we avoid pulling in full dev extras here since the build backend
|
|
86
|
+
# (hatchling) reads everything it needs from pyproject.toml.
|
|
87
|
+
- run: uv venv
|
|
88
|
+
- run: uv pip install build
|
|
89
|
+
- run: uv run python -m build
|
|
90
|
+
|
|
91
|
+
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
|
92
|
+
with:
|
|
93
|
+
name: dist
|
|
94
|
+
path: dist/
|
|
95
|
+
|
|
96
|
+
# --------------------------------------------------------------------------
|
|
97
|
+
# Per-PR dev-version publish to TestPyPI. Rewrites pyproject.toml to a
|
|
98
|
+
# `.devN` version before building so each PR gets its own unique TestPyPI
|
|
99
|
+
# release, then posts (or updates) a sticky PR comment with the install
|
|
100
|
+
# command for reviewers.
|
|
101
|
+
# --------------------------------------------------------------------------
|
|
102
|
+
publish-pr-testpypi:
|
|
103
|
+
# Fork-PR guard: GitHub masks secrets + denies OIDC to workflow runs
|
|
104
|
+
# from forks, so the deploy would fail anyway -- and more importantly,
|
|
105
|
+
# letting an untrusted fork run a privileged publish step is a real
|
|
106
|
+
# supply-chain hazard (exfiltrating TestPyPI credentials, pushing a
|
|
107
|
+
# malicious preview under the agex-cli name, etc.). Skip cleanly for
|
|
108
|
+
# forks; internal PRs take the fast path.
|
|
109
|
+
if: >-
|
|
110
|
+
github.event_name == 'pull_request'
|
|
111
|
+
&& github.event.pull_request.head.repo.full_name == github.repository
|
|
112
|
+
runs-on: ubuntu-latest
|
|
113
|
+
environment: testpypi
|
|
114
|
+
permissions:
|
|
115
|
+
id-token: write # OIDC -- Trusted Publishing
|
|
116
|
+
contents: read # actions/checkout
|
|
117
|
+
pull-requests: write # sticky comment with the install command
|
|
118
|
+
steps:
|
|
119
|
+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
120
|
+
- uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3
|
|
121
|
+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
|
122
|
+
with:
|
|
123
|
+
python-version: "3.11"
|
|
124
|
+
- run: uv venv
|
|
125
|
+
- run: uv pip install build
|
|
126
|
+
|
|
127
|
+
# Rewrite `[project].version` from `X.Y.Z` to `X.Y.Z.devN` where
|
|
128
|
+
# N = github.run_number (monotonic across the workflow's lifetime).
|
|
129
|
+
# run_attempt is intentionally NOT encoded: PEP 440's dev segment is
|
|
130
|
+
# a single integer with no internal dots allowed, so concatenating
|
|
131
|
+
# would be ambiguous (`dev71` could mean run_number=7 + attempt=1 OR
|
|
132
|
+
# run_number=71). `skip-existing: true` on the TestPyPI upload below
|
|
133
|
+
# handles rerun idempotency instead. Parsing via tomllib + re-emitting
|
|
134
|
+
# only the `[project]` table's `version` key is more robust than
|
|
135
|
+
# `sed` -- if a future `[tool.*]` section ever introduces its own
|
|
136
|
+
# `version` key the regex approach would silently rewrite the wrong
|
|
137
|
+
# one.
|
|
138
|
+
- name: Set dev version
|
|
139
|
+
id: devver
|
|
140
|
+
run: |
|
|
141
|
+
set -e
|
|
142
|
+
DEV_VERSION=$(uv run python -c "
|
|
143
|
+
import re, tomllib
|
|
144
|
+
data = open('pyproject.toml').read()
|
|
145
|
+
base = tomllib.loads(data)['project']['version']
|
|
146
|
+
dev = f'{base}.dev${{ github.run_number }}'
|
|
147
|
+
# Fail-closed: bound the final version to PEP 440's safe shape
|
|
148
|
+
# before substituting into pyproject.toml (defense in depth against
|
|
149
|
+
# a hostile value sneaking into the [project].version field).
|
|
150
|
+
assert re.fullmatch(r'[0-9]+(\.[0-9]+)*(\.dev[0-9]+)?', dev), dev
|
|
151
|
+
out = re.sub(r'(?m)^version = .+', f'version = \"{dev}\"', data, count=1)
|
|
152
|
+
open('pyproject.toml', 'w').write(out)
|
|
153
|
+
print(dev)
|
|
154
|
+
")
|
|
155
|
+
echo "dev_version=${DEV_VERSION}" >> "$GITHUB_OUTPUT"
|
|
156
|
+
echo "Publishing agex-cli ${DEV_VERSION} to TestPyPI"
|
|
157
|
+
|
|
158
|
+
- run: uv run python -m build
|
|
159
|
+
|
|
160
|
+
- uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
|
|
161
|
+
with:
|
|
162
|
+
repository-url: https://test.pypi.org/legacy/
|
|
163
|
+
# Belt-and-braces -- the dev-version scheme above already produces
|
|
164
|
+
# a unique version per run_id+run_attempt, so collisions shouldn't
|
|
165
|
+
# happen. If they ever do (e.g. a retry after a partial upload),
|
|
166
|
+
# skip-existing makes the workflow idempotent instead of failing.
|
|
167
|
+
skip-existing: true
|
|
168
|
+
|
|
169
|
+
# Sticky PR comment with the install command. Uses an
|
|
170
|
+
# `<!-- agex-pypi-preview -->` marker to edit the existing comment on
|
|
171
|
+
# reruns (same pattern as .github/workflows/docs.yml). `--paginate
|
|
172
|
+
# --slurp` ensures the marker is found even on PRs with >30 comments
|
|
173
|
+
# (default gh api page size); otherwise the workflow would post a
|
|
174
|
+
# duplicate comment on late-stage PRs.
|
|
175
|
+
- name: Post (or update) install-command comment
|
|
176
|
+
if: success()
|
|
177
|
+
env:
|
|
178
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
179
|
+
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
180
|
+
DEV_VERSION: ${{ steps.devver.outputs.dev_version }}
|
|
181
|
+
REPO: ${{ github.repository }}
|
|
182
|
+
run: |
|
|
183
|
+
set -e
|
|
184
|
+
MARKER='<!-- agex-pypi-preview -->'
|
|
185
|
+
INSTALL="pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ agex-cli==${DEV_VERSION}"
|
|
186
|
+
BODY="$MARKER"$'\n'"📦 **TestPyPI preview:** \`agex-cli==${DEV_VERSION}\`"$'\n\n'"Install this PR's wheel to try it locally:"$'\n\n'"\`\`\`bash"$'\n'"$INSTALL"$'\n'"\`\`\`"
|
|
187
|
+
# `gh api --paginate` streams one JSON array per page; `jq -s`
|
|
188
|
+
# ("slurp") collects them into `[[page1], [page2], ...]`, `add`
|
|
189
|
+
# flattens to a single array of all comments, then the filter
|
|
190
|
+
# picks our marker. `gh api --slurp --jq` itself is mutually
|
|
191
|
+
# exclusive (see https://github.com/cli/cli/issues/8615), so
|
|
192
|
+
# the slurp has to happen inside jq.
|
|
193
|
+
EXISTING=$(gh api --paginate "repos/$REPO/issues/$PR_NUMBER/comments" \
|
|
194
|
+
| jq -rs "add | [.[] | select(.body | contains(\"$MARKER\")) | .id] | first // empty")
|
|
195
|
+
if [ -n "$EXISTING" ]; then
|
|
196
|
+
gh api --method PATCH "repos/$REPO/issues/comments/$EXISTING" \
|
|
197
|
+
-f body="$BODY" > /dev/null
|
|
198
|
+
echo "Updated existing preview comment #$EXISTING"
|
|
199
|
+
else
|
|
200
|
+
gh pr comment "$PR_NUMBER" --body "$BODY"
|
|
201
|
+
echo "Created new preview comment"
|
|
202
|
+
fi
|
|
203
|
+
|
|
204
|
+
publish-testpypi:
|
|
205
|
+
needs: build
|
|
206
|
+
if: github.ref == 'refs/heads/main'
|
|
207
|
+
runs-on: ubuntu-latest
|
|
208
|
+
environment: testpypi
|
|
209
|
+
permissions:
|
|
210
|
+
id-token: write # OIDC; no API token needed
|
|
211
|
+
contents: read # actions/download-artifact needs this
|
|
212
|
+
steps:
|
|
213
|
+
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
|
214
|
+
with:
|
|
215
|
+
name: dist
|
|
216
|
+
path: dist/
|
|
217
|
+
- uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
|
|
218
|
+
with:
|
|
219
|
+
repository-url: https://test.pypi.org/legacy/
|
|
220
|
+
# See header comment -- idempotent on repeat pushes of the same version.
|
|
221
|
+
skip-existing: true
|
|
222
|
+
|
|
223
|
+
# --------------------------------------------------------------------------
|
|
224
|
+
# Ensures an annotated `v<version>` tag exists on origin so the PyPI
|
|
225
|
+
# release and the GitHub Release have a matching git anchor. Runs AFTER
|
|
226
|
+
# the TestPyPI canary so we don't tag a version that failed to build.
|
|
227
|
+
#
|
|
228
|
+
# Output semantics: `tag_present = true` when the tag exists on origin
|
|
229
|
+
# AFTER this job finishes, regardless of whether we had to create it or
|
|
230
|
+
# it was already there. Downstream jobs gate on that, not on "did this
|
|
231
|
+
# specific run push the tag", so reruns after a transient PyPI or
|
|
232
|
+
# Release failure can complete the publish instead of getting stuck in
|
|
233
|
+
# a "tag exists but release didn't complete" dead-end. Idempotency for
|
|
234
|
+
# the actual publish/release steps is handled in those jobs (skip-existing
|
|
235
|
+
# on PyPI, `gh release view` probe before `gh release create`).
|
|
236
|
+
#
|
|
237
|
+
# Tag existence is checked against origin via `git ls-remote`, not the
|
|
238
|
+
# local ref store -- actions/checkout's default tag-fetching behavior
|
|
239
|
+
# varies with fetch-depth / options, and a false negative here would
|
|
240
|
+
# cause `git push origin v$VERSION` to fail on an already-pushed tag.
|
|
241
|
+
#
|
|
242
|
+
# GITHUB_TOKEN-pushed tags do not retrigger workflows (documented GitHub
|
|
243
|
+
# loop-prevention). That's fine -- publish-pypi runs inline in the same
|
|
244
|
+
# workflow run, keyed off `tag_present`.
|
|
245
|
+
# --------------------------------------------------------------------------
|
|
246
|
+
autotag:
|
|
247
|
+
needs: publish-testpypi
|
|
248
|
+
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
|
249
|
+
runs-on: ubuntu-latest
|
|
250
|
+
permissions:
|
|
251
|
+
contents: write # git push tag
|
|
252
|
+
outputs:
|
|
253
|
+
version: ${{ steps.version.outputs.version }}
|
|
254
|
+
tag_present: ${{ steps.tag.outputs.tag_present }}
|
|
255
|
+
steps:
|
|
256
|
+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
257
|
+
with:
|
|
258
|
+
fetch-depth: 0
|
|
259
|
+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
|
260
|
+
with:
|
|
261
|
+
python-version: "3.11"
|
|
262
|
+
- name: Read version from pyproject.toml
|
|
263
|
+
id: version
|
|
264
|
+
run: |
|
|
265
|
+
VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
|
|
266
|
+
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
|
267
|
+
echo "Detected version: $VERSION"
|
|
268
|
+
- name: Ensure tag exists on origin
|
|
269
|
+
id: tag
|
|
270
|
+
env:
|
|
271
|
+
VERSION: ${{ steps.version.outputs.version }}
|
|
272
|
+
run: |
|
|
273
|
+
set -e
|
|
274
|
+
if git ls-remote --exit-code --tags origin "refs/tags/v$VERSION" >/dev/null 2>&1; then
|
|
275
|
+
echo "Tag v$VERSION already present on origin — downstream jobs will run idempotently."
|
|
276
|
+
else
|
|
277
|
+
git config user.name "github-actions[bot]"
|
|
278
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
279
|
+
git tag -a "v$VERSION" -m "Release v$VERSION"
|
|
280
|
+
git push origin "v$VERSION"
|
|
281
|
+
echo "Created and pushed tag v$VERSION"
|
|
282
|
+
fi
|
|
283
|
+
echo "tag_present=true" >> "$GITHUB_OUTPUT"
|
|
284
|
+
|
|
285
|
+
# --------------------------------------------------------------------------
|
|
286
|
+
# Real PyPI publish. Runs inline on main after `autotag` -- the dist
|
|
287
|
+
# artifact produced by `build` is the same wheel we already canaried on
|
|
288
|
+
# TestPyPI, so there's no rebuild.
|
|
289
|
+
#
|
|
290
|
+
# `skip-existing: true` is set so reruns after a transient failure (or
|
|
291
|
+
# after a partial-success run where the tag got pushed but a later step
|
|
292
|
+
# failed) converge cleanly instead of failing on "file already exists".
|
|
293
|
+
# Combined with the `autotag` gate, the normal case is a real upload;
|
|
294
|
+
# the abnormal case is a clear no-op log line.
|
|
295
|
+
# --------------------------------------------------------------------------
|
|
296
|
+
publish-pypi:
|
|
297
|
+
needs: [build, autotag]
|
|
298
|
+
if: >-
|
|
299
|
+
github.event_name == 'push'
|
|
300
|
+
&& github.ref == 'refs/heads/main'
|
|
301
|
+
&& needs.autotag.outputs.tag_present == 'true'
|
|
302
|
+
runs-on: ubuntu-latest
|
|
303
|
+
environment: pypi
|
|
304
|
+
permissions:
|
|
305
|
+
id-token: write # OIDC; no API token needed
|
|
306
|
+
contents: read # actions/download-artifact needs this
|
|
307
|
+
steps:
|
|
308
|
+
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
|
309
|
+
with:
|
|
310
|
+
name: dist
|
|
311
|
+
path: dist/
|
|
312
|
+
- uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
|
|
313
|
+
with:
|
|
314
|
+
skip-existing: true
|
|
315
|
+
|
|
316
|
+
# --------------------------------------------------------------------------
|
|
317
|
+
# Creates a GitHub Release at the tag `autotag` ensured exists, with
|
|
318
|
+
# body pulled from the matching `## [X.Y.Z]` section of CHANGELOG.md.
|
|
319
|
+
# If the section is empty (or missing), falls back to a stub rather than
|
|
320
|
+
# failing the workflow -- the release anchor is more important than
|
|
321
|
+
# perfect notes.
|
|
322
|
+
#
|
|
323
|
+
# Idempotent: if a Release for this tag already exists (e.g. a rerun
|
|
324
|
+
# after a partial failure), `gh release view` succeeds and we skip
|
|
325
|
+
# creation. Otherwise we create it. No "release already exists" error
|
|
326
|
+
# blocks reruns.
|
|
327
|
+
# --------------------------------------------------------------------------
|
|
328
|
+
github-release:
|
|
329
|
+
needs: [autotag, publish-pypi]
|
|
330
|
+
if: needs.autotag.outputs.tag_present == 'true'
|
|
331
|
+
runs-on: ubuntu-latest
|
|
332
|
+
permissions:
|
|
333
|
+
contents: write # gh release create
|
|
334
|
+
steps:
|
|
335
|
+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
336
|
+
with:
|
|
337
|
+
ref: v${{ needs.autotag.outputs.version }}
|
|
338
|
+
fetch-depth: 1
|
|
339
|
+
- name: Extract CHANGELOG section
|
|
340
|
+
env:
|
|
341
|
+
VERSION: ${{ needs.autotag.outputs.version }}
|
|
342
|
+
run: |
|
|
343
|
+
set -e
|
|
344
|
+
# Pull the section from `## [X.Y.Z]` up to (but not including)
|
|
345
|
+
# the next `## [` header. The em-dash variant in existing entries
|
|
346
|
+
# (`— 2026-04-19`) means we match on the bracketed version only,
|
|
347
|
+
# not the full header line.
|
|
348
|
+
awk -v ver="$VERSION" '
|
|
349
|
+
/^## \[/ {
|
|
350
|
+
if (capturing) exit
|
|
351
|
+
if ($0 ~ "^## \\[" ver "\\]") { capturing=1; next }
|
|
352
|
+
}
|
|
353
|
+
capturing { print }
|
|
354
|
+
' CHANGELOG.md > release-notes.md
|
|
355
|
+
# Strip leading/trailing blank lines for tidy rendering. `\s` is
|
|
356
|
+
# not portable in sed (GNU/BSD differ); use the POSIX class.
|
|
357
|
+
sed -i -e '/./,$!d' -e :a -e '/^[[:space:]]*$/{$d;N;ba' -e '}' release-notes.md || true
|
|
358
|
+
if [ ! -s release-notes.md ]; then
|
|
359
|
+
echo "See CHANGELOG.md for details." > release-notes.md
|
|
360
|
+
fi
|
|
361
|
+
echo "--- release-notes.md ---"
|
|
362
|
+
cat release-notes.md
|
|
363
|
+
echo "------------------------"
|
|
364
|
+
- name: Create GitHub Release (or skip if it already exists)
|
|
365
|
+
env:
|
|
366
|
+
GH_TOKEN: ${{ github.token }}
|
|
367
|
+
VERSION: ${{ needs.autotag.outputs.version }}
|
|
368
|
+
run: |
|
|
369
|
+
set -e
|
|
370
|
+
if gh release view "v$VERSION" >/dev/null 2>&1; then
|
|
371
|
+
echo "Release v$VERSION already exists — skipping creation."
|
|
372
|
+
else
|
|
373
|
+
gh release create "v$VERSION" \
|
|
374
|
+
--title "v$VERSION" \
|
|
375
|
+
--notes-file release-notes.md
|
|
376
|
+
fi
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
name: test
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ${{ matrix.os }}
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
os: [ubuntu-latest, macos-latest, windows-latest]
|
|
15
|
+
python: ["3.10", "3.11", "3.12", "3.13"]
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
18
|
+
- uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3
|
|
19
|
+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
|
20
|
+
with:
|
|
21
|
+
python-version: ${{ matrix.python }}
|
|
22
|
+
- run: uv venv
|
|
23
|
+
- run: uv pip install -e ".[dev]"
|
|
24
|
+
- run: uv run pytest
|
|
25
|
+
|
|
26
|
+
# --------------------------------------------------------------------------
|
|
27
|
+
# Enforces that every PR touching code (src/, tests/, pyproject.toml) also
|
|
28
|
+
# bumps the version in pyproject.toml. Without this, a merge to main that
|
|
29
|
+
# forgot to bump would silently try to republish the existing PyPI release
|
|
30
|
+
# and fail deep inside the publish workflow. Catching it at PR-time turns
|
|
31
|
+
# that failure mode into a single sticky comment pointing at /version-bump.
|
|
32
|
+
#
|
|
33
|
+
# Docs-only PRs skip this check automatically (no code diff = no bump
|
|
34
|
+
# needed). See culture/.github/workflows/tests.yml:23-78 for the source
|
|
35
|
+
# pattern this was ported from.
|
|
36
|
+
# --------------------------------------------------------------------------
|
|
37
|
+
version-check:
|
|
38
|
+
if: github.event_name == 'pull_request'
|
|
39
|
+
runs-on: ubuntu-latest
|
|
40
|
+
permissions:
|
|
41
|
+
contents: read
|
|
42
|
+
pull-requests: write # sticky "forgot to bump" comment
|
|
43
|
+
steps:
|
|
44
|
+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
45
|
+
with:
|
|
46
|
+
fetch-depth: 0
|
|
47
|
+
- run: git fetch origin main
|
|
48
|
+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
|
49
|
+
with:
|
|
50
|
+
python-version: "3.11"
|
|
51
|
+
|
|
52
|
+
- name: Check if code changed
|
|
53
|
+
id: changes
|
|
54
|
+
run: |
|
|
55
|
+
CODE_CHANGED=$(git diff --name-only origin/main...HEAD -- 'src/' 'pyproject.toml' 'tests/' | head -1)
|
|
56
|
+
if [ -z "$CODE_CHANGED" ]; then
|
|
57
|
+
echo "skip=true" >> "$GITHUB_OUTPUT"
|
|
58
|
+
echo "Only docs/config changed — skipping version check"
|
|
59
|
+
else
|
|
60
|
+
echo "skip=false" >> "$GITHUB_OUTPUT"
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
- name: Check version bump
|
|
64
|
+
if: steps.changes.outputs.skip != 'true'
|
|
65
|
+
env:
|
|
66
|
+
GH_TOKEN: ${{ github.token }}
|
|
67
|
+
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
68
|
+
REPO: ${{ github.repository }}
|
|
69
|
+
run: |
|
|
70
|
+
set -e
|
|
71
|
+
PR_VERSION=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
|
|
72
|
+
MAIN_VERSION=$(git show origin/main:pyproject.toml | python3 -c "import sys,tomllib; print(tomllib.loads(sys.stdin.read())['project']['version'])")
|
|
73
|
+
|
|
74
|
+
MARKER='<!-- version-check -->'
|
|
75
|
+
|
|
76
|
+
# Find any existing sticky comment. Paginate so the lookup is
|
|
77
|
+
# correct on long-lived PRs with >30 comments (same pattern as
|
|
78
|
+
# docs.yml / publish.yml's agex-pypi-preview comment). `gh api
|
|
79
|
+
# --slurp --jq` can't be combined (cli#8615), so the slurp has
|
|
80
|
+
# to happen inside jq.
|
|
81
|
+
EXISTING=$(gh api --paginate "repos/$REPO/issues/$PR_NUMBER/comments" \
|
|
82
|
+
| jq -rs "add | [.[] | select(.body | contains(\"$MARKER\")) | .id] | first // empty")
|
|
83
|
+
|
|
84
|
+
if [ "$PR_VERSION" = "$MAIN_VERSION" ]; then
|
|
85
|
+
BODY="⚠️ **Version not bumped** — \`pyproject.toml\` still has \`$PR_VERSION\` (same as main). Run \`/version-bump patch\` (or minor/major) before merging to avoid a failed PyPI publish.
|
|
86
|
+
|
|
87
|
+
$MARKER"
|
|
88
|
+
|
|
89
|
+
if [ -n "$EXISTING" ]; then
|
|
90
|
+
gh api "repos/$REPO/issues/comments/$EXISTING" \
|
|
91
|
+
-X PATCH -f body="$BODY" > /dev/null
|
|
92
|
+
else
|
|
93
|
+
gh pr comment "$PR_NUMBER" --body "$BODY" || true
|
|
94
|
+
fi
|
|
95
|
+
|
|
96
|
+
echo "::error::Version $PR_VERSION matches main. Bump before merging."
|
|
97
|
+
exit 1
|
|
98
|
+
else
|
|
99
|
+
echo "Version bumped: $MAIN_VERSION -> $PR_VERSION"
|
|
100
|
+
# Clean up a stale "not bumped" warning from an earlier failing
|
|
101
|
+
# run so the PR reflects the current (green) state.
|
|
102
|
+
if [ -n "$EXISTING" ]; then
|
|
103
|
+
gh api "repos/$REPO/issues/comments/$EXISTING" -X DELETE > /dev/null
|
|
104
|
+
echo "Removed stale version-check comment #$EXISTING"
|
|
105
|
+
fi
|
|
106
|
+
fi
|