From b95ffb6a32f0f483d685b65a7c5155f03766e8f0 Mon Sep 17 00:00:00 2001 From: zengchuang Date: Thu, 29 Oct 2026 10:34:21 +0800 Subject: [PATCH 01/10] ci: prepare versioned release drafts with the built-in bot Build and validate versioned Python packages, attach checksums, and generate reviewable release notes using the built-in GitHub token. Keep publication under maintainer control and preserve existing CI and campaign boundaries. Agent-assisted-by: Codex (implementation and release research) Signed-off-by: zengchuang --- .github/release.yml | 10 +++ .github/workflows/release.yml | 131 ++++++++++++++++++++++++++++++++++ docs/contributing.md | 4 ++ docs/releases.md | 100 ++++++++++++++++++++++++++ tests/test_release.py | 68 ++++++++++++++++++ tools/check_release.py | 69 ++++++++++++++++++ 6 files changed, 382 insertions(+) create mode 100644 .github/release.yml create mode 100644 .github/workflows/release.yml create mode 100644 docs/releases.md create mode 100644 tests/test_release.py create mode 100644 tools/check_release.py diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 000000000..db109ac9d --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,10 @@ +changelog: + categories: + - title: Features + labels: [enhancement] + - title: Fixes + labels: [bug] + - title: Documentation + labels: [documentation] + - title: Other changes + labels: ["*"] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..0bba3a13a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,131 @@ +name: Release bot + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: Existing stable version tag (vMAJOR.MINOR.PATCH) + required: true + type: string + dry_run: + description: Build and validate only; do not create a Release draft + default: true + type: boolean + +permissions: + contents: read + +concurrency: + group: release-${{ inputs.tag || github.ref_name }} + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 20 + outputs: + tag: ${{ steps.ref.outputs.tag }} + commit: ${{ steps.ref.outputs.commit }} + env: + RELEASE_TAG: ${{ inputs.tag || github.ref_name }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + EXPECTED_COMMIT: ${{ github.event_name == 'push' && github.sha || '' }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Validate the existing tag and default-branch ancestry + id: ref + shell: bash + run: | + python tools/check_release.py ref --tag "$RELEASE_TAG" \ + --default-branch "$DEFAULT_BRANCH" --expected-commit "$EXPECTED_COMMIT" >> "$GITHUB_OUTPUT" + cp tools/check_release.py "$RUNNER_TEMP/check_release.py" + - name: Check out exactly the validated release commit + env: + RELEASE_COMMIT: ${{ steps.ref.outputs.commit }} + run: git checkout --detach "$RELEASE_COMMIT" + - name: Build and check distributables + run: | + python -m pip install build twine + python -m build --outdir "$RUNNER_TEMP/release-dist" + python -m twine check "$RUNNER_TEMP"/release-dist/* + - name: Check a clean wheel installation outside the checkout + shell: bash + run: | + python -m venv "$RUNNER_TEMP/wheel-env" + wheels=("$RUNNER_TEMP"/release-dist/*.whl) + "$RUNNER_TEMP/wheel-env/bin/python" -m pip install "${wheels[0]}[skills]" + cp tests/check_installed.py "$RUNNER_TEMP/check_installed.py" + cd "$RUNNER_TEMP" + env -u PYTHONPATH "$RUNNER_TEMP/wheel-env/bin/python" -I check_installed.py + env -u PYTHONPATH "$RUNNER_TEMP/wheel-env/bin/python" -I check_release.py \ + installed --tag "$RELEASE_TAG" + - name: Generate checksums + working-directory: ${{ runner.temp }}/release-dist + run: sha256sum *.whl *.tar.gz > SHA256SUMS + - uses: actions/upload-artifact@v4 + with: + name: release-distributions + path: ${{ runner.temp }}/release-dist/ + if-no-files-found: error + retention-days: 7 + + draft: + needs: build + if: github.event_name == 'push' || !inputs.dry_run + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ needs.build.outputs.tag }} + RELEASE_COMMIT: ${{ needs.build.outputs.commit }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + steps: + # Do not check out or execute repository/build code with the write token. + - uses: actions/download-artifact@v4 + with: + name: release-distributions + path: dist + - name: Recheck the remote tag and ancestry before writing + shell: bash + run: | + object=$(gh api "repos/$GH_REPO/git/ref/tags/$RELEASE_TAG" --jq '.object') + for attempt in {1..10}; do + kind=$(jq -r '.type' <<< "$object") + sha=$(jq -r '.sha' <<< "$object") + if [[ "$kind" == commit ]]; then break; fi + [[ "$kind" == tag ]] || exit 1 + object=$(gh api "repos/$GH_REPO/git/tags/$sha" --jq '.object') + done + [[ "$kind" == commit && "$sha" == "$RELEASE_COMMIT" ]] + status=$(gh api "repos/$GH_REPO/compare/$RELEASE_COMMIT...$DEFAULT_BRANCH" --jq '.status') + [[ "$status" == ahead || "$status" == identical ]] + - name: Refuse an existing draft or published Release + shell: bash + run: | + tags=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq '.[].tag_name') + if grep -Fxq "$RELEASE_TAG" <<< "$tags"; then + echo "::error::Release already exists; inspect it before any manual recovery." + exit 1 + fi + - name: Verify downloaded checksums + working-directory: dist + run: sha256sum --check SHA256SUMS + - name: Create a draft with generated release notes + shell: bash + run: | + notes="Source: https://github.com/$GH_REPO/tree/$RELEASE_COMMIT" + notes+=$'\n\nThese notes describe software changes. Release builds and scaffold checks do not establish campaign PASS, model quality or service performance. Any such claim requires its acceptance issue and complete frozen evidence.' + gh release create "$RELEASE_TAG" dist/*.whl dist/*.tar.gz dist/SHA256SUMS \ + --verify-tag --draft --title "InferRSI $RELEASE_TAG" \ + --generate-notes --notes "$notes" diff --git a/docs/contributing.md b/docs/contributing.md index 151eff5c4..2946e7bf0 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -87,6 +87,10 @@ licenses, local links and reads from a noneditable wheel installation. ## Acceptance checklist +For software packaging and bot-created Release drafts, follow the +[release guide](releases.md). Release preparation is separate from campaign +acceptance and the four CI lanes. + Open an issue using the [acceptance form](../.github/ISSUE_TEMPLATE/campaign-acceptance.yml) before claiming a result. Record: diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 000000000..a3e235afa --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,100 @@ +# Releases + +The [release bot](../.github/workflows/release.yml) builds a stable version tag +and creates a **draft** GitHub Release as `github-actions[bot]`. It uploads a +wheel, source distribution and `SHA256SUMS`, and generates notes from merged PRs. +The [notes configuration](../.github/release.yml) uses existing `enhancement`, +`bug` and `documentation` labels, with a catch-all for other PRs. + +This is release automation, separate from the four CI lanes. It does not bump +versions, merge changes, publish the draft, upload to PyPI or run accelerator +experiments. Building a release does not establish campaign `PASS`. + +## Setup + +Merge the release configuration into the default branch. GitHub Actions must be +enabled and organization policy must allow the listed GitHub-owned actions and +the publisher job's `contents: write` permission. No additional secret, PAT, +GitHub App or repository-wide write-token default is needed. Build jobs have +read-only access; only the draft job receives write access, without executing +repository code. + +The initial bot supports stable tags `vMAJOR.MINOR.PATCH` only. Pre-release and +local-version tags are rejected. Release commits must be reachable from the +default branch. Use tags containing this release tooling and the corresponding +installed-package checker; this is not a retroactive publisher for older tags. + +## Prepare a version + +1. Choose the version and scope in the governing release Issue. Update both + `project.version` in `pyproject.toml` and `__version__` in + `src/inferrsi/__init__.py` in a reviewed PR. No version is chosen or bumped by + this configuration change. +2. Complete the applicable CI/review checks and merge the version PR. The bot + rebuilds and checks package installation, but does not replace PR validation. +3. Fetch the default branch and create an annotated tag at the intended merged + revision. Substitute the selected tag and commit below; `` is the + canonical repository remote, which may differ from a contributor's fork. + + ```bash + git fetch --tags + git tag -a -m "InferRSI " + git push refs/tags/ + ``` + +Pushing the tag starts the bot. It resolves the exact tag ref, checks default +branch ancestry and the push-event commit, builds the sdist and a wheel from +that sdist, runs `twine check`, and reuses `tests/check_installed.py` from a +clean wheel installation outside the checkout. Distribution metadata and the +installed runtime version must both equal the tag's version. + +The bot rechecks the remote tag and ancestry before creating the draft. Avoid +moving/deleting release tags, including during a run; these checks are not a +server-side lock against concurrent tag changes. Tag protection can be managed +separately by repository owners. + +## Dry run and retry + +Under **Actions → Release bot → Run workflow**, select the default branch, +enter an existing version tag and leave **dry_run** checked. This runs the +read-only build and uploads the package/checksum Actions artifact for seven +days. It skips the write job and creates no Release. Clearing **dry_run** +requests a draft for that existing tag. The equivalent CLI is: + +```bash +gh workflow run release.yml --repo InferMatrixLab/InferRSI --ref \ + -f tag= -F dry_run=true +``` + +Same-tag runs are serialized. A failure before draft creation can be retried. +An existing draft or published Release stops creation; the bot never overwrites +notes/assets or uses `--clobber`. If an upload left an incomplete draft, inspect +its tag/source and assets first. An authorized maintainer may remove only that +unpublished incomplete draft and rerun, or recover it manually. Published +release corrections require a new version and a separate release decision. + +## Review and publish + +Inspect the draft notes, source revision and all three attachments. Verify the +downloaded package hashes with `sha256sum --check SHA256SUMS`. Review generated +PR titles and any performance/quality claims: such claims need the acceptance +Issue, pinned experimental revisions and complete frozen evidence described in +[contributing](contributing.md#evidence-and-claims). Preserve failed and +inconclusive results; a software version does not qualify hardware or models. +Then publish the draft through GitHub under the maintainer's release authority. + +The first release may include many historical PRs. Review and edit its generated +notes before publishing; later releases use GitHub's previous-release selection. + +## Reference and limits + +[JiuwenSwarm v0.2.7](https://github.com/openJiuwen-ai/jiuwenswarm/releases/tag/v0.2.7) +demonstrates bot-authored releases with release notes and downloadable packages. +Its publisher is a [private GitHub App](https://github.com/apps/openjiuwen-release-bot); +the publishing workflow is not present in the inspected public source. InferRSI +implements its own flow using the built-in token, following GitHub's +[token permissions](https://docs.github.com/en/actions/tutorials/authenticate-with-github_token), +[generated notes](https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes) +and [draft-release CLI](https://cli.github.com/manual/gh_release_create) contracts. +Remote release execution is verified only by an actual Actions run; local +checks alone do not establish that repository/organization policies permit it. diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 000000000..752c2f9d3 --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,68 @@ +import subprocess + +import pytest + +from tools.check_release import check_versions, release_version, resolve_release + + +@pytest.mark.parametrize("tag", ["v0.1.0", "v1.2.3", "v10.20.30"]) +def test_stable_release_versions(tag): + check_versions(tag, tag[1:], tag[1:]) + + +@pytest.mark.parametrize("tag", [ + "1.2.3", "v01.2.3", "v1.2", "v1.2.3rc1", "v1.2.3+local", "v1.2.3\n", "--help", +]) +def test_reject_ambiguous_and_nonstable_tags(tag): + with pytest.raises(ValueError): + release_version(tag) + + +@pytest.mark.parametrize("distribution,runtime", [("1.2.4", "1.2.3"), ("1.2.3", "1.2.4")]) +def test_reject_inconsistent_distribution_or_runtime(distribution, runtime): + with pytest.raises(ValueError, match="agree exactly"): + check_versions("v1.2.3", distribution, runtime) + + +@pytest.fixture +def release_repo(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + def git(*args): + return subprocess.check_output(["git", *args], text=True, stderr=subprocess.PIPE).strip() + + git("init", "-b", "main") + git("config", "user.name", "Release Test") + git("config", "user.email", "release@example.test") + git("commit", "--allow-empty", "-m", "initial") + git("update-ref", "refs/remotes/origin/main", "HEAD") + return git + + +@pytest.mark.parametrize("annotated", [False, True]) +def test_resolve_lightweight_and_annotated_tags(release_repo, annotated): + git = release_repo + args = ("-a", "-m", "release") if annotated else () + git("tag", *args, "v1.2.3") + commit = git("rev-parse", "HEAD") + assert resolve_release("v1.2.3", "main", commit) == commit + + +def test_branch_with_same_name_does_not_replace_a_missing_tag(release_repo): + release_repo("branch", "v1.2.3") + with pytest.raises(ValueError, match="existing tag"): + resolve_release("v1.2.3", "main") + + +def test_reject_tag_outside_default_branch(release_repo): + release_repo("checkout", "-b", "feature") + release_repo("commit", "--allow-empty", "-m", "unmerged") + release_repo("tag", "v1.2.3") + with pytest.raises(ValueError, match="reachable"): + resolve_release("v1.2.3", "main") + + +def test_reject_tag_moved_after_push_event(release_repo): + release_repo("tag", "v1.2.3") + with pytest.raises(ValueError, match="triggering commit"): + resolve_release("v1.2.3", "main", "0" * 40) diff --git a/tools/check_release.py b/tools/check_release.py new file mode 100644 index 000000000..02a277d5e --- /dev/null +++ b/tools/check_release.py @@ -0,0 +1,69 @@ +"""Validate release refs and clean installed versions without publishing anything.""" + +import argparse +import re +import subprocess + + +def release_version(tag: str) -> str: + number = r"(?:0|[1-9][0-9]*)" + if not re.fullmatch(rf"v{number}\.{number}\.{number}", tag): + raise ValueError("Release tags must have the stable form vMAJOR.MINOR.PATCH") + return tag[1:] + + +def resolve_release(tag: str, default_branch: str, expected_commit: str = "") -> str: + release_version(tag) + try: + commit = subprocess.check_output( + ["git", "rev-parse", "--verify", f"refs/tags/{tag}^{{commit}}"], + text=True, stderr=subprocess.PIPE, + ).strip() + subprocess.run( + ["git", "merge-base", "--is-ancestor", commit, + f"refs/remotes/origin/{default_branch}"], + check=True, capture_output=True, + ) + except subprocess.CalledProcessError as error: + raise ValueError( + "Release requires an existing tag reachable from the default branch" + ) from error + if expected_commit and commit != expected_commit: + raise ValueError("Release tag no longer matches the triggering commit") + return commit + + +def check_versions(tag: str, distribution: str, runtime: str) -> None: + version = release_version(tag) + if distribution != version or runtime != version: + raise ValueError("Tag, installed distribution and runtime versions must agree exactly") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + ref = commands.add_parser("ref") + ref.add_argument("--tag", required=True) + ref.add_argument("--default-branch", required=True) + ref.add_argument("--expected-commit", default="") + installed = commands.add_parser("installed") + installed.add_argument("--tag", required=True) + args = parser.parse_args() + try: + if args.command == "ref": + commit = resolve_release(args.tag, args.default_branch, args.expected_commit) + print(f"tag={args.tag}") + print(f"commit={commit}") + else: + from importlib.metadata import version + + from inferrsi import __version__ + + check_versions(args.tag, version("inferrsi"), __version__) + print("Installed release versions agree") + except ValueError as error: + parser.exit(1, f"{error}\n") + + +if __name__ == "__main__": + main() From 24bcbc314b24cadfa0298952fef888ebafe20ec3 Mon Sep 17 00:00:00 2001 From: zengchuang Date: Thu, 29 Oct 2026 11:23:53 +0800 Subject: [PATCH 02/10] ci: automate daily version tags and release publication Skip unchanged source trees, allocate stable patch versions, and validate tag-derived packages before creating tags and publishing releases. Use SCM versions to keep source tags and package versions aligned without bot commits to main. Agent-assisted-by: Codex (implementation and release research) Signed-off-by: zengchuang --- .git_archival.txt | 3 + .gitattributes | 1 + .github/workflows/release.yml | 138 +++++++++++++++++---------- .gitignore | 1 + MANIFEST.in | 5 + docs/contributing.md | 2 +- docs/releases.md | 173 +++++++++++++++++----------------- pyproject.toml | 11 ++- src/inferrsi/__init__.py | 6 +- tests/test_release.py | 108 ++++++++++++++++----- tools/check_release.py | 110 +++++++++++++++------ 11 files changed, 362 insertions(+), 196 deletions(-) create mode 100644 .git_archival.txt create mode 100644 MANIFEST.in diff --git a/.git_archival.txt b/.git_archival.txt new file mode 100644 index 000000000..6acb3b415 --- /dev/null +++ b/.git_archival.txt @@ -0,0 +1,3 @@ +node: $Format:%H$ +node-date: $Format:%cI$ +describe-name: $Format:%(describe:tags=true,match=v[0-9]*)$ diff --git a/.gitattributes b/.gitattributes index af78c9dae..f501e6dec 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,5 @@ * text=auto eol=lf +.git_archival.txt export-subst # Preserve recorded evidence and historical validator bytes across checkouts. /evidence/** -text diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0bba3a13a..098683610 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,16 +1,13 @@ name: Release bot on: - push: - tags: ["v*"] + schedule: + # 02:00 Asia/Shanghai every day. GitHub schedules can be delayed. + - cron: "0 18 * * *" workflow_dispatch: inputs: - tag: - description: Existing stable version tag (vMAJOR.MINOR.PATCH) - required: true - type: string dry_run: - description: Build and validate only; do not create a Release draft + description: Plan and validate only; create no remote tag or Release default: true type: boolean @@ -18,51 +15,82 @@ permissions: contents: read concurrency: - group: release-${{ inputs.tag || github.ref_name }} + group: release-bot cancel-in-progress: false jobs: - build: + plan: + if: >- + github.repository == 'InferMatrixLab/InferRSI' && + github.ref == format('refs/heads/{0}', github.event.repository.default_branch) runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 5 outputs: - tag: ${{ steps.ref.outputs.tag }} - commit: ${{ steps.ref.outputs.commit }} + release: ${{ steps.plan.outputs.release }} + tag: ${{ steps.plan.outputs.tag }} + commit: ${{ steps.plan.outputs.commit }} + previous_tag: ${{ steps.plan.outputs.previous_tag }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Plan only when the source tree changed since the last published release + id: plan + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + shell: bash + run: | + gh api --paginate --slurp "repos/$GH_REPO/releases?per_page=100" > "$RUNNER_TEMP/releases.json" + python tools/check_release.py plan --default-branch "$DEFAULT_BRANCH" \ + --releases "$RUNNER_TEMP/releases.json" >> "$GITHUB_OUTPUT" + + build: + needs: plan + if: needs.plan.outputs.release == 'true' + runs-on: ubuntu-latest + timeout-minutes: 25 env: - RELEASE_TAG: ${{ inputs.tag || github.ref_name }} - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - EXPECTED_COMMIT: ${{ github.event_name == 'push' && github.sha || '' }} + RELEASE_TAG: ${{ needs.plan.outputs.tag }} + RELEASE_COMMIT: ${{ needs.plan.outputs.commit }} steps: - uses: actions/checkout@v4 with: + ref: ${{ needs.plan.outputs.commit }} fetch-depth: 0 persist-credentials: false - uses: actions/setup-python@v5 with: python-version: "3.11" - - name: Validate the existing tag and default-branch ancestry - id: ref + - name: Validate source and create the local version tag shell: bash run: | - python tools/check_release.py ref --tag "$RELEASE_TAG" \ - --default-branch "$DEFAULT_BRANCH" --expected-commit "$EXPECTED_COMMIT" >> "$GITHUB_OUTPUT" - cp tools/check_release.py "$RUNNER_TEMP/check_release.py" - - name: Check out exactly the validated release commit - env: - RELEASE_COMMIT: ${{ steps.ref.outputs.commit }} - run: git checkout --detach "$RELEASE_COMMIT" + [[ "$(git rev-parse HEAD)" == "$RELEASE_COMMIT" ]] + git tag "$RELEASE_TAG" "$RELEASE_COMMIT" + - name: Run CPU checks before release + run: | + python -m pip install -e ".[dev]" build twine + ruff check src tests tools + pytest - name: Build and check distributables + shell: bash run: | - python -m pip install build twine python -m build --outdir "$RUNNER_TEMP/release-dist" python -m twine check "$RUNNER_TEMP"/release-dist/* + python tools/check_release.py sdist "$RUNNER_TEMP"/release-dist/*.tar.gz - name: Check a clean wheel installation outside the checkout shell: bash run: | python -m venv "$RUNNER_TEMP/wheel-env" wheels=("$RUNNER_TEMP"/release-dist/*.whl) "$RUNNER_TEMP/wheel-env/bin/python" -m pip install "${wheels[0]}[skills]" - cp tests/check_installed.py "$RUNNER_TEMP/check_installed.py" + cp tests/check_installed.py tools/check_release.py "$RUNNER_TEMP/" cd "$RUNNER_TEMP" env -u PYTHONPATH "$RUNNER_TEMP/wheel-env/bin/python" -I check_installed.py env -u PYTHONPATH "$RUNNER_TEMP/wheel-env/bin/python" -I check_release.py \ @@ -77,9 +105,11 @@ jobs: if-no-files-found: error retention-days: 7 - draft: - needs: build - if: github.event_name == 'push' || !inputs.dry_run + publish: + needs: [plan, build] + if: >- + needs.plan.outputs.release == 'true' && needs.build.result == 'success' && + (github.event_name == 'schedule' || !inputs.dry_run) runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -87,45 +117,49 @@ jobs: env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} - RELEASE_TAG: ${{ needs.build.outputs.tag }} - RELEASE_COMMIT: ${{ needs.build.outputs.commit }} + RELEASE_TAG: ${{ needs.plan.outputs.tag }} + RELEASE_COMMIT: ${{ needs.plan.outputs.commit }} + PREVIOUS_TAG: ${{ needs.plan.outputs.previous_tag }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} steps: - # Do not check out or execute repository/build code with the write token. + # The write job never checks out or executes repository/build code. - uses: actions/download-artifact@v4 with: name: release-distributions path: dist - - name: Recheck the remote tag and ancestry before writing + - name: Verify downloaded checksums + working-directory: dist + run: sha256sum --check SHA256SUMS + - name: Recheck source ancestry and release state before writing shell: bash run: | - object=$(gh api "repos/$GH_REPO/git/ref/tags/$RELEASE_TAG" --jq '.object') - for attempt in {1..10}; do - kind=$(jq -r '.type' <<< "$object") - sha=$(jq -r '.sha' <<< "$object") - if [[ "$kind" == commit ]]; then break; fi - [[ "$kind" == tag ]] || exit 1 - object=$(gh api "repos/$GH_REPO/git/tags/$sha" --jq '.object') - done - [[ "$kind" == commit && "$sha" == "$RELEASE_COMMIT" ]] status=$(gh api "repos/$GH_REPO/compare/$RELEASE_COMMIT...$DEFAULT_BRANCH" --jq '.status') [[ "$status" == ahead || "$status" == identical ]] - - name: Refuse an existing draft or published Release - shell: bash - run: | - tags=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq '.[].tag_name') + releases=$(gh api --paginate --slurp "repos/$GH_REPO/releases?per_page=100") + tags=$(jq -r '.[][] | .tag_name' <<< "$releases") if grep -Fxq "$RELEASE_TAG" <<< "$tags"; then - echo "::error::Release already exists; inspect it before any manual recovery." + echo "::error::Release already exists; no tags or assets will be overwritten." exit 1 fi - - name: Verify downloaded checksums - working-directory: dist - run: sha256sum --check SHA256SUMS - - name: Create a draft with generated release notes + published=$(jq -r '.[][] | select(.draft == false and .prerelease == false) | .tag_name | select(test("^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$"))' <<< "$releases") + latest=$(printf '%s\n' "$published" | sort -V | tail -n 1) + [[ "$latest" == "$PREVIOUS_TAG" ]] + - name: Create the remote tag, upload packages, then publish shell: bash run: | + gh api --method POST "repos/$GH_REPO/git/refs" \ + -f ref="refs/tags/$RELEASE_TAG" -f sha="$RELEASE_COMMIT" > /dev/null notes="Source: https://github.com/$GH_REPO/tree/$RELEASE_COMMIT" notes+=$'\n\nThese notes describe software changes. Release builds and scaffold checks do not establish campaign PASS, model quality or service performance. Any such claim requires its acceptance issue and complete frozen evidence.' + options=() + if [[ -n "$PREVIOUS_TAG" ]]; then options+=(--notes-start-tag "$PREVIOUS_TAG"); fi gh release create "$RELEASE_TAG" dist/*.whl dist/*.tar.gz dist/SHA256SUMS \ --verify-tag --draft --title "InferRSI $RELEASE_TAG" \ - --generate-notes --notes "$notes" + --generate-notes --notes "$notes" "${options[@]}" + assets=$(gh release view "$RELEASE_TAG" --json assets --jq '.assets[].name') + for file in dist/*.whl dist/*.tar.gz dist/SHA256SUMS; do + grep -Fxq "$(basename "$file")" <<< "$assets" + done + sha=$(gh api "repos/$GH_REPO/git/ref/tags/$RELEASE_TAG" --jq '.object.sha') + [[ "$sha" == "$RELEASE_COMMIT" ]] + gh release edit "$RELEASE_TAG" --draft=false --latest diff --git a/.gitignore b/.gitignore index b9162eb5a..ad24353fa 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ __pycache__/ .venv/ dist/ build/ +src/inferrsi/_version.py .coverage htmlcov/ diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 000000000..b98f40004 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,5 @@ +# Keep SCM discovery from adding repository evidence, workflows or local tooling. +global-exclude * +graft src/inferrsi +include pyproject.toml README.md LICENSE MANIFEST.in +global-exclude __pycache__ *.py[cod] diff --git a/docs/contributing.md b/docs/contributing.md index 2946e7bf0..f8cb1d975 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -87,7 +87,7 @@ licenses, local links and reads from a noneditable wheel installation. ## Acceptance checklist -For software packaging and bot-created Release drafts, follow the +For software packaging and automatic daily Releases, follow the [release guide](releases.md). Release preparation is separate from campaign acceptance and the four CI lanes. diff --git a/docs/releases.md b/docs/releases.md index a3e235afa..3215792f7 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1,100 +1,95 @@ -# Releases - -The [release bot](../.github/workflows/release.yml) builds a stable version tag -and creates a **draft** GitHub Release as `github-actions[bot]`. It uploads a -wheel, source distribution and `SHA256SUMS`, and generates notes from merged PRs. -The [notes configuration](../.github/release.yml) uses existing `enhancement`, -`bug` and `documentation` labels, with a catch-all for other PRs. - -This is release automation, separate from the four CI lanes. It does not bump -versions, merge changes, publish the draft, upload to PyPI or run accelerator -experiments. Building a release does not establish campaign `PASS`. - -## Setup - -Merge the release configuration into the default branch. GitHub Actions must be -enabled and organization policy must allow the listed GitHub-owned actions and -the publisher job's `contents: write` permission. No additional secret, PAT, -GitHub App or repository-wide write-token default is needed. Build jobs have -read-only access; only the draft job receives write access, without executing -repository code. - -The initial bot supports stable tags `vMAJOR.MINOR.PATCH` only. Pre-release and -local-version tags are rejected. Release commits must be reachable from the -default branch. Use tags containing this release tooling and the corresponding -installed-package checker; this is not a retroactive publisher for older tags. - -## Prepare a version - -1. Choose the version and scope in the governing release Issue. Update both - `project.version` in `pyproject.toml` and `__version__` in - `src/inferrsi/__init__.py` in a reviewed PR. No version is chosen or bumped by - this configuration change. -2. Complete the applicable CI/review checks and merge the version PR. The bot - rebuilds and checks package installation, but does not replace PR validation. -3. Fetch the default branch and create an annotated tag at the intended merged - revision. Substitute the selected tag and commit below; `` is the - canonical repository remote, which may differ from a contributor's fork. - - ```bash - git fetch --tags - git tag -a -m "InferRSI " - git push refs/tags/ - ``` - -Pushing the tag starts the bot. It resolves the exact tag ref, checks default -branch ancestry and the push-event commit, builds the sdist and a wheel from -that sdist, runs `twine check`, and reuses `tests/check_installed.py` from a -clean wheel installation outside the checkout. Distribution metadata and the -installed runtime version must both equal the tag's version. - -The bot rechecks the remote tag and ancestry before creating the draft. Avoid -moving/deleting release tags, including during a run; these checks are not a -server-side lock against concurrent tag changes. Tag protection can be managed -separately by repository owners. - -## Dry run and retry - -Under **Actions → Release bot → Run workflow**, select the default branch, -enter an existing version tag and leave **dry_run** checked. This runs the -read-only build and uploads the package/checksum Actions artifact for seven -days. It skips the write job and creates no Release. Clearing **dry_run** -requests a draft for that existing tag. The equivalent CLI is: +# Automatic daily releases + +The [release bot](../.github/workflows/release.yml) runs every day at **02:00 +Asia/Shanghai** (18:00 UTC). GitHub schedules can be delayed. If the default +branch's source tree is unchanged from the highest successfully published stable +version, it skips the release. Empty commits do not cause a release. + +When changed, the bot chooses a version, builds and checks the packages, creates +the tag and publishes the GitHub Release as `github-actions[bot]`. There is no +manual version bump, tag push or draft publication step. The first release uses +`tool.inferrsi.release.initial_version` in `pyproject.toml` (initially `0.1.0`); +subsequent releases allocate the next unused stable patch version. Major/minor +releases need a separately reviewed change to that initial-version floor. + +## Publication flow + +1. Snapshot the default branch and inspect stable tags plus published Releases. + Drafts and prereleases are not successful-release baselines. Compare exact + source trees and reject a published source outside the default branch's + history rather than silently release a rollback. +2. Create a local tag in a read-only build job. `setuptools-scm` derives both + distribution and runtime versions from that tag. Tags point directly at the + reviewed source commit; the bot does not write version commits to `main`. +3. Run CPU tests/lint, build the sdist and a wheel from the sdist, check metadata + with `twine`, and run the complete installed-package checker in a clean wheel + environment outside the checkout. Check tag/distribution/runtime parity and + generate `SHA256SUMS`. `MANIFEST.in` limits sdist contents to package sources + and build metadata; repository evidence/workflows are not release payload. +4. A separate write job verifies the artifacts, source ancestry and release + state again. It creates the remote tag only after validation, creates an + internal draft, uploads wheel/sdist/checksums and automatically publishes + after confirming the attachments and tag identity. It does not execute + repository code. These operations stay in the same workflow: tag events + created with `GITHUB_TOKEN` do not trigger another workflow. + +All source-tree changes, including documentation, are eligible. The +[notes configuration](../.github/release.yml) groups existing `enhancement`, +`bug` and `documentation` labels and includes other merged PRs. Notes use the +previous published tag rather than incomplete reserved tags. A release describes +software changes; CPU/package checks do not establish campaign `PASS`, model +quality or service performance. Such claims retain the acceptance and evidence +requirements in [contributing](contributing.md#evidence-and-claims). + +## Setup and manual checks + +Merge the configuration into the default branch. GitHub Actions and the listed +actions must be allowed by organization policy, and the publisher needs +`contents: write`. No extra secret, PAT, GitHub App or repository-wide write +default is needed. The workflow guards the canonical repository and default +branch; copies must deliberately update that repository guard. + +Under **Actions → Release bot → Run workflow**, select the default branch and +leave **dry_run** checked. It plans and builds the next release without creating +a remote tag or Release; unchanged trees skip here too. Clearing **dry_run** +runs the same automatic publication immediately. It takes no version/tag input. ```bash gh workflow run release.yml --repo InferMatrixLab/InferRSI --ref \ - -f tag= -F dry_run=true + -F dry_run=true ``` -Same-tag runs are serialized. A failure before draft creation can be retried. -An existing draft or published Release stops creation; the bot never overwrites -notes/assets or uses `--clobber`. If an upload left an incomplete draft, inspect -its tag/source and assets first. An authorized maintainer may remove only that -unpublished incomplete draft and rerun, or recover it manually. Published -release corrections require a new version and a separate release decision. +Actions artifacts are retained for seven days. Package versions come from Git +tags/build-generated `_version.py`; plain unbuilt source imports report +`0+unknown`. Build from a full checkout or the supplied sdist. Git archives use +the exported `.git_archival.txt`; development builds without a stable tag may +have a development version and are not stable releases. -## Review and publish +## Failure and concurrency behavior -Inspect the draft notes, source revision and all three attachments. Verify the -downloaded package hashes with `sha256sum --check SHA256SUMS`. Review generated -PR titles and any performance/quality claims: such claims need the acceptance -Issue, pinned experimental revisions and complete frozen evidence described in -[contributing](contributing.md#evidence-and-claims). Preserve failed and -inconclusive results; a software version does not qualify hardware or models. -Then publish the draft through GitHub under the maintainer's release authority. +Runs are serialized across schedule and manual dispatch. A failed build creates +no remote tag or Release. A later tag/upload/publication failure can leave an +unused tag or private incomplete draft. The bot never moves tags, overwrites +notes/assets or uses `--clobber`. On a later eligible run it allocates the next +unused patch version, so failures may leave gaps in version numbers. Failed +attempts do not advance the successful-release baseline or suppress retry of +unreleased source. Old drafts remain for an authorized maintainer to inspect. -The first release may include many historical PRs. Review and edit its generated -notes before publishing; later releases use GitHub's previous-release selection. +Tag creation and public publication are separate GitHub operations. Client +checks cannot lock out concurrent owner actions; manage tag protection/immutable +releases separately if needed. Public release corrections use a new version. +No PyPI upload, native installer, automatic merge, hardware experiment or new CI +lane is added. The existing four lanes remain unchanged. -## Reference and limits +## Sources and verification limits [JiuwenSwarm v0.2.7](https://github.com/openJiuwen-ai/jiuwenswarm/releases/tag/v0.2.7) -demonstrates bot-authored releases with release notes and downloadable packages. -Its publisher is a [private GitHub App](https://github.com/apps/openjiuwen-release-bot); -the publishing workflow is not present in the inspected public source. InferRSI -implements its own flow using the built-in token, following GitHub's -[token permissions](https://docs.github.com/en/actions/tutorials/authenticate-with-github_token), -[generated notes](https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes) -and [draft-release CLI](https://cli.github.com/manual/gh_release_create) contracts. -Remote release execution is verified only by an actual Actions run; local -checks alone do not establish that repository/organization policies permit it. +shows bot-authored release notes and downloadable packages. Its publisher is a +[private GitHub App](https://github.com/apps/openjiuwen-release-bot); its pipeline +is not public. InferRSI uses its own built-in-token workflow following GitHub's +[token/trigger rules](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/trigger-a-workflow), +[generated notes](https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes), +and [publication CLI](https://cli.github.com/manual/gh_release_edit), plus the +[setuptools-scm build/archive contract](https://setuptools-scm.readthedocs.io/latest/usage/). +Remote scheduled execution, repository policy and actual publication remain +unverified until observed in an actual release run. diff --git a/pyproject.toml b/pyproject.toml index dbcc1ee8d..1fb05c1cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [build-system] -requires = ["setuptools>=69"] +requires = ["setuptools>=80", "setuptools-scm>=8"] build-backend = "setuptools.build_meta" [project] name = "inferrsi" -version = "0.1.0" +dynamic = ["version"] description = "CLI-first reproducible inference experiments and source-grounded tuning knowledge" readme = "README.md" license = "Apache-2.0" @@ -25,6 +25,13 @@ namespaces = false [tool.setuptools.package-data] inferrsi = ["data/**/*.json", "data/**/*.md", "data/**/LICENSE", "data/**/THIRD_PARTY_NOTICES", "data/**/scripts/*.py"] +[tool.setuptools_scm] +version_file = "src/inferrsi/_version.py" +tag_regex = '^v(?P[0-9]+\.[0-9]+\.[0-9]+)$' + +[tool.inferrsi.release] +initial_version = "0.1.0" + [project.optional-dependencies] skills = ["PyYAML>=6.0"] dev = ["pytest>=7.4", "ruff>=0.6", "PyYAML>=6.0"] diff --git a/src/inferrsi/__init__.py b/src/inferrsi/__init__.py index a4ec8d46a..ab94bd267 100644 --- a/src/inferrsi/__init__.py +++ b/src/inferrsi/__init__.py @@ -1,3 +1,7 @@ """InferRSI's control package has no inference-runtime import side effects.""" -__version__ = "0.1.0" +try: + from ._version import __version__ +except ImportError: + # A source checkout before its first build has no generated version file. + __version__ = "0+unknown" diff --git a/tests/test_release.py b/tests/test_release.py index 752c2f9d3..5e439aad0 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -1,8 +1,10 @@ +import io import subprocess +import tarfile import pytest -from tools.check_release import check_versions, release_version, resolve_release +from tools.check_release import check_sdist, check_versions, plan_release, release_version @pytest.mark.parametrize("tag", ["v0.1.0", "v1.2.3", "v10.20.30"]) @@ -39,30 +41,94 @@ def git(*args): return git -@pytest.mark.parametrize("annotated", [False, True]) -def test_resolve_lightweight_and_annotated_tags(release_repo, annotated): - git = release_repo - args = ("-a", "-m", "release") if annotated else () - git("tag", *args, "v1.2.3") - commit = git("rev-parse", "HEAD") - assert resolve_release("v1.2.3", "main", commit) == commit +def published(tag="v0.1.0", draft=False, prerelease=False): + return {"tag_name": tag, "draft": draft, "prerelease": prerelease} -def test_branch_with_same_name_does_not_replace_a_missing_tag(release_repo): - release_repo("branch", "v1.2.3") - with pytest.raises(ValueError, match="existing tag"): - resolve_release("v1.2.3", "main") +def change_source(git): + from pathlib import Path + + Path("source.txt").write_text("changed", encoding="utf-8") + git("add", "source.txt") + git("commit", "-m", "change") + git("update-ref", "refs/remotes/origin/main", "HEAD") + + +def test_bootstrap_without_existing_tags(release_repo): + plan = plan_release("main", [], "0.1.0") + assert plan == {"release": "true", "tag": "v0.1.0", + "commit": release_repo("rev-parse", "HEAD"), "previous_tag": ""} + + +@pytest.mark.parametrize("empty_commit", [False, True]) +def test_unchanged_tree_skips_even_with_new_commit(release_repo, empty_commit): + release_repo("tag", "v0.1.0") + if empty_commit: + release_repo("commit", "--allow-empty", "-m", "empty") + release_repo("update-ref", "refs/remotes/origin/main", "HEAD") + assert plan_release("main", [published()], "0.1.0") == {"release": "false"} + + +def test_changed_tree_uses_semantic_patch_order_and_ignores_nonstable_tags(release_repo): + release_repo("tag", "v0.1.9") + release_repo("tag", "v0.1.10") + release_repo("tag", "v9.0.0rc1") + release_repo("tag", "arbitrary") + change_source(release_repo) + plan = plan_release("main", [published("v0.1.9"), published("v0.1.10")], "0.1.0") + assert plan["tag"] == "v0.1.11" + assert plan["previous_tag"] == "v0.1.10" + + +def test_failed_reserved_tag_or_draft_does_not_suppress_retry(release_repo): + release_repo("tag", "v0.1.0") + release_repo("tag", "v0.1.1") + change_source(release_repo) + releases = [published(), published("v0.1.1", draft=True)] + assert plan_release("main", releases, "0.1.0")["tag"] == "v0.1.2" -def test_reject_tag_outside_default_branch(release_repo): - release_repo("checkout", "-b", "feature") +def test_first_attempt_failure_allocates_new_version_instead_of_overwriting(release_repo): + release_repo("tag", "v0.1.0") + plan = plan_release("main", [published(draft=True)], "0.1.0") + assert plan["tag"] == "v0.1.1" and plan["previous_tag"] == "" + + +def test_draft_without_a_remote_tag_still_reserves_its_version(release_repo): + assert plan_release("main", [published(draft=True)], "0.1.0")["tag"] == "v0.1.1" + + +def test_manual_version_floor_and_prerelease_not_a_success_baseline(release_repo): + release_repo("tag", "v0.1.0") + plan = plan_release("main", [published(prerelease=True)], "0.2.0") + assert plan["tag"] == "v0.2.0" and plan["previous_tag"] == "" + + +def test_missing_published_tag_fails_instead_of_publishing_unverified_source(release_repo): + with pytest.raises(subprocess.CalledProcessError): + plan_release("main", [published()], "0.1.0") + + +def test_reject_published_source_outside_default_history(release_repo): + release_repo("checkout", "-b", "unmerged") release_repo("commit", "--allow-empty", "-m", "unmerged") - release_repo("tag", "v1.2.3") - with pytest.raises(ValueError, match="reachable"): - resolve_release("v1.2.3", "main") + release_repo("tag", "v0.1.0") + with pytest.raises(subprocess.CalledProcessError): + plan_release("main", [published()], "0.1.0") -def test_reject_tag_moved_after_push_event(release_repo): - release_repo("tag", "v1.2.3") - with pytest.raises(ValueError, match="triggering commit"): - resolve_release("v1.2.3", "main", "0" * 40) +@pytest.mark.parametrize("name,allowed", [ + ("src/inferrsi/data/example.json", True), ("README.md", True), + ("evidence/run/results.json", False), (".github/workflows/release.yml", False), + ("../outside", False), +]) +def test_sdist_boundary(tmp_path, name, allowed): + path = tmp_path / "package.tar.gz" + with tarfile.open(path, "w:gz") as archive: + entry = tarfile.TarInfo("inferrsi-0.1.0/" + name) + archive.addfile(entry, io.BytesIO(b"")) + if allowed: + check_sdist(path) + else: + with pytest.raises(ValueError): + check_sdist(path) diff --git a/tools/check_release.py b/tools/check_release.py index 02a277d5e..b36e72f62 100644 --- a/tools/check_release.py +++ b/tools/check_release.py @@ -1,8 +1,11 @@ -"""Validate release refs and clean installed versions without publishing anything.""" +"""Plan daily stable releases and validate their packages without publishing.""" import argparse +import json import re import subprocess +import tarfile +from pathlib import Path def release_version(tag: str) -> str: @@ -12,25 +15,49 @@ def release_version(tag: str) -> str: return tag[1:] -def resolve_release(tag: str, default_branch: str, expected_commit: str = "") -> str: - release_version(tag) - try: - commit = subprocess.check_output( - ["git", "rev-parse", "--verify", f"refs/tags/{tag}^{{commit}}"], - text=True, stderr=subprocess.PIPE, - ).strip() - subprocess.run( - ["git", "merge-base", "--is-ancestor", commit, - f"refs/remotes/origin/{default_branch}"], - check=True, capture_output=True, - ) - except subprocess.CalledProcessError as error: - raise ValueError( - "Release requires an existing tag reachable from the default branch" - ) from error - if expected_commit and commit != expected_commit: - raise ValueError("Release tag no longer matches the triggering commit") - return commit +def version_tuple(tag: str) -> tuple[int, ...]: + return tuple(map(int, release_version(tag).split("."))) + + +def git(*args: str) -> str: + return subprocess.check_output(["git", *args], text=True, stderr=subprocess.PIPE).strip() + + +def plan_release(default_branch: str, releases: list[dict], initial_version: str) -> dict: + initial = version_tuple(f"v{initial_version}") + source = git("rev-parse", "--verify", f"refs/remotes/origin/{default_branch}^{{commit}}") + stable = {} + for tag in git("tag", "--list").splitlines(): + try: + stable[tag] = version_tuple(tag) + except ValueError: + continue + published = [] + reserved = [] + for release in releases: + tag = release["tag_name"] + try: + value = version_tuple(tag) + except ValueError: + continue + reserved.append(value) + if release["draft"] is False and release["prerelease"] is False: + published.append((value, tag)) + previous_tag = max(published)[1] if published else "" + if previous_tag: + previous = git("rev-parse", "--verify", f"refs/tags/{previous_tag}^{{commit}}") + subprocess.run(["git", "merge-base", "--is-ancestor", previous, source], + check=True, capture_output=True) + if git("rev-parse", f"{previous}^{{tree}}") == git("rev-parse", f"{source}^{{tree}}"): + return {"release": "false"} + used = [*stable.values(), *reserved] + if used: + major, minor, patch = max(used) + version = max(initial, (major, minor, patch + 1)) + else: + version = initial + return {"release": "true", "tag": "v" + ".".join(map(str, version)), + "commit": source, "previous_tag": previous_tag} def check_versions(tag: str, distribution: str, runtime: str) -> None: @@ -39,21 +66,44 @@ def check_versions(tag: str, distribution: str, runtime: str) -> None: raise ValueError("Tag, installed distribution and runtime versions must agree exactly") +def check_sdist(path: Path) -> None: + # setuptools-scm's file finder must not expand the release to all tracked files. + allowed = {"src", "pyproject.toml", "README.md", "LICENSE", "MANIFEST.in", + "PKG-INFO", "setup.cfg"} + with tarfile.open(path) as archive: + for member in archive.getmembers(): + parts = Path(member.name).parts + if ".." in parts or Path(member.name).is_absolute(): + raise ValueError("Unsafe source-distribution path") + if len(parts) > 1 and parts[1] not in allowed: + raise ValueError("Source distribution includes repository-only material") + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) commands = parser.add_subparsers(dest="command", required=True) - ref = commands.add_parser("ref") - ref.add_argument("--tag", required=True) - ref.add_argument("--default-branch", required=True) - ref.add_argument("--expected-commit", default="") + plan = commands.add_parser("plan") + plan.add_argument("--default-branch", required=True) + plan.add_argument("--releases", type=Path, required=True) installed = commands.add_parser("installed") installed.add_argument("--tag", required=True) + sdist = commands.add_parser("sdist") + sdist.add_argument("path", type=Path) args = parser.parse_args() try: - if args.command == "ref": - commit = resolve_release(args.tag, args.default_branch, args.expected_commit) - print(f"tag={args.tag}") - print(f"commit={commit}") + if args.command == "plan": + # The workflow uses Python 3.11; importing these helpers remains 3.10-safe. + import tomllib + + config = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8")) + pages = json.loads(args.releases.read_text(encoding="utf-8")) + releases = [release for page in pages for release in page] + result = plan_release(args.default_branch, releases, + config["tool"]["inferrsi"]["release"]["initial_version"]) + for key, value in result.items(): + print(f"{key}={value}") + elif args.command == "sdist": + check_sdist(args.path) else: from importlib.metadata import version @@ -61,8 +111,8 @@ def main() -> None: check_versions(args.tag, version("inferrsi"), __version__) print("Installed release versions agree") - except ValueError as error: - parser.exit(1, f"{error}\n") + except (ValueError, subprocess.CalledProcessError) as error: + parser.exit(1, f"Release validation failed: {error}\n") if __name__ == "__main__": From 646e6b59878cfd788df876ead23ab14a653a517a Mon Sep 17 00:00:00 2001 From: zengchuang Date: Thu, 29 Oct 2026 11:27:33 +0800 Subject: [PATCH 03/10] fix: keep reserved tags from changing the planned package version Bind the SCM build version to the validated release plan so failed tag reservations on the same source can recover. Require source cleanliness after tests and builds. Agent-assisted-by: Codex (implementation and independent research) Signed-off-by: zengchuang --- .github/workflows/release.yml | 5 +++++ docs/releases.md | 9 +++++++-- tests/test_release.py | 5 ++++- tools/check_release.py | 1 + 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 098683610..cb35af474 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,6 +28,7 @@ jobs: outputs: release: ${{ steps.plan.outputs.release }} tag: ${{ steps.plan.outputs.tag }} + version: ${{ steps.plan.outputs.version }} commit: ${{ steps.plan.outputs.commit }} previous_tag: ${{ steps.plan.outputs.previous_tag }} steps: @@ -59,6 +60,8 @@ jobs: env: RELEASE_TAG: ${{ needs.plan.outputs.tag }} RELEASE_COMMIT: ${{ needs.plan.outputs.commit }} + # Multiple reserved tags can share a commit after failed publication. + SETUPTOOLS_SCM_PRETEND_VERSION_FOR_INFERRSI: ${{ needs.plan.outputs.version }} steps: - uses: actions/checkout@v4 with: @@ -78,12 +81,14 @@ jobs: python -m pip install -e ".[dev]" build twine ruff check src tests tools pytest + test -z "$(git status --porcelain)" - name: Build and check distributables shell: bash run: | python -m build --outdir "$RUNNER_TEMP/release-dist" python -m twine check "$RUNNER_TEMP"/release-dist/* python tools/check_release.py sdist "$RUNNER_TEMP"/release-dist/*.tar.gz + test -z "$(git status --porcelain)" - name: Check a clean wheel installation outside the checkout shell: bash run: | diff --git a/docs/releases.md b/docs/releases.md index 3215792f7..b6a16fd4d 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -19,7 +19,10 @@ releases need a separately reviewed change to that initial-version floor. source trees and reject a published source outside the default branch's history rather than silently release a rollback. 2. Create a local tag in a read-only build job. `setuptools-scm` derives both - distribution and runtime versions from that tag. Tags point directly at the + distribution and runtime versions from that tag. The build pins SCM's + package-specific version override to the validated plan, avoiding ambiguous + Git version discovery when failed reservations share a source commit. + Tags point directly at the reviewed source commit; the bot does not write version commits to `main`. 3. Run CPU tests/lint, build the sdist and a wheel from the sdist, check metadata with `twine`, and run the complete installed-package checker in a clean wheel @@ -62,7 +65,9 @@ gh workflow run release.yml --repo InferMatrixLab/InferRSI --ref Date: Thu, 29 Oct 2026 11:32:12 +0800 Subject: [PATCH 04/10] fix: preserve developer preview classification in automatic releases Keep published preview versions as no-change baselines, retain the project's prerelease classification, and document tag-derived installation without a stale unpublished-version claim. Agent-assisted-by: Codex (implementation and independent review) Signed-off-by: zengchuang --- .github/workflows/release.yml | 11 +++++++++-- README.md | 7 +++++-- docs/releases.md | 12 +++++++++--- pyproject.toml | 1 + tests/test_release.py | 15 ++++++++++++--- tools/check_release.py | 13 +++++++++---- 6 files changed, 45 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cb35af474..80e18a14a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,6 +29,7 @@ jobs: release: ${{ steps.plan.outputs.release }} tag: ${{ steps.plan.outputs.tag }} version: ${{ steps.plan.outputs.version }} + prerelease: ${{ steps.plan.outputs.prerelease }} commit: ${{ steps.plan.outputs.commit }} previous_tag: ${{ steps.plan.outputs.previous_tag }} steps: @@ -125,6 +126,7 @@ jobs: RELEASE_TAG: ${{ needs.plan.outputs.tag }} RELEASE_COMMIT: ${{ needs.plan.outputs.commit }} PREVIOUS_TAG: ${{ needs.plan.outputs.previous_tag }} + PRERELEASE: ${{ needs.plan.outputs.prerelease }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} steps: # The write job never checks out or executes repository/build code. @@ -146,7 +148,7 @@ jobs: echo "::error::Release already exists; no tags or assets will be overwritten." exit 1 fi - published=$(jq -r '.[][] | select(.draft == false and .prerelease == false) | .tag_name | select(test("^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$"))' <<< "$releases") + published=$(jq -r '.[][] | select(.draft == false) | .tag_name | select(test("^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$"))' <<< "$releases") latest=$(printf '%s\n' "$published" | sort -V | tail -n 1) [[ "$latest" == "$PREVIOUS_TAG" ]] - name: Create the remote tag, upload packages, then publish @@ -167,4 +169,9 @@ jobs: done sha=$(gh api "repos/$GH_REPO/git/ref/tags/$RELEASE_TAG" --jq '.object.sha') [[ "$sha" == "$RELEASE_COMMIT" ]] - gh release edit "$RELEASE_TAG" --draft=false --latest + if [[ "$PRERELEASE" == true ]]; then + gh release edit "$RELEASE_TAG" --draft=false --prerelease --latest=false + else + [[ "$PRERELEASE" == false ]] + gh release edit "$RELEASE_TAG" --draft=false --prerelease=false --latest + fi diff --git a/README.md b/README.md index f18fe084a..8b736c4a4 100644 --- a/README.md +++ b/README.md @@ -125,10 +125,13 @@ MiniCPM's SGLang baseline returns a complete waveform, so its TTFA equals end-to Requires **Python 3.10+**. Licensed under [Apache-2.0](LICENSE). -**v0.1.0 is a planned Developer Preview (pre-release).** The `v0.1.0` tag is not published yet, this preview is not on PyPI, and there is no API stability promise. After the v0.1.0 tag is published, install that pre-release with: +**InferRSI is a Developer Preview (pre-release), with no API stability promise.** +The [daily release bot](docs/releases.md) publishes a version when the default +branch changes. This preview is not on PyPI. Choose an existing tag from +[Releases](https://github.com/InferMatrixLab/InferRSI/releases), then install: ```bash -pip install "git+https://github.com/InferMatrixLab/InferRSI.git@v0.1.0" +pip install "git+https://github.com/InferMatrixLab/InferRSI.git@" ``` From this checkout: diff --git a/docs/releases.md b/docs/releases.md index b6a16fd4d..13c537fa1 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -2,20 +2,26 @@ The [release bot](../.github/workflows/release.yml) runs every day at **02:00 Asia/Shanghai** (18:00 UTC). GitHub schedules can be delayed. If the default -branch's source tree is unchanged from the highest successfully published stable +branch's source tree is unchanged from the highest successfully published numeric version, it skips the release. Empty commits do not cause a release. When changed, the bot chooses a version, builds and checks the packages, creates the tag and publishes the GitHub Release as `github-actions[bot]`. There is no manual version bump, tag push or draft publication step. The first release uses `tool.inferrsi.release.initial_version` in `pyproject.toml` (initially `0.1.0`); -subsequent releases allocate the next unused stable patch version. Major/minor +subsequent releases allocate the next unused numeric patch version. Major/minor releases need a separately reviewed change to that initial-version floor. +The existing **Developer Preview** classification is preserved: +`tool.inferrsi.release.prerelease = true` marks automatic releases as GitHub +prereleases and does not mark them Latest. Changing to a stable channel is a +separately reviewed configuration decision. Published previews count as +successful releases for no-change detection; private drafts do not. + ## Publication flow 1. Snapshot the default branch and inspect stable tags plus published Releases. - Drafts and prereleases are not successful-release baselines. Compare exact + Private drafts are not successful-release baselines. Compare exact source trees and reject a published source outside the default branch's history rather than silently release a rollback. 2. Create a local tag in a read-only build job. `setuptools-scm` derives both diff --git a/pyproject.toml b/pyproject.toml index 1fb05c1cc..046f0b73d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ tag_regex = '^v(?P[0-9]+\.[0-9]+\.[0-9]+)$' [tool.inferrsi.release] initial_version = "0.1.0" +prerelease = true [project.optional-dependencies] skills = ["PyYAML>=6.0"] diff --git a/tests/test_release.py b/tests/test_release.py index 5aa06e8f5..154e47d2e 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -57,7 +57,8 @@ def change_source(git): def test_bootstrap_without_existing_tags(release_repo): plan = plan_release("main", [], "0.1.0") assert plan == {"release": "true", "tag": "v0.1.0", - "version": "0.1.0", "commit": release_repo("rev-parse", "HEAD"), + "version": "0.1.0", "prerelease": "true", + "commit": release_repo("rev-parse", "HEAD"), "previous_tag": ""} @@ -101,10 +102,18 @@ def test_draft_without_a_remote_tag_still_reserves_its_version(release_repo): assert plan_release("main", [published(draft=True)], "0.1.0")["tag"] == "v0.1.1" -def test_manual_version_floor_and_prerelease_not_a_success_baseline(release_repo): +def test_published_developer_preview_is_a_successful_no_change_baseline(release_repo): release_repo("tag", "v0.1.0") + assert plan_release("main", [published(prerelease=True)], "0.1.0") == {"release": "false"} + change_source(release_repo) plan = plan_release("main", [published(prerelease=True)], "0.2.0") - assert plan["tag"] == "v0.2.0" and plan["previous_tag"] == "" + assert plan["tag"] == "v0.2.0" and plan["previous_tag"] == "v0.1.0" + + +def test_explicit_stable_channel_and_invalid_channel_configuration(release_repo): + assert plan_release("main", [], "1.0.0", False)["prerelease"] == "false" + with pytest.raises(ValueError, match="boolean"): + plan_release("main", [], "1.0.0", "false") def test_missing_published_tag_fails_instead_of_publishing_unverified_source(release_repo): diff --git a/tools/check_release.py b/tools/check_release.py index 8146b3867..58c3b8933 100644 --- a/tools/check_release.py +++ b/tools/check_release.py @@ -1,4 +1,4 @@ -"""Plan daily stable releases and validate their packages without publishing.""" +"""Plan daily versioned releases and validate their packages without publishing.""" import argparse import json @@ -23,7 +23,10 @@ def git(*args: str) -> str: return subprocess.check_output(["git", *args], text=True, stderr=subprocess.PIPE).strip() -def plan_release(default_branch: str, releases: list[dict], initial_version: str) -> dict: +def plan_release(default_branch: str, releases: list[dict], initial_version: str, + prerelease: bool = True) -> dict: + if type(prerelease) is not bool: + raise ValueError("Release prerelease setting must be a boolean") initial = version_tuple(f"v{initial_version}") source = git("rev-parse", "--verify", f"refs/remotes/origin/{default_branch}^{{commit}}") stable = {} @@ -41,7 +44,7 @@ def plan_release(default_branch: str, releases: list[dict], initial_version: str except ValueError: continue reserved.append(value) - if release["draft"] is False and release["prerelease"] is False: + if release["draft"] is False: published.append((value, tag)) previous_tag = max(published)[1] if published else "" if previous_tag: @@ -58,6 +61,7 @@ def plan_release(default_branch: str, releases: list[dict], initial_version: str version = initial return {"release": "true", "tag": "v" + ".".join(map(str, version)), "version": ".".join(map(str, version)), + "prerelease": str(prerelease).lower(), "commit": source, "previous_tag": previous_tag} @@ -99,8 +103,9 @@ def main() -> None: config = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8")) pages = json.loads(args.releases.read_text(encoding="utf-8")) releases = [release for page in pages for release in page] + settings = config["tool"]["inferrsi"]["release"] result = plan_release(args.default_branch, releases, - config["tool"]["inferrsi"]["release"]["initial_version"]) + settings["initial_version"], settings["prerelease"]) for key, value in result.items(): print(f"{key}={value}") elif args.command == "sdist": From 8d6cbd9a366f046994fd6b167d1bd5e118d876ec Mon Sep 17 00:00:00 2001 From: zengchuang Date: Thu, 29 Oct 2026 11:49:00 +0800 Subject: [PATCH 05/10] ci: constrain the release bot to tag and release operations Validate publication inputs and existing commit identity, explicitly disable PR permission, and exercise the actual publisher shell against a tag/Release-only recording mock. Document the broader Contents token scope without implying server-side commit or merge denial. Agent-assisted-by: Codex (implementation and independent review) Signed-off-by: zengchuang --- .github/workflows/release.yml | 16 ++- docs/releases.md | 24 +++++ tests/test_release_publisher.py | 175 ++++++++++++++++++++++++++++++++ 3 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 tests/test_release_publisher.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 80e18a14a..3fdcb1f86 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,7 @@ on: permissions: contents: read + pull-requests: none concurrency: group: release-bot @@ -119,7 +120,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 permissions: + # GitHub bundles tag/Release writes under Contents; no tag-only scope exists. + # This workflow must never create commits, update branches or operate on PRs. contents: write + pull-requests: none env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} @@ -130,6 +134,14 @@ jobs: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} steps: # The write job never checks out or executes repository/build code. + - name: Validate publication inputs before using the write token + shell: bash + run: | + version_tag='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' + [[ "$RELEASE_TAG" =~ $version_tag ]] + [[ "$RELEASE_COMMIT" =~ ^[0-9a-f]{40}$ ]] + [[ -z "$PREVIOUS_TAG" || "$PREVIOUS_TAG" =~ $version_tag ]] + [[ "$PRERELEASE" == true || "$PRERELEASE" == false ]] - uses: actions/download-artifact@v4 with: name: release-distributions @@ -140,7 +152,9 @@ jobs: - name: Recheck source ancestry and release state before writing shell: bash run: | - status=$(gh api "repos/$GH_REPO/compare/$RELEASE_COMMIT...$DEFAULT_BRANCH" --jq '.status') + comparison=$(gh api "repos/$GH_REPO/compare/$RELEASE_COMMIT...$DEFAULT_BRANCH") + [[ "$(jq -r '.base_commit.sha' <<< "$comparison")" == "$RELEASE_COMMIT" ]] + status=$(jq -r '.status' <<< "$comparison") [[ "$status" == ahead || "$status" == identical ]] releases=$(gh api --paginate --slurp "repos/$GH_REPO/releases?per_page=100") tags=$(jq -r '.[][] | .tag_name' <<< "$releases") diff --git a/docs/releases.md b/docs/releases.md index 13c537fa1..544b7985a 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -18,6 +18,30 @@ prereleases and does not mark them Latest. Changing to a stable channel is a separately reviewed configuration decision. Published previews count as successful releases for no-change detection; private drafts do not. +## Authority boundary + +The bot's only repository mutations are creating a new `refs/tags/` +at an existing reviewed commit and creating/uploading/publishing that version's +Release. It must not create code commits, edit repository files, create/update/ +delete code branches, create/approve/merge PRs or enable auto-merge. Configuration +development and review are contributor work; they are not bot capabilities. + +Plan/build jobs have Contents read access. Only the publisher receives Contents +write; Pull requests permission is explicitly none. The publisher validates its +tag/SHA inputs and never checks out or executes repository code. Regression +tests execute its actual shell commands against a recording GitHub mock that +rejects non-publication writes. + +This is an enforced workflow behavior contract, not a tag-only token scope. +GitHub's Contents write permission also authorizes +[file commits](https://docs.github.com/en/rest/repos/contents#create-or-update-file-contents) +and [PR merging](https://docs.github.com/en/rest/pulls/pulls#merge-a-pull-request); +setting Pull requests to none does not remove those Contents capabilities. +GitHub does not offer a separate tag/Release-only GITHUB_TOKEN permission. +Server-side branch restrictions need a separately scoped repository-policy +change: the GitHub Actions App identity is shared by other workflows, so this +configuration does not silently change their access or existing branch rules. + ## Publication flow 1. Snapshot the default branch and inspect stable tags plus published Releases. diff --git a/tests/test_release_publisher.py b/tests/test_release_publisher.py new file mode 100644 index 000000000..93831d77a --- /dev/null +++ b/tests/test_release_publisher.py @@ -0,0 +1,175 @@ +"""Exercise the actual publisher shell against a tag/Release-only GitHub mock.""" + +import hashlib +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest +import yaml + +WORKFLOW = Path(__file__).resolve().parents[1] / ".github/workflows/release.yml" +COMMIT = "1" * 40 + +MOCK_GH = r'''#!/usr/bin/env python +import json +import os +import sys +from pathlib import Path + +args = sys.argv[1:] +sys.stdout.reconfigure(newline="\n") +state_path = Path(os.environ["FAKE_GH_STATE"]) +state = json.loads(state_path.read_text()) +log = Path(os.environ["FAKE_GH_LOG"]) + + +def record(operation, **data): + with log.open("a", encoding="utf-8") as stream: + stream.write(json.dumps({"operation": operation, **data}) + "\n") + + +def save(): + state_path.write_text(json.dumps(state)) + + +record("call", args=args) +repo = "repos/example/release" +commit = "1" * 40 +tag = "v1.2.3" +if args == ["api", f"{repo}/compare/{commit}...main"]: + print(json.dumps({"status": state.get("ancestry", "identical"), + "base_commit": {"sha": state.get("resolved", commit)}})) +elif args == ["api", "--paginate", "--slurp", f"{repo}/releases?per_page=100"]: + releases = [{"tag_name": "v1.2.2", "draft": False, "prerelease": True}] + if state.get("existing"): + releases.append({"tag_name": tag, "draft": False, "prerelease": True}) + print(json.dumps([releases])) +elif args == ["api", "--method", "POST", f"{repo}/git/refs", + "-f", f"ref=refs/tags/{tag}", "-f", f"sha={commit}"]: + record("create_tag", ref=f"refs/tags/{tag}", sha=commit) + state["tag_created"] = True + save() +elif args[:3] == ["release", "create", tag]: + if not state.get("tag_created") or "--draft" not in args or "--verify-tag" not in args: + raise SystemExit("Release must be prepared as a draft on the existing tag") + record("create_release") + state["assets"] = ["inferrsi-1.2.3.whl", "inferrsi-1.2.3.tar.gz", "SHA256SUMS"] + if state.get("missing_asset"): + state["assets"].pop() + save() +elif args == ["release", "view", tag, "--json", "assets", "--jq", ".assets[].name"]: + print("\n".join(state["assets"])) +elif args == ["api", f"{repo}/git/ref/tags/{tag}", "--jq", ".object.sha"]: + print("2" * 40 if state.get("moved") else commit) +elif args[:3] == ["release", "edit", tag] and "--draft=false" in args: + record("publish_release") +else: + raise SystemExit("Forbidden operation: publisher may only create tags and publish Releases") +''' + + +def workflow(): + return yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) + + +def test_write_token_is_confined_to_a_no_checkout_publisher(): + config = workflow() + assert config["permissions"] == {"contents": "read", "pull-requests": "none"} + for name, job in config["jobs"].items(): + if name != "publish": + assert not job.get("permissions") + continue + assert job["permissions"] == {"contents": "write", "pull-requests": "none"} + actions = [step["uses"] for step in job["steps"] if "uses" in step] + assert actions == ["actions/download-artifact@v4"] + + +@pytest.fixture +def publisher(tmp_path): + bash = shutil.which("bash") + if os.name == "nt": + candidate = Path(os.environ.get("ProgramFiles", "")) / "Git/bin/bash.exe" + bash = str(candidate) if candidate.is_file() else None + if not bash or not shutil.which("jq"): + pytest.skip("Publisher shell integration requires bash and jq") + mock_bin = tmp_path / "bin" + mock_bin.mkdir() + gh = mock_bin / "gh" + gh.write_text(MOCK_GH, encoding="utf-8", newline="\n") + gh.chmod(0o755) + dist = tmp_path / "dist" + dist.mkdir() + checksums = [] + for name in ("inferrsi-1.2.3.whl", "inferrsi-1.2.3.tar.gz"): + (dist / name).write_bytes(b"synthetic package") + checksums.append(hashlib.sha256(b"synthetic package").hexdigest() + " " + name) + (dist / "SHA256SUMS").write_text("\n".join(checksums) + "\n", encoding="utf-8", newline="\n") + blocks = [] + for step in workflow()["jobs"]["publish"]["steps"]: + if "run" in step: + directory = step.get("working-directory", ".") + blocks.append(f"(cd {directory}\n{step['run']})") + script = tmp_path / "publish.sh" + # Match the Linux runner's LF output when using the native Windows jq binary. + shim = 'jq() { command jq --binary "$@"; }\n' if os.name == "nt" else "" + script.write_text("set -euo pipefail\n" + shim + "\n".join(blocks), + encoding="utf-8", newline="\n") + + def run(state=None, **inputs): + state_path, log_path = tmp_path / "state.json", tmp_path / "calls.jsonl" + state_path.write_text(json.dumps(state or {}), encoding="utf-8") + env = dict(os.environ) + env.update(PATH=str(mock_bin) + os.pathsep + env["PATH"], + FAKE_GH_STATE=str(state_path), FAKE_GH_LOG=str(log_path), + GH_TOKEN="fake-no-access", GH_REPO="example/release", DEFAULT_BRANCH="main", + RELEASE_TAG="v1.2.3", RELEASE_COMMIT=COMMIT, PREVIOUS_TAG="v1.2.2", + PRERELEASE="true") + env.update(inputs) + result = subprocess.run([bash, str(script)], cwd=tmp_path, env=env, + capture_output=True, text=True, timeout=30) + entries = [json.loads(line) for line in log_path.read_text().splitlines()] if ( + log_path.exists()) else [] + mutations = [entry for entry in entries if entry["operation"] != "call"] + return result, mutations + + return run + + +def test_publisher_only_creates_a_tag_and_publishes_its_release(publisher): + result, mutations = publisher() + assert result.returncode == 0, result.stderr + assert mutations == [ + {"operation": "create_tag", "ref": "refs/tags/v1.2.3", "sha": COMMIT}, + {"operation": "create_release"}, {"operation": "publish_release"}, + ] + + +@pytest.mark.parametrize("inputs", [ + {"RELEASE_TAG": "main"}, {"RELEASE_TAG": "refs/heads/main"}, + {"RELEASE_TAG": "--help"}, {"RELEASE_TAG": "v1.2.3\nrefs/heads/main"}, + {"RELEASE_COMMIT": "main"}, {"RELEASE_COMMIT": "1" * 39}, + {"PREVIOUS_TAG": "refs/heads/main"}, {"PRERELEASE": "yes"}, +]) +def test_invalid_publication_inputs_cannot_write(publisher, inputs): + result, mutations = publisher(**inputs) + assert result.returncode != 0 + assert mutations == [] + + +@pytest.mark.parametrize("state", [ + {"existing": True}, {"ancestry": "diverged"}, {"resolved": "2" * 40}, +]) +def test_conflicting_or_unverified_source_cannot_write(publisher, state): + result, mutations = publisher(state) + assert result.returncode != 0 + assert mutations == [] + + +@pytest.mark.parametrize("state", [{"moved": True}, {"missing_asset": True}]) +def test_incomplete_or_retargeted_release_cannot_be_published(publisher, state): + result, mutations = publisher(state) + assert result.returncode != 0 + assert [entry["operation"] for entry in mutations] == ["create_tag", "create_release"] From 4e55261444c65df1aeed3b27e09c82f3194f9b6a Mon Sep 17 00:00:00 2001 From: zengchuang Date: Thu, 29 Oct 2026 19:46:02 +0800 Subject: [PATCH 06/10] ci: attach a versioned Codex plugin installer to releases Reuse the canonical plugin builder, align generated plugin and runtime versions, validate extracted archives and bundled resource hashes, and include the installer in checksum/upload/publication gates. Agent-assisted-by: Codex (implementation and independent research) Signed-off-by: zengchuang --- .github/workflows/release.yml | 18 ++++++-- docs/codex-plugin.md | 14 ++++++ docs/releases.md | 15 +++++- tests/test_codex_plugin.py | 61 +++++++++++++++++++++++- tests/test_release_publisher.py | 22 +++++++-- tools/build_codex_plugin.py | 27 +++++++++-- tools/check_release.py | 82 ++++++++++++++++++++++++++++++++- 7 files changed, 223 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3fdcb1f86..753ff0ccb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -88,7 +88,7 @@ jobs: shell: bash run: | python -m build --outdir "$RUNNER_TEMP/release-dist" - python -m twine check "$RUNNER_TEMP"/release-dist/* + python -m twine check "$RUNNER_TEMP"/release-dist/*.whl "$RUNNER_TEMP"/release-dist/*.tar.gz python tools/check_release.py sdist "$RUNNER_TEMP"/release-dist/*.tar.gz test -z "$(git status --porcelain)" - name: Check a clean wheel installation outside the checkout @@ -102,9 +102,19 @@ jobs: env -u PYTHONPATH "$RUNNER_TEMP/wheel-env/bin/python" -I check_installed.py env -u PYTHONPATH "$RUNNER_TEMP/wheel-env/bin/python" -I check_release.py \ installed --tag "$RELEASE_TAG" + - name: Build and validate the installable Codex plugin ZIP + shell: bash + run: | + python tools/build_codex_plugin.py \ + --output "$RUNNER_TEMP/plugin-build/inferrsi-codex-plugin-$RELEASE_TAG" \ + --release-version "${RELEASE_TAG#v}" + cp "$RUNNER_TEMP/plugin-build/inferrsi-codex-plugin-$RELEASE_TAG.zip" "$RUNNER_TEMP/release-dist/" + python tools/check_release.py plugin \ + "$RUNNER_TEMP/release-dist/inferrsi-codex-plugin-$RELEASE_TAG.zip" --tag "$RELEASE_TAG" + test -z "$(git status --porcelain)" - name: Generate checksums working-directory: ${{ runner.temp }}/release-dist - run: sha256sum *.whl *.tar.gz > SHA256SUMS + run: sha256sum *.whl *.tar.gz *.zip > SHA256SUMS - uses: actions/upload-artifact@v4 with: name: release-distributions @@ -174,11 +184,11 @@ jobs: notes+=$'\n\nThese notes describe software changes. Release builds and scaffold checks do not establish campaign PASS, model quality or service performance. Any such claim requires its acceptance issue and complete frozen evidence.' options=() if [[ -n "$PREVIOUS_TAG" ]]; then options+=(--notes-start-tag "$PREVIOUS_TAG"); fi - gh release create "$RELEASE_TAG" dist/*.whl dist/*.tar.gz dist/SHA256SUMS \ + gh release create "$RELEASE_TAG" dist/*.whl dist/*.tar.gz dist/*.zip dist/SHA256SUMS \ --verify-tag --draft --title "InferRSI $RELEASE_TAG" \ --generate-notes --notes "$notes" "${options[@]}" assets=$(gh release view "$RELEASE_TAG" --json assets --jq '.assets[].name') - for file in dist/*.whl dist/*.tar.gz dist/SHA256SUMS; do + for file in dist/*.whl dist/*.tar.gz dist/*.zip dist/SHA256SUMS; do grep -Fxq "$(basename "$file")" <<< "$assets" done sha=$(gh api "repos/$GH_REPO/git/ref/tags/$RELEASE_TAG" --jq '.object.sha') diff --git a/docs/codex-plugin.md b/docs/codex-plugin.md index 60d82031a..5e40ad565 100644 --- a/docs/codex-plugin.md +++ b/docs/codex-plugin.md @@ -42,6 +42,14 @@ Codex client with plugin support, Python 3.10+ and Git. ## Install from a local archive +The [daily release](releases.md) includes a versioned installer named +`inferrsi-codex-plugin-vX.Y.Z.zip` in +[GitHub Releases](https://github.com/InferMatrixLab/InferRSI/releases). Download +it with `SHA256SUMS`, verify the checksum and extract it into a fresh directory. +The enclosed `inferrsi-codex-plugin-vX.Y.Z/` directory is the marketplace root; +its marketplace name is `inferrsi-local`. This installer bundles the same-version +InferRSI runtime and knowledge, so it does not need a separate pip installation. + Requires a local Codex client with plugin support and Python 3.10 or newer. Git is needed for target-repository inspection and experiment source provenance. The built plugin contains the same InferRSI core and knowledge bytes as its @@ -109,6 +117,12 @@ This writes a fresh `dist/codex-plugin/` marketplace and To rebuild, choose a new `--output `; the builder refuses to replace an existing distribution/archive. +For release builds, first build the runtime so SCM generates `_version.py`, then +pass `--release-version ` and a versioned output directory. The builder +requires that version to match the generated runtime, and stamps only the +generated plugin manifest. The tracked source template remains unchanged. +Unbuilt source previews use the runtime's explicit `0+unknown` fallback. + For a separately named distribution, use `--marketplace-name inferrsi-preview`. Publish only the generated marketplace directory on its distribution branch; the source template alone is not a working remote plugin. diff --git a/docs/releases.md b/docs/releases.md index 544b7985a..7bff8bd8c 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -59,9 +59,14 @@ configuration does not silently change their access or existing branch rules. environment outside the checkout. Check tag/distribution/runtime parity and generate `SHA256SUMS`. `MANIFEST.in` limits sdist contents to package sources and build metadata; repository evidence/workflows are not release payload. + Reuse `tools/build_codex_plugin.py` to build the self-contained + `inferrsi-codex-plugin-vX.Y.Z.zip` installer from the same runtime bytes. + Its generated plugin metadata, bundled runtime and tag must have the same + version. Verify every bundled runtime hash and exercise the extracted + launcher/knowledge reads outside the checkout with isolated Python. 4. A separate write job verifies the artifacts, source ancestry and release state again. It creates the remote tag only after validation, creates an - internal draft, uploads wheel/sdist/checksums and automatically publishes + internal draft, uploads wheel/sdist/plugin ZIP/checksums and automatically publishes after confirming the attachments and tag identity. It does not execute repository code. These operations stay in the same workflow: tag events created with `GITHUB_TOKEN` do not trigger another workflow. @@ -74,6 +79,14 @@ software changes; CPU/package checks do not establish campaign `PASS`, model quality or service performance. Such claims retain the acceptance and evidence requirements in [contributing](contributing.md#evidence-and-claims). +Each Release contains a Python wheel, source distribution, the versioned Codex +plugin ZIP and `SHA256SUMS` covering all three packages. Extract the ZIP and +follow the [Codex plugin installation guide](codex-plugin.md#install-from-a-local-archive). +The ZIP contains the native local marketplace, plugin, complete bundled runtime +and knowledge, licenses and installation README. Plugin build failure or a +missing/corrupt installer blocks publication. It does not update a plugin branch, +account installation or marketplace listing. + ## Setup and manual checks Merge the configuration into the default branch. GitHub Actions and the listed diff --git a/tests/test_codex_plugin.py b/tests/test_codex_plugin.py index bfc74f43a..060a0a9c4 100644 --- a/tests/test_codex_plugin.py +++ b/tests/test_codex_plugin.py @@ -10,7 +10,8 @@ import pytest -from tools.build_codex_plugin import PROJECT, _io_path, build_plugin +from tools.build_codex_plugin import PROJECT, _io_path, _version, build_plugin +from tools.check_release import check_plugin try: import tomllib @@ -151,6 +152,64 @@ def test_named_marketplace_installs_the_same_bundled_runtime(tmp_path): assert json.loads(info.stdout)["content_sha256"] == manifest["content_sha256"] +@pytest.fixture(scope="module") +def release_distribution(tmp_path_factory): + root = tmp_path_factory.mktemp("codex-release") + project = root / "source" + for directory in ("src/inferrsi", "plugins/inferrsi"): + shutil.copytree(_io_path(PROJECT / directory), _io_path(project / directory), + ignore=shutil.ignore_patterns("__pycache__", "*.pyc")) + (project / "docs").mkdir() + shutil.copyfile(PROJECT / "docs/codex-plugin.md", project / "docs/codex-plugin.md") + shutil.copyfile(PROJECT / "LICENSE", project / "LICENSE") + (project / "src/inferrsi/_version.py").write_text( + '__version__ = version = "0.2.0"\n', encoding="utf-8", + ) + template = (project / "plugins/inferrsi/plugin.json").read_bytes() + result = build_plugin(root / "inferrsi-codex-plugin-v0.2.0", project=project, + release_version="0.2.0") + return project, template, result + + +def test_release_archive_is_installable_and_all_versions_match(release_distribution): + project, template, result = release_distribution + check_plugin(Path(result["archive"]), "v0.2.0") + assert (project / "plugins/inferrsi/plugin.json").read_bytes() == template + with zipfile.ZipFile(result["archive"]) as archive: + name = "inferrsi-codex-plugin-v0.2.0/plugins/inferrsi/plugin.json" + assert json.loads(archive.read(name))["version"] == "0.2.0" + with pytest.raises(ValueError, match="agree exactly"): + check_plugin(Path(result["archive"]), "v0.2.1") + + +def test_release_archive_rejects_changed_runtime_bytes(release_distribution, tmp_path): + original = release_distribution[2]["archive"] + changed = tmp_path / "changed.zip" + with zipfile.ZipFile(original) as source, zipfile.ZipFile(changed, "w") as destination: + for member in source.infolist(): + data = source.read(member.filename) + if member.filename.endswith("runtime/inferrsi/__init__.py"): + data += b"\n" + destination.writestr(member, data) + with pytest.raises(ValueError, match="checksum mismatch"): + check_plugin(changed, "v0.2.0") + + +def test_release_builder_rejects_mismatched_version_before_writing(release_distribution, tmp_path): + project = release_distribution[0] + output = tmp_path / "wrong-version" + with pytest.raises(ValueError, match="match the built runtime"): + build_plugin(output, project=project, release_version="0.2.1") + assert not output.exists() and not output.with_suffix(".zip").exists() + + +def test_unbuilt_version_fallback_does_not_import_an_installed_runtime(tmp_path): + source = tmp_path / "__init__.py" + source.write_text('try:\n from ._version import __version__\n' + 'except ImportError:\n __version__ = "0+unknown"\n', encoding="utf-8") + assert _version(source) == "0+unknown" + + @pytest.mark.parametrize(("quality", "exit_code", "verdict"), [ ("pass", 0, "PASS"), ("fail", 1, "FAIL"), ("missing", 2, "INCONCLUSIVE"), ]) diff --git a/tests/test_release_publisher.py b/tests/test_release_publisher.py index 93831d77a..fff623ef5 100644 --- a/tests/test_release_publisher.py +++ b/tests/test_release_publisher.py @@ -56,7 +56,12 @@ def save(): if not state.get("tag_created") or "--draft" not in args or "--verify-tag" not in args: raise SystemExit("Release must be prepared as a draft on the existing tag") record("create_release") - state["assets"] = ["inferrsi-1.2.3.whl", "inferrsi-1.2.3.tar.gz", "SHA256SUMS"] + installer = "dist/inferrsi-codex-plugin-v1.2.3.zip" + if installer not in args: + raise SystemExit("Release upload must include the versioned Codex installer") + record("upload_plugin", name=Path(installer).name) + state["assets"] = ["inferrsi-1.2.3.whl", "inferrsi-1.2.3.tar.gz", "SHA256SUMS", + Path(installer).name] if state.get("missing_asset"): state["assets"].pop() save() @@ -103,7 +108,7 @@ def publisher(tmp_path): dist = tmp_path / "dist" dist.mkdir() checksums = [] - for name in ("inferrsi-1.2.3.whl", "inferrsi-1.2.3.tar.gz"): + for name in ("inferrsi-1.2.3.whl", "inferrsi-1.2.3.tar.gz", "inferrsi-codex-plugin-v1.2.3.zip"): (dist / name).write_bytes(b"synthetic package") checksums.append(hashlib.sha256(b"synthetic package").hexdigest() + " " + name) (dist / "SHA256SUMS").write_text("\n".join(checksums) + "\n", encoding="utf-8", newline="\n") @@ -119,6 +124,10 @@ def publisher(tmp_path): encoding="utf-8", newline="\n") def run(state=None, **inputs): + if state and state.get("corrupt_zip"): + (dist / "inferrsi-codex-plugin-v1.2.3.zip").write_bytes(b"changed installer") + if state and state.get("absent_zip"): + (dist / "inferrsi-codex-plugin-v1.2.3.zip").unlink() state_path, log_path = tmp_path / "state.json", tmp_path / "calls.jsonl" state_path.write_text(json.dumps(state or {}), encoding="utf-8") env = dict(os.environ) @@ -143,7 +152,9 @@ def test_publisher_only_creates_a_tag_and_publishes_its_release(publisher): assert result.returncode == 0, result.stderr assert mutations == [ {"operation": "create_tag", "ref": "refs/tags/v1.2.3", "sha": COMMIT}, - {"operation": "create_release"}, {"operation": "publish_release"}, + {"operation": "create_release"}, + {"operation": "upload_plugin", "name": "inferrsi-codex-plugin-v1.2.3.zip"}, + {"operation": "publish_release"}, ] @@ -161,6 +172,7 @@ def test_invalid_publication_inputs_cannot_write(publisher, inputs): @pytest.mark.parametrize("state", [ {"existing": True}, {"ancestry": "diverged"}, {"resolved": "2" * 40}, + {"corrupt_zip": True}, {"absent_zip": True}, ]) def test_conflicting_or_unverified_source_cannot_write(publisher, state): result, mutations = publisher(state) @@ -172,4 +184,6 @@ def test_conflicting_or_unverified_source_cannot_write(publisher, state): def test_incomplete_or_retargeted_release_cannot_be_published(publisher, state): result, mutations = publisher(state) assert result.returncode != 0 - assert [entry["operation"] for entry in mutations] == ["create_tag", "create_release"] + assert [entry["operation"] for entry in mutations] == [ + "create_tag", "create_release", "upload_plugin", + ] diff --git a/tools/build_codex_plugin.py b/tools/build_codex_plugin.py index 925e78bc9..3c2952755 100644 --- a/tools/build_codex_plugin.py +++ b/tools/build_codex_plugin.py @@ -32,16 +32,19 @@ def _json(path: Path, value: dict) -> None: def _version(path: Path) -> str: - for node in ast.parse(path.read_text(encoding="utf-8")).body: + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): if isinstance(node, ast.Assign) and any( isinstance(target, ast.Name) and target.id == "__version__" for target in node.targets ): - return ast.literal_eval(node.value) + value = ast.literal_eval(node.value) + if isinstance(value, str): + return value raise ValueError("Runtime __version__ is missing") def build_plugin( output: Path, *, project: Path = PROJECT, marketplace_name: str = "inferrsi-local", + release_version: str | None = None, ) -> dict: """Create a fresh distribution; never replace user configuration or earlier builds.""" if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", marketplace_name): @@ -57,6 +60,16 @@ def build_plugin( source = _io_path(project / "src" / "inferrsi") template = _io_path(project / "plugins" / "inferrsi") + generated = source / "_version.py" + runtime_version = _version(generated if generated.is_file() else source / "__init__.py") + if release_version is not None: + number = r"(?:0|[1-9][0-9]*)" + if not re.fullmatch(rf"{number}\.{number}\.{number}", release_version): + raise ValueError("Release version must have the numeric form MAJOR.MINOR.PATCH") + if release_version != runtime_version: + raise ValueError( + "Release version must match the built runtime; build the runtime first" + ) _io_path(output.parent).mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix="irsi-", dir=_io_path(output.parent)) as staging: stage = Path(staging) / output.name @@ -87,10 +100,13 @@ def build_plugin( ) identity = "\n".join(f"{name}\0{digest}" for name, digest in sorted(hashes.items())) metadata = json.loads((plugin / "plugin.json").read_text(encoding="utf-8")) + if release_version is not None: + metadata["version"] = release_version + _json(plugin / "plugin.json", metadata) _json(runtime / "manifest.json", { "schema_version": 1, "plugin_version": metadata["version"], - "runtime_version": _version(source / "__init__.py"), + "runtime_version": runtime_version, "content_sha256": hashlib.sha256(identity.encode("utf-8")).hexdigest(), "files": hashes, }) @@ -129,9 +145,12 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--output", type=Path, default=PROJECT / "dist" / "codex-plugin") parser.add_argument("--marketplace-name", default="inferrsi-local") + parser.add_argument("--release-version", + help="Require the built runtime version and stamp the ZIP") args = parser.parse_args() try: - result = build_plugin(args.output, marketplace_name=args.marketplace_name) + result = build_plugin(args.output, marketplace_name=args.marketplace_name, + release_version=args.release_version) except (OSError, ValueError) as exc: parser.error(str(exc)) print(json.dumps(result, indent=2)) diff --git a/tools/check_release.py b/tools/check_release.py index 58c3b8933..b9abce3f3 100644 --- a/tools/check_release.py +++ b/tools/check_release.py @@ -1,11 +1,17 @@ """Plan daily versioned releases and validate their packages without publishing.""" import argparse +import hashlib import json +import os import re +import stat import subprocess +import sys import tarfile -from pathlib import Path +import tempfile +import zipfile +from pathlib import Path, PurePosixPath def release_version(tag: str) -> str: @@ -84,6 +90,72 @@ def check_sdist(path: Path) -> None: raise ValueError("Source distribution includes repository-only material") +def check_plugin(path: Path, tag: str) -> None: + version = release_version(tag) + with zipfile.ZipFile(path) as archive: + names = archive.namelist() + roots = {PurePosixPath(name).parts[0] for name in names if name} + if len(roots) != 1 or len(names) != len(set(names)): + raise ValueError("Plugin ZIP must contain one unambiguous marketplace root") + for member in archive.infolist(): + name = PurePosixPath(member.filename) + if (not name.parts or name.is_absolute() or ".." in name.parts + or "\\" in member.filename or ":" in member.filename + or stat.S_ISLNK(member.external_attr >> 16)): + raise ValueError("Unsafe plugin ZIP entry") + root = roots.pop() + plugin = f"{root}/plugins/inferrsi" + metadata = json.loads(archive.read(f"{plugin}/plugin.json")) + manifest = json.loads(archive.read(f"{plugin}/runtime/manifest.json")) + catalog = json.loads(archive.read(f"{root}/.agents/plugins/marketplace.json")) + expected = [("inferrsi", {"source": "local", "path": "./plugins/inferrsi"})] + if [(item["name"], item["source"]) for item in catalog["plugins"]] != expected: + raise ValueError("Plugin marketplace does not resolve the bundled plugin") + check_versions(tag, metadata["version"], manifest["runtime_version"]) + if metadata["name"] != "inferrsi" or manifest["plugin_version"] != version: + raise ValueError("Plugin identity/version does not match the release") + prefix = f"{plugin}/runtime/" + actual = {name[len(prefix):] for name in names + if name.startswith(prefix + "inferrsi/") and not name.endswith("/")} + hashes = manifest["files"] + if not hashes or set(hashes) != actual: + raise ValueError("Plugin manifest must cover every bundled runtime file") + for name, digest in hashes.items(): + if hashlib.sha256(archive.read(prefix + name)).hexdigest() != digest: + raise ValueError("Plugin bundled runtime checksum mismatch") + identity = "\n".join(f"{name}\0{digest}" for name, digest in sorted(hashes.items())) + if hashlib.sha256(identity.encode("utf-8")).hexdigest() != manifest["content_sha256"]: + raise ValueError("Plugin content identity mismatch") + with tempfile.TemporaryDirectory(prefix="irsi-") as staging: + destination = Path(staging).resolve() + if os.name == "nt": + destination = Path("\\\\?\\" + str(destination)) + archive.extractall(destination) + launcher = destination / plugin / "skills/inferrsi/scripts/inferrsi.py" + command = [sys.executable, "-I", "-S", "-X", "utf8", str(launcher)] + + def invoke(*args): + try: + result = subprocess.run(command + list(args), cwd=staging, check=True, + capture_output=True, text=True, encoding="utf-8", + timeout=45) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error: + raise ValueError("Extracted Codex plugin failed isolated validation") from error + return json.loads(result.stdout) + + info = invoke("--plugin-info") + check_versions(tag, info["plugin_version"], info["runtime_version"]) + if info["runtime"] != "bundled" or info["content_sha256"] != manifest["content_sha256"]: + raise ValueError("Plugin launcher did not select the verified bundled runtime") + items = invoke("collect", "--limit", "1", "--json")["items"] + if not items: + raise ValueError("Plugin has no bundled knowledge") + item = items[0] + material = invoke("read", item["ref"], "--revision", item["revision"], "--material") + if material["revision"] != item["revision"]: + raise ValueError("Plugin knowledge read lost its revision identity") + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) commands = parser.add_subparsers(dest="command", required=True) @@ -94,6 +166,9 @@ def main() -> None: installed.add_argument("--tag", required=True) sdist = commands.add_parser("sdist") sdist.add_argument("path", type=Path) + plugin = commands.add_parser("plugin") + plugin.add_argument("path", type=Path) + plugin.add_argument("--tag", required=True) args = parser.parse_args() try: if args.command == "plan": @@ -110,6 +185,8 @@ def main() -> None: print(f"{key}={value}") elif args.command == "sdist": check_sdist(args.path) + elif args.command == "plugin": + check_plugin(args.path, args.tag) else: from importlib.metadata import version @@ -117,7 +194,8 @@ def main() -> None: check_versions(args.tag, version("inferrsi"), __version__) print("Installed release versions agree") - except (ValueError, subprocess.CalledProcessError) as error: + except (ValueError, KeyError, OSError, zipfile.BadZipFile, + subprocess.CalledProcessError) as error: parser.exit(1, f"Release validation failed: {error}\n") From 8d091e27f81852267fdf2555f2e991617d22ca22 Mon Sep 17 00:00:00 2001 From: zengchuang Date: Thu, 29 Oct 2026 19:47:41 +0800 Subject: [PATCH 07/10] docs: keep the plugin installer release guide link portable Use a canonical URL in the README copied into installer archives and describe source-revision parity precisely. Agent-assisted-by: Codex (implementation and independent review) Signed-off-by: zengchuang --- docs/codex-plugin.md | 3 ++- docs/releases.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/codex-plugin.md b/docs/codex-plugin.md index 5e40ad565..ec77e532b 100644 --- a/docs/codex-plugin.md +++ b/docs/codex-plugin.md @@ -42,7 +42,8 @@ Codex client with plugin support, Python 3.10+ and Git. ## Install from a local archive -The [daily release](releases.md) includes a versioned installer named +The [daily release guide](https://github.com/InferMatrixLab/InferRSI/blob/main/docs/releases.md) +describes the versioned installer named `inferrsi-codex-plugin-vX.Y.Z.zip` in [GitHub Releases](https://github.com/InferMatrixLab/InferRSI/releases). Download it with `SHA256SUMS`, verify the checksum and extract it into a fresh directory. diff --git a/docs/releases.md b/docs/releases.md index 7bff8bd8c..e8ad1495c 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -60,7 +60,7 @@ configuration does not silently change their access or existing branch rules. generate `SHA256SUMS`. `MANIFEST.in` limits sdist contents to package sources and build metadata; repository evidence/workflows are not release payload. Reuse `tools/build_codex_plugin.py` to build the self-contained - `inferrsi-codex-plugin-vX.Y.Z.zip` installer from the same runtime bytes. + `inferrsi-codex-plugin-vX.Y.Z.zip` installer from the same source revision. Its generated plugin metadata, bundled runtime and tag must have the same version. Verify every bundled runtime hash and exercise the extracted launcher/knowledge reads outside the checkout with isolated Python. From 379570c1ffe3a847c3bbe52ef57448ef80e86fef Mon Sep 17 00:00:00 2001 From: zengchuang Date: Thu, 29 Oct 2026 19:52:23 +0800 Subject: [PATCH 08/10] fix: reject installer ZIP path aliases and unverified runtime files Validate canonical cross-platform extraction destinations before metadata reads or execution, reject path collisions and import-shadowing modules, and cover malformed archive regressions. Agent-assisted-by: Codex (implementation and independent review) Signed-off-by: zengchuang --- tests/test_codex_plugin.py | 33 +++++++++++++++++++++++++++++++++ tools/check_release.py | 25 +++++++++++++++++++++---- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/tests/test_codex_plugin.py b/tests/test_codex_plugin.py index 060a0a9c4..acf98771e 100644 --- a/tests/test_codex_plugin.py +++ b/tests/test_codex_plugin.py @@ -195,6 +195,39 @@ def test_release_archive_rejects_changed_runtime_bytes(release_distribution, tmp check_plugin(changed, "v0.2.0") +@pytest.mark.parametrize("replacement", [ + "plugins//inferrsi/", "plugins/./inferrsi/", "plugins/Inferrsi/", "plugins./inferrsi/", +]) +def test_release_archive_rejects_extraction_aliases(tmp_path, replacement): + changed = tmp_path / "aliased.zip" + name = "inferrsi-codex-plugin-v0.2.0/plugins/inferrsi/runtime/inferrsi/__init__.py" + alias = name.replace("plugins/inferrsi/", replacement) + with zipfile.ZipFile(changed, "w") as archive: + archive.writestr(name, b"pass") + archive.writestr(alias, b"raise RuntimeError('must not execute unverified alias')") + with pytest.raises(ValueError, match="Unsafe"): + check_plugin(changed, "v0.2.0") + + +@pytest.mark.parametrize("name", [".", "../file", "/absolute", "root/CON.txt", "root/file. "]) +def test_release_archive_rejects_unsafe_names_before_metadata(tmp_path, name): + changed = tmp_path / "unsafe.zip" + with zipfile.ZipFile(changed, "w") as archive: + archive.writestr(name, b"") + with pytest.raises(ValueError, match="Unsafe"): + check_plugin(changed, "v0.2.0") + + +def test_release_archive_rejects_unverified_runtime_modules(release_distribution, tmp_path): + original = release_distribution[2]["archive"] + changed = tmp_path / "shadow.zip" + shutil.copyfile(original, changed) + with zipfile.ZipFile(changed, "a") as archive: + archive.writestr("inferrsi-codex-plugin-v0.2.0/plugins/inferrsi/runtime/hashlib.py", b"") + with pytest.raises(ValueError, match="outside the bundled runtime"): + check_plugin(changed, "v0.2.0") + + def test_release_builder_rejects_mismatched_version_before_writing(release_distribution, tmp_path): project = release_distribution[0] output = tmp_path / "wrong-version" diff --git a/tools/check_release.py b/tools/check_release.py index b9abce3f3..70a0ddbef 100644 --- a/tools/check_release.py +++ b/tools/check_release.py @@ -94,15 +94,27 @@ def check_plugin(path: Path, tag: str) -> None: version = release_version(tag) with zipfile.ZipFile(path) as archive: names = archive.namelist() - roots = {PurePosixPath(name).parts[0] for name in names if name} - if len(roots) != 1 or len(names) != len(set(names)): + if len(names) != len(set(names)): raise ValueError("Plugin ZIP must contain one unambiguous marketplace root") + roots = set() + destinations = set() for member in archive.infolist(): name = PurePosixPath(member.filename) + canonical = name.as_posix() + ("/" if member.is_dir() else "") if (not name.parts or name.is_absolute() or ".." in name.parts or "\\" in member.filename or ":" in member.filename + or member.filename != canonical + or name.as_posix().casefold() in destinations + or any(part.endswith((".", " ")) or re.search(r'[<>"|?*]', part) + or re.fullmatch(r"con|prn|aux|nul|com[1-9]|lpt[1-9]", + part.split(".")[0], re.IGNORECASE) + for part in name.parts) or stat.S_ISLNK(member.external_attr >> 16)): raise ValueError("Unsafe plugin ZIP entry") + destinations.add(name.as_posix().casefold()) + roots.add(name.parts[0]) + if len(roots) != 1: + raise ValueError("Plugin ZIP must contain one unambiguous marketplace root") root = roots.pop() plugin = f"{root}/plugins/inferrsi" metadata = json.loads(archive.read(f"{plugin}/plugin.json")) @@ -116,7 +128,10 @@ def check_plugin(path: Path, tag: str) -> None: raise ValueError("Plugin identity/version does not match the release") prefix = f"{plugin}/runtime/" actual = {name[len(prefix):] for name in names - if name.startswith(prefix + "inferrsi/") and not name.endswith("/")} + if name.startswith(prefix) and name != prefix + "manifest.json" + and not name.endswith("/")} + if any(not name.startswith("inferrsi/") for name in actual): + raise ValueError("Unexpected file outside the bundled runtime package") hashes = manifest["files"] if not hashes or set(hashes) != actual: raise ValueError("Plugin manifest must cover every bundled runtime file") @@ -129,7 +144,9 @@ def check_plugin(path: Path, tag: str) -> None: with tempfile.TemporaryDirectory(prefix="irsi-") as staging: destination = Path(staging).resolve() if os.name == "nt": - destination = Path("\\\\?\\" + str(destination)) + value = str(destination) + destination = Path("\\\\?\\UNC\\" + value[2:] if value.startswith("\\\\") + else "\\\\?\\" + value) archive.extractall(destination) launcher = destination / plugin / "skills/inferrsi/scripts/inferrsi.py" command = [sys.executable, "-I", "-S", "-X", "utf8", str(launcher)] From 619b522ad8c047fbb5a45c1ef062cfb396d64c85 Mon Sep 17 00:00:00 2001 From: zengchuang Date: Thu, 29 Oct 2026 19:54:32 +0800 Subject: [PATCH 09/10] fix: reject case-aliased installer directory trees Track consistent spelling for every archive path prefix so Windows cannot coalesce an unverified alternate directory into the verified runtime. Agent-assisted-by: Codex (implementation and independent review) Signed-off-by: zengchuang --- tests/test_codex_plugin.py | 9 +++++++++ tools/check_release.py | 5 +++++ 2 files changed, 14 insertions(+) diff --git a/tests/test_codex_plugin.py b/tests/test_codex_plugin.py index acf98771e..57c520c9a 100644 --- a/tests/test_codex_plugin.py +++ b/tests/test_codex_plugin.py @@ -218,6 +218,15 @@ def test_release_archive_rejects_unsafe_names_before_metadata(tmp_path, name): check_plugin(changed, "v0.2.0") +def test_release_archive_rejects_new_modules_under_case_aliased_directories(tmp_path): + changed = tmp_path / "directory-alias.zip" + with zipfile.ZipFile(changed, "w") as archive: + archive.writestr("root/plugins/inferrsi/runtime/inferrsi/__init__.py", b"pass") + archive.writestr("root/Plugins/inferrsi/runtime/hashlib.py", b"must not execute") + with pytest.raises(ValueError, match="Unsafe"): + check_plugin(changed, "v0.2.0") + + def test_release_archive_rejects_unverified_runtime_modules(release_distribution, tmp_path): original = release_distribution[2]["archive"] changed = tmp_path / "shadow.zip" diff --git a/tools/check_release.py b/tools/check_release.py index 70a0ddbef..8d3c67adf 100644 --- a/tools/check_release.py +++ b/tools/check_release.py @@ -98,6 +98,7 @@ def check_plugin(path: Path, tag: str) -> None: raise ValueError("Plugin ZIP must contain one unambiguous marketplace root") roots = set() destinations = set() + spellings = {} for member in archive.infolist(): name = PurePosixPath(member.filename) canonical = name.as_posix() + ("/" if member.is_dir() else "") @@ -112,6 +113,10 @@ def check_plugin(path: Path, tag: str) -> None: or stat.S_ISLNK(member.external_attr >> 16)): raise ValueError("Unsafe plugin ZIP entry") destinations.add(name.as_posix().casefold()) + for depth in range(1, len(name.parts) + 1): + prefix = "/".join(name.parts[:depth]) + if spellings.setdefault(prefix.casefold(), prefix) != prefix: + raise ValueError("Unsafe plugin ZIP directory alias") roots.add(name.parts[0]) if len(roots) != 1: raise ValueError("Plugin ZIP must contain one unambiguous marketplace root") From e4ec9add3321cad842f7c7f926da34b03e9785be Mon Sep 17 00:00:00 2001 From: zengchuang Date: Sat, 31 Oct 2026 11:50:10 +0800 Subject: [PATCH 10/10] fix: ship the Codex plugin as a standalone offline ZIP Remove marketplace wrappers and installation instructions, preserve bundled runtime verification, and install Python releases from validated wheels to avoid same-commit tag ambiguity. Agent-assisted-by: Codex (implementation and independent review) Signed-off-by: zengchuang --- README.md | 10 +-- docs/codex-plugin.md | 133 +++++++++++++---------------------- docs/releases.md | 7 +- plugins/inferrsi/plugin.json | 2 +- tests/test_codex_plugin.py | 60 ++++++++++------ tools/build_codex_plugin.py | 37 +++------- tools/check_release.py | 22 +++--- 7 files changed, 120 insertions(+), 151 deletions(-) diff --git a/README.md b/README.md index 8b736c4a4..4e0ded005 100644 --- a/README.md +++ b/README.md @@ -127,14 +127,16 @@ Requires **Python 3.10+**. Licensed under [Apache-2.0](LICENSE). **InferRSI is a Developer Preview (pre-release), with no API stability promise.** The [daily release bot](docs/releases.md) publishes a version when the default -branch changes. This preview is not on PyPI. Choose an existing tag from -[Releases](https://github.com/InferMatrixLab/InferRSI/releases), then install: +branch changes. This preview is not on PyPI. Download the wheel and `SHA256SUMS` +from the selected [Release](https://github.com/InferMatrixLab/InferRSI/releases), +verify its checksum, then install the downloaded wheel: ```bash -pip install "git+https://github.com/InferMatrixLab/InferRSI.git@" +python -m pip install "/inferrsi--py3-none-any.whl" ``` -From this checkout: +For Codex, use the [standalone plugin ZIP](docs/codex-plugin.md#install-from-the-release-zip). +For CLI development from this checkout: ```bash python -m venv .venv diff --git a/docs/codex-plugin.md b/docs/codex-plugin.md index ec77e532b..7d0c0b983 100644 --- a/docs/codex-plugin.md +++ b/docs/codex-plugin.md @@ -1,55 +1,46 @@ -# InferRSI plugin for Codex (local preview) +# InferRSI plugin for Codex (Developer Preview) Use InferRSI from a Codex conversation in your target repository: research a bottleneck, prepare a comparison, or inspect an existing run. This is the Codex implementation slice of [RFC #152](https://github.com/InferMatrixLab/InferRSI/issues/152); DSH/openJiuwen integration and a measured usability comparison remain separate. -## Install from the GitHub preview +## Install from the Release ZIP -Give Codex the [preview installation page](https://github.com/zengchuang-hw/OmniRSI/tree/codex/inferrsi-plugin-preview) -and ask it to install InferRSI. On Windows, use the following PowerShell route -to fetch only the generated branch and enable long paths for this Git operation: - -```powershell -$pluginInstallDir = Join-Path $env:LOCALAPPDATA "InferRSI/codex-plugin-preview" -git -c core.longpaths=true clone --depth 1 --single-branch --branch codex/inferrsi-plugin-preview https://github.com/zengchuang-hw/OmniRSI $pluginInstallDir -codex plugin marketplace add $pluginInstallDir -codex plugin add inferrsi@inferrsi-preview -codex plugin list --marketplace inferrsi-preview --json -``` - -Choose a fresh directory, or inspect an existing installation before reusing it. -This does not change global Git configuration. Direct Git marketplace registration -on the tested Windows client first checked out the repository's default branch -and hit the platform's path-length limit; the explicit single-branch clone avoids -that unrelated checkout. Other clients may use the native Git-source route: +The [daily release guide](https://github.com/InferMatrixLab/InferRSI/blob/main/docs/releases.md) +describes the versioned installer named +`inferrsi-codex-plugin-vX.Y.Z.zip` in +[GitHub Releases](https://github.com/InferMatrixLab/InferRSI/releases). Download +it with `SHA256SUMS` and verify the ZIP's SHA-256 before importing it. The ZIP +contains exactly one plugin directory: ```text -codex plugin marketplace add https://github.com/zengchuang-hw/OmniRSI --ref codex/inferrsi-plugin-preview -codex plugin add inferrsi@inferrsi-preview -codex plugin list --marketplace inferrsi-preview --json +inferrsi/ + plugin.json + skills/inferrsi/SKILL.md + skills/inferrsi/scripts/inferrsi.py + runtime/manifest.json + runtime/inferrsi/ + LICENSE + README.md ``` -This uses a generated preview branch in the fork, separate from InferRSI's main -branch and the public plugin directory. The branch includes the marketplace, -native plugin and bundled runtime, so the client does not need to build source. -Keep one InferRSI installation active: if switching from the older local preview, -remove `inferrsi@inferrsi-local` and its marketplace using the commands below. -Start a new conversation after installation; refresh/restart a desktop client -if its plugin catalog has not updated. Both installation routes require a local -Codex client with plugin support, Python 3.10+ and Git. +The downloaded archive bundles the same-version InferRSI runtime and knowledge; +installation requires no source checkout, package build or separate pip install. +There is no marketplace catalog, registration step or distribution branch. -## Install from a local archive +In a Codex client with **Plugin Creator** available, provide the downloaded ZIP +and ask: "Import this InferRSI plugin ZIP as a private plugin." For an existing +InferRSI plugin, ask to update that plugin from the ZIP instead of creating a +duplicate. Use the returned plugin link to enable it, then start a new +conversation. The import uploads the archive to the account/workspace; it still +requires a network connection. Here, offline package means the runtime and +resources are included, not that the Codex client can operate air-gapped. -The [daily release guide](https://github.com/InferMatrixLab/InferRSI/blob/main/docs/releases.md) -describes the versioned installer named -`inferrsi-codex-plugin-vX.Y.Z.zip` in -[GitHub Releases](https://github.com/InferMatrixLab/InferRSI/releases). Download -it with `SHA256SUMS`, verify the checksum and extract it into a fresh directory. -The enclosed `inferrsi-codex-plugin-vX.Y.Z/` directory is the marketplace root; -its marketplace name is `inferrsi-local`. This installer bundles the same-version -InferRSI runtime and knowledge, so it does not need a separate pip installation. +Clients without archive import cannot install this package directly. The current +Codex CLI's `plugin add` accepts marketplace selectors, not ZIP paths; do not pass +the archive to that command. Archive ingestion and enablement must be verified +on the target host; release checks validate package contents and local execution. Requires a local Codex client with plugin support and Python 3.10 or newer. Git is needed for target-repository inspection and experiment source provenance. @@ -57,28 +48,6 @@ The built plugin contains the same InferRSI core and knowledge bytes as its source build; basic operations need no pip install, network service or API key specific to InferRSI. Your normal Codex model connection is still required. -Obtain the generated marketplace folder, or extract its ZIP. Then run: - -The following selectors are for the default `inferrsi-local` build. For an -archive from the GitHub preview, use `inferrsi-preview` instead; the authoritative -name is the top-level `name` in `.agents/plugins/marketplace.json`. - -```text -codex plugin marketplace add "" -codex plugin add inferrsi@inferrsi-local -codex plugin list --marketplace inferrsi-local --json -``` - -`` is the extracted directory containing -`.agents/plugins/marketplace.json` and `plugins/inferrsi/`. Substitute its actual -path. Local marketplace paths are resolved from that root. The raw -`plugins/inferrsi/` source template is not a complete distribution. - -In desktop clients, the same local marketplace can be found in the plugin -directory and InferRSI installed/enabled there. Begin a new conversation after -installation so its skill catalog includes InferRSI. If a running client does not -refresh the marketplace, restart it and check again. - ## First task Open the repository you want to investigate and ask, for example: @@ -113,8 +82,9 @@ Image validators and target engines retain their own optional dependencies. python tools/build_codex_plugin.py ``` -This writes a fresh `dist/codex-plugin/` marketplace and -`dist/codex-plugin.zip`. Use that marketplace directory in the install commands. +This writes a fresh `dist/codex-plugin/` standalone plugin directory and +`dist/codex-plugin.zip`. The ZIP always has the single enclosing `inferrsi/` +directory shown above; the output directory's name does not add a wrapper. To rebuild, choose a new `--output `; the builder refuses to replace an existing distribution/archive. @@ -124,9 +94,8 @@ requires that version to match the generated runtime, and stamps only the generated plugin manifest. The tracked source template remains unchanged. Unbuilt source previews use the runtime's explicit `0+unknown` fallback. -For a separately named distribution, use `--marketplace-name inferrsi-preview`. -Publish only the generated marketplace directory on its distribution branch; -the source template alone is not a working remote plugin. +Only the ZIP is attached to Releases. The raw `plugins/inferrsi/` source template +does not contain the bundled runtime and is not an installation package. The build copies the canonical Python package, its packaged knowledge/resources and source notices, plus the repository license. It records per-file SHA-256 @@ -136,27 +105,21 @@ source owners. No new core dependencies or CI lanes are introduced. ## Updates, disable and uninstall -Build a fresh distribution. Remove this preview plugin and its marketplace using -the commands below, then repeat installation with the new marketplace root. -Check the selected bundle with `--plugin-info` in a new conversation. This also -refreshes a locally rebuilt preview with the same version. Do not overwrite an -installed cache or carry forward an old runtime by hand. +Download and verify the new Release ZIP, then ask Plugin Creator to update the +existing InferRSI plugin from that archive. Check the selected bundle with +`--plugin-info` in a new conversation. Do not overwrite an installed cache or +carry forward an old runtime by hand. -Disable InferRSI in the desktop plugin UI when you want to retain its installation -but stop loading it. To remove the preview: +Plugin Creator updates overlay existing files. Ask it to compare the old and new +runtime manifests and explicitly delete removed runtime/resources while retaining +the same plugin identity and audience; omitting a file from the ZIP alone does +not remove its installed copy. -```text -codex plugin remove inferrsi@inferrsi-local -codex plugin marketplace remove inferrsi-local -``` - -For the GitHub preview, substitute `inferrsi@inferrsi-preview` and -`inferrsi-preview` respectively. With the Windows clone route, update only that -clone using `git -C "" -c core.longpaths=true pull --ff-only`, -then reinstall and verify the plugin. A source registered directly as Git can -instead be refreshed with `codex plugin marketplace upgrade inferrsi-preview`. -Refresh/reinstall the selected bundle, especially when a preview is rebuilt with -the same version. +Disable InferRSI in the desktop plugin UI when you want to retain its installation +but stop loading it. To uninstall, remove the selected InferRSI plugin in the +client's plugin management UI. Existing marketplace-based previews are a separate +installation: disable/remove them in the client before enabling the ZIP import +so only one InferRSI entry skill is active. Removal does not delete target repositories or experiment evidence. The plugin registers no MCP server, hooks, daemon or scheduled task. diff --git a/docs/releases.md b/docs/releases.md index e8ad1495c..d40b9f407 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -81,11 +81,12 @@ requirements in [contributing](contributing.md#evidence-and-claims). Each Release contains a Python wheel, source distribution, the versioned Codex plugin ZIP and `SHA256SUMS` covering all three packages. Extract the ZIP and -follow the [Codex plugin installation guide](codex-plugin.md#install-from-a-local-archive). -The ZIP contains the native local marketplace, plugin, complete bundled runtime +follow the [Codex plugin installation guide](codex-plugin.md#install-from-the-release-zip). +The ZIP contains one standalone plugin, complete bundled runtime and knowledge, licenses and installation README. Plugin build failure or a missing/corrupt installer blocks publication. It does not update a plugin branch, -account installation or marketplace listing. +account installation or marketplace listing. No marketplace catalog or installation +wrapper is packaged; the downloaded ZIP is the only Codex plugin release asset. ## Setup and manual checks diff --git a/plugins/inferrsi/plugin.json b/plugins/inferrsi/plugin.json index a55e58818..a829ad496 100644 --- a/plugins/inferrsi/plugin.json +++ b/plugins/inferrsi/plugin.json @@ -12,7 +12,7 @@ "onboardingSkill": "./skills/inferrsi/SKILL.md", "interface": { "displayName": "InferRSI", - "shortDescription": "Research and verify inference optimizations", + "shortDescription": "Inference research and checks", "developerName": "InferMatrixLab", "category": "Productivity", "defaultPrompt": [ diff --git a/tests/test_codex_plugin.py b/tests/test_codex_plugin.py index 57c520c9a..3b9b61e22 100644 --- a/tests/test_codex_plugin.py +++ b/tests/test_codex_plugin.py @@ -21,16 +21,16 @@ @pytest.fixture(scope="module") def distribution(tmp_path_factory): - root = tmp_path_factory.mktemp("codex-plugin") / "marketplace" + root = tmp_path_factory.mktemp("codex-plugin") / "distribution" result = build_plugin(root) extracted = _io_path(root.parent / "extracted") with zipfile.ZipFile(result["archive"]) as archive: archive.extractall(extracted) - return extracted / root.name + return extracted / "inferrsi" def invoke(distribution, cwd, *args): - launcher = distribution / "plugins/inferrsi/skills/inferrsi/scripts/inferrsi.py" + launcher = distribution / "skills/inferrsi/scripts/inferrsi.py" return subprocess.run( [sys.executable, "-I", "-S", "-X", "utf8", str(launcher), *args], cwd=cwd, capture_output=True, text=True, encoding="utf-8", timeout=45, @@ -38,7 +38,7 @@ def invoke(distribution, cwd, *args): def test_bundle_preserves_canonical_bytes_and_license(distribution): - plugin = distribution / "plugins/inferrsi" + plugin = distribution runtime = plugin / "runtime" manifest = json.loads((runtime / "manifest.json").read_text(encoding="utf-8")) for name, digest in manifest["files"].items(): @@ -46,7 +46,6 @@ def test_bundle_preserves_canonical_bytes_and_license(distribution): assert hashlib.sha256(actual).hexdigest() == digest assert actual == _io_path(PROJECT / "src" / name).read_bytes() assert (plugin / "LICENSE").read_bytes() == (PROJECT / "LICENSE").read_bytes() - assert (distribution / "LICENSE").read_bytes() == (PROJECT / "LICENSE").read_bytes() assert not list(plugin.rglob("*.pyc")) @@ -61,7 +60,7 @@ def test_bundle_contains_all_declared_package_data(distribution): for pattern in patterns for path in source.glob(pattern) if path.is_file() } assert "data/skill_library/repositories/splash/THIRD_PARTY_NOTICES" in expected - runtime = distribution / "plugins/inferrsi/runtime" + runtime = distribution / "runtime" installed = { path.relative_to(runtime / "inferrsi").as_posix() for path in (runtime / "inferrsi/data").rglob("*") if path.is_file() @@ -110,7 +109,7 @@ def test_entry_workflow_selects_packaged_skill_projections(distribution, tmp_pat def test_missing_runtime_has_repair_guidance(tmp_path): source = PROJECT / "plugins/inferrsi" incomplete = tmp_path / "incomplete" - shutil.copytree(source, incomplete / "plugins/inferrsi") + shutil.copytree(source, incomplete) result = invoke(incomplete, tmp_path, "--plugin-info") assert result.returncode == 2 assert "built distribution" in result.stderr @@ -140,15 +139,22 @@ def test_builder_refuses_to_replace_existing_files(tmp_path): assert sentinel.read_text(encoding="utf-8") == "keep" -def test_named_marketplace_installs_the_same_bundled_runtime(tmp_path): - output = tmp_path / "remote-preview" - result = build_plugin(output, marketplace_name="inferrsi-preview") - catalog = json.loads((output / ".agents/plugins/marketplace.json").read_text(encoding="utf-8")) - assert result["marketplace_name"] == catalog["name"] == "inferrsi-preview" - plugin = output / catalog["plugins"][0]["source"]["path"] +def test_archive_contains_only_one_standalone_plugin(tmp_path): + output = tmp_path / "offline-package" + result = build_plugin(output) + assert result["plugin"] == str(output) + with zipfile.ZipFile(result["archive"]) as archive: + names = archive.namelist() + assert {Path(name).parts[0] for name in names} == {"inferrsi"} + assert "inferrsi/plugin.json" in names + assert "inferrsi/README.md" in names + assert not any("marketplace" in name or ".agents" in name for name in names) + metadata = json.loads(archive.read("inferrsi/plugin.json")) + assert metadata["$schema"] == "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json" + assert len(metadata["extensions"]["com.openai"]["interface"]["shortDescription"]) <= 30 info = invoke(output, tmp_path, "--plugin-info") assert info.returncode == 0, info.stderr - manifest = json.loads((plugin / "runtime/manifest.json").read_text(encoding="utf-8")) + manifest = json.loads((output / "runtime/manifest.json").read_text(encoding="utf-8")) assert json.loads(info.stdout)["content_sha256"] == manifest["content_sha256"] @@ -176,7 +182,7 @@ def test_release_archive_is_installable_and_all_versions_match(release_distribut check_plugin(Path(result["archive"]), "v0.2.0") assert (project / "plugins/inferrsi/plugin.json").read_bytes() == template with zipfile.ZipFile(result["archive"]) as archive: - name = "inferrsi-codex-plugin-v0.2.0/plugins/inferrsi/plugin.json" + name = "inferrsi/plugin.json" assert json.loads(archive.read(name))["version"] == "0.2.0" with pytest.raises(ValueError, match="agree exactly"): check_plugin(Path(result["archive"]), "v0.2.1") @@ -196,12 +202,12 @@ def test_release_archive_rejects_changed_runtime_bytes(release_distribution, tmp @pytest.mark.parametrize("replacement", [ - "plugins//inferrsi/", "plugins/./inferrsi/", "plugins/Inferrsi/", "plugins./inferrsi/", + "inferrsi//", "inferrsi/./", "Inferrsi/", "inferrsi./", ]) def test_release_archive_rejects_extraction_aliases(tmp_path, replacement): changed = tmp_path / "aliased.zip" - name = "inferrsi-codex-plugin-v0.2.0/plugins/inferrsi/runtime/inferrsi/__init__.py" - alias = name.replace("plugins/inferrsi/", replacement) + name = "inferrsi/runtime/inferrsi/__init__.py" + alias = name.replace("inferrsi/", replacement, 1) with zipfile.ZipFile(changed, "w") as archive: archive.writestr(name, b"pass") archive.writestr(alias, b"raise RuntimeError('must not execute unverified alias')") @@ -221,8 +227,8 @@ def test_release_archive_rejects_unsafe_names_before_metadata(tmp_path, name): def test_release_archive_rejects_new_modules_under_case_aliased_directories(tmp_path): changed = tmp_path / "directory-alias.zip" with zipfile.ZipFile(changed, "w") as archive: - archive.writestr("root/plugins/inferrsi/runtime/inferrsi/__init__.py", b"pass") - archive.writestr("root/Plugins/inferrsi/runtime/hashlib.py", b"must not execute") + archive.writestr("inferrsi/runtime/inferrsi/__init__.py", b"pass") + archive.writestr("inferrsi/Runtime/hashlib.py", b"must not execute") with pytest.raises(ValueError, match="Unsafe"): check_plugin(changed, "v0.2.0") @@ -232,11 +238,23 @@ def test_release_archive_rejects_unverified_runtime_modules(release_distribution changed = tmp_path / "shadow.zip" shutil.copyfile(original, changed) with zipfile.ZipFile(changed, "a") as archive: - archive.writestr("inferrsi-codex-plugin-v0.2.0/plugins/inferrsi/runtime/hashlib.py", b"") + archive.writestr("inferrsi/runtime/hashlib.py", b"") with pytest.raises(ValueError, match="outside the bundled runtime"): check_plugin(changed, "v0.2.0") +@pytest.mark.parametrize("extra", [ + "inferrsi/.agents/plugins/marketplace.json", "marketplace/plugins/inferrsi/plugin.json", +]) +def test_release_archive_rejects_marketplace_wrappers(release_distribution, tmp_path, extra): + changed = tmp_path / "marketplace.zip" + shutil.copyfile(release_distribution[2]["archive"], changed) + with zipfile.ZipFile(changed, "a") as archive: + archive.writestr(extra, "{}") + with pytest.raises(ValueError, match="standalone|marketplace"): + check_plugin(changed, "v0.2.0") + + def test_release_builder_rejects_mismatched_version_before_writing(release_distribution, tmp_path): project = release_distribution[0] output = tmp_path / "wrong-version" diff --git a/tools/build_codex_plugin.py b/tools/build_codex_plugin.py index 3c2952755..40a38fd7a 100644 --- a/tools/build_codex_plugin.py +++ b/tools/build_codex_plugin.py @@ -1,4 +1,4 @@ -"""Build a local Codex marketplace and archive from the canonical runtime bytes.""" +"""Build a standalone Codex plugin archive from the canonical runtime bytes.""" from __future__ import annotations @@ -43,12 +43,9 @@ def _version(path: Path) -> str: def build_plugin( - output: Path, *, project: Path = PROJECT, marketplace_name: str = "inferrsi-local", - release_version: str | None = None, + output: Path, *, project: Path = PROJECT, release_version: str | None = None, ) -> dict: """Create a fresh distribution; never replace user configuration or earlier builds.""" - if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", marketplace_name): - raise ValueError("Marketplace name must contain lowercase words separated by hyphens") output = output.resolve() archive = output.with_name(output.name + ".zip") if output.exists() or archive.exists(): @@ -72,12 +69,10 @@ def build_plugin( ) _io_path(output.parent).mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix="irsi-", dir=_io_path(output.parent)) as staging: - stage = Path(staging) / output.name - plugin = stage / "plugins" / "inferrsi" + plugin = Path(staging) / "inferrsi" shutil.copytree(template, plugin, ignore=shutil.ignore_patterns("__pycache__", "*.pyc")) shutil.copyfile(project / "LICENSE", plugin / "LICENSE") - shutil.copyfile(project / "LICENSE", stage / "LICENSE") - shutil.copyfile(project / "docs" / "codex-plugin.md", stage / "README.md") + shutil.copyfile(project / "docs" / "codex-plugin.md", plugin / "README.md") runtime = plugin / "runtime" hashes = {} for path in sorted(source.rglob("*")): @@ -110,32 +105,20 @@ def build_plugin( "content_sha256": hashlib.sha256(identity.encode("utf-8")).hexdigest(), "files": hashes, }) - _json(stage / ".agents" / "plugins" / "marketplace.json", { - "name": marketplace_name, - "interface": {"displayName": "InferRSI preview"}, - "plugins": [{ - "name": "inferrsi", - "source": {"source": "local", "path": "./plugins/inferrsi"}, - "policy": {"installation": "AVAILABLE", "authentication": "ON_INSTALL"}, - "category": "Productivity", - }], - }) # Stable ZIP metadata makes repeated builds of identical bytes comparable. with zipfile.ZipFile(archive, "x", compression=zipfile.ZIP_DEFLATED) as bundle: - for path in sorted(stage.rglob("*")): + for path in sorted(plugin.rglob("*")): if path.is_file(): info = zipfile.ZipInfo( - (Path(output.name) / path.relative_to(stage)).as_posix(), + (Path("inferrsi") / path.relative_to(plugin)).as_posix(), date_time=(1980, 1, 1, 0, 0, 0), ) info.compress_type = zipfile.ZIP_DEFLATED info.external_attr = 0o100644 << 16 bundle.writestr(info, path.read_bytes()) - shutil.copytree(stage, _io_path(output)) + shutil.copytree(plugin, _io_path(output)) return { - "marketplace_name": marketplace_name, - "marketplace": str(output), - "plugin": str(output / "plugins" / "inferrsi"), + "plugin": str(output), "archive": str(archive), "runtime_files": len(hashes), } @@ -144,13 +127,11 @@ def build_plugin( def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--output", type=Path, default=PROJECT / "dist" / "codex-plugin") - parser.add_argument("--marketplace-name", default="inferrsi-local") parser.add_argument("--release-version", help="Require the built runtime version and stamp the ZIP") args = parser.parse_args() try: - result = build_plugin(args.output, marketplace_name=args.marketplace_name, - release_version=args.release_version) + result = build_plugin(args.output, release_version=args.release_version) except (OSError, ValueError) as exc: parser.error(str(exc)) print(json.dumps(result, indent=2)) diff --git a/tools/check_release.py b/tools/check_release.py index 8d3c67adf..f5a2bd7f7 100644 --- a/tools/check_release.py +++ b/tools/check_release.py @@ -95,7 +95,7 @@ def check_plugin(path: Path, tag: str) -> None: with zipfile.ZipFile(path) as archive: names = archive.namelist() if len(names) != len(set(names)): - raise ValueError("Plugin ZIP must contain one unambiguous marketplace root") + raise ValueError("Plugin ZIP must contain one standalone inferrsi plugin") roots = set() destinations = set() spellings = {} @@ -118,16 +118,20 @@ def check_plugin(path: Path, tag: str) -> None: if spellings.setdefault(prefix.casefold(), prefix) != prefix: raise ValueError("Unsafe plugin ZIP directory alias") roots.add(name.parts[0]) - if len(roots) != 1: - raise ValueError("Plugin ZIP must contain one unambiguous marketplace root") - root = roots.pop() - plugin = f"{root}/plugins/inferrsi" + if roots != {"inferrsi"}: + raise ValueError("Plugin ZIP must contain one standalone inferrsi plugin") + plugin = "inferrsi" + allowed = {"plugin.json", "skills", "runtime", "LICENSE", "README.md"} + if any(len(PurePosixPath(name).parts) > 1 + and PurePosixPath(name).parts[1] not in allowed for name in names): + raise ValueError("Plugin ZIP includes a marketplace or unrelated wrapper") metadata = json.loads(archive.read(f"{plugin}/plugin.json")) manifest = json.loads(archive.read(f"{plugin}/runtime/manifest.json")) - catalog = json.loads(archive.read(f"{root}/.agents/plugins/marketplace.json")) - expected = [("inferrsi", {"source": "local", "path": "./plugins/inferrsi"})] - if [(item["name"], item["source"]) for item in catalog["plugins"]] != expected: - raise ValueError("Plugin marketplace does not resolve the bundled plugin") + if metadata.get("$schema") != "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json": + raise ValueError("Plugin manifest must use the portable plugin schema") + archive.getinfo(f"{plugin}/skills/inferrsi/SKILL.md") + archive.getinfo(f"{plugin}/README.md") + archive.getinfo(f"{plugin}/LICENSE") check_versions(tag, metadata["version"], manifest["runtime_version"]) if metadata["name"] != "inferrsi" or manifest["plugin_version"] != version: raise ValueError("Plugin identity/version does not match the release")