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/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..753ff0ccb --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,201 @@ +name: Release bot + +on: + schedule: + # 02:00 Asia/Shanghai every day. GitHub schedules can be delayed. + - cron: "0 18 * * *" + workflow_dispatch: + inputs: + dry_run: + description: Plan and validate only; create no remote tag or Release + default: true + type: boolean + +permissions: + contents: read + pull-requests: none + +concurrency: + group: release-bot + cancel-in-progress: false + +jobs: + plan: + if: >- + github.repository == 'InferMatrixLab/InferRSI' && + github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + 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: + - 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: ${{ 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: + ref: ${{ needs.plan.outputs.commit }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Validate source and create the local version tag + shell: bash + run: | + [[ "$(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 + 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/*.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 + 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 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 \ + 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 *.zip > SHA256SUMS + - uses: actions/upload-artifact@v4 + with: + name: release-distributions + path: ${{ runner.temp }}/release-dist/ + if-no-files-found: error + retention-days: 7 + + 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: + # 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 }} + 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. + - 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 + path: dist + - name: Verify downloaded checksums + working-directory: dist + run: sha256sum --check SHA256SUMS + - name: Recheck source ancestry and release state before writing + shell: bash + run: | + 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") + if grep -Fxq "$RELEASE_TAG" <<< "$tags"; then + echo "::error::Release already exists; no tags or assets will be overwritten." + exit 1 + fi + 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 + 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/*.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/*.zip 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" ]] + 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/.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/README.md b/README.md index f18fe084a..4e0ded005 100644 --- a/README.md +++ b/README.md @@ -125,13 +125,18 @@ 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. 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@v0.1.0" +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 60d82031a..7d0c0b983 100644 --- a/docs/codex-plugin.md +++ b/docs/codex-plugin.md @@ -1,46 +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. + +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. @@ -48,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: @@ -104,14 +82,20 @@ 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. -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. +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. + +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 @@ -121,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. - -Disable InferRSI in the desktop plugin UI when you want to retain its installation -but stop loading it. To remove the preview: +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. -```text -codex plugin remove inferrsi@inferrsi-local -codex plugin marketplace remove inferrsi-local -``` +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. -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/contributing.md b/docs/contributing.md index 151eff5c4..f8cb1d975 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 automatic daily Releases, 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..d40b9f407 --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,144 @@ +# 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 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 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. + +## 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. + 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 + 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 + 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 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. +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/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. + +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). + +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-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. No marketplace catalog or installation +wrapper is packaged; the downloaded ZIP is the only Codex plugin release asset. + +## 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 dry_run=true +``` + +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`; when multiple tags share a commit, use the +uploaded wheel/sdist or explicitly select the SCM build version. Development +builds without a stable tag may +have a development version and are not stable releases. + +## Failure and concurrency behavior + +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. + +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. + +## Sources and verification limits + +[JiuwenSwarm v0.2.7](https://github.com/openJiuwen-ai/jiuwenswarm/releases/tag/v0.2.7) +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/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/pyproject.toml b/pyproject.toml index dbcc1ee8d..046f0b73d 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,14 @@ 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" +prerelease = true + [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_codex_plugin.py b/tests/test_codex_plugin.py index bfc74f43a..3b9b61e22 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 @@ -20,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, @@ -37,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(): @@ -45,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")) @@ -60,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() @@ -109,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 @@ -139,18 +139,137 @@ 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"] +@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/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") + + +@pytest.mark.parametrize("replacement", [ + "inferrsi//", "inferrsi/./", "Inferrsi/", "inferrsi./", +]) +def test_release_archive_rejects_extraction_aliases(tmp_path, replacement): + changed = tmp_path / "aliased.zip" + 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')") + 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_new_modules_under_case_aliased_directories(tmp_path): + changed = tmp_path / "directory-alias.zip" + with zipfile.ZipFile(changed, "w") as archive: + 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") + + +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/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" + 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.py b/tests/test_release.py new file mode 100644 index 000000000..154e47d2e --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,146 @@ +import io +import subprocess +import tarfile + +import pytest + +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"]) +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 + + +def published(tag="v0.1.0", draft=False, prerelease=False): + return {"tag_name": tag, "draft": draft, "prerelease": prerelease} + + +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", + "version": "0.1.0", "prerelease": "true", + "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_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"] == "" + assert plan["version"] == "0.1.1" + assert plan["commit"] == release_repo("rev-parse", "refs/tags/v0.1.0") + + +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_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"] == "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): + 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", "v0.1.0") + with pytest.raises(subprocess.CalledProcessError): + plan_release("main", [published()], "0.1.0") + + +@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/tests/test_release_publisher.py b/tests/test_release_publisher.py new file mode 100644 index 000000000..fff623ef5 --- /dev/null +++ b/tests/test_release_publisher.py @@ -0,0 +1,189 @@ +"""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") + 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() +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", "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") + 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): + 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) + 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": "upload_plugin", "name": "inferrsi-codex-plugin-v1.2.3.zip"}, + {"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}, + {"corrupt_zip": True}, {"absent_zip": True}, +]) +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", "upload_plugin", + ] diff --git a/tools/build_codex_plugin.py b/tools/build_codex_plugin.py index 925e78bc9..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 @@ -32,20 +32,20 @@ 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", + 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(): @@ -57,14 +57,22 @@ 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 - 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("*")): @@ -87,39 +95,30 @@ 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, }) - _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), } @@ -128,10 +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) + 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 new file mode 100644 index 000000000..f5a2bd7f7 --- /dev/null +++ b/tools/check_release.py @@ -0,0 +1,229 @@ +"""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 +import tempfile +import zipfile +from pathlib import Path, PurePosixPath + + +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 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, + 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 = {} + 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: + 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)), + "version": ".".join(map(str, version)), + "prerelease": str(prerelease).lower(), + "commit": source, "previous_tag": previous_tag} + + +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 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 check_plugin(path: Path, tag: str) -> None: + version = release_version(tag) + with zipfile.ZipFile(path) as archive: + names = archive.namelist() + if len(names) != len(set(names)): + raise ValueError("Plugin ZIP must contain one standalone inferrsi plugin") + roots = set() + destinations = set() + spellings = {} + 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()) + 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 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")) + 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") + prefix = f"{plugin}/runtime/" + actual = {name[len(prefix):] for name in names + 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") + 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": + 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)] + + 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) + 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) + 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": + # 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] + settings = config["tool"]["inferrsi"]["release"] + result = plan_release(args.default_branch, releases, + settings["initial_version"], settings["prerelease"]) + for key, value in result.items(): + 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 + + from inferrsi import __version__ + + check_versions(args.tag, version("inferrsi"), __version__) + print("Installed release versions agree") + except (ValueError, KeyError, OSError, zipfile.BadZipFile, + subprocess.CalledProcessError) as error: + parser.exit(1, f"Release validation failed: {error}\n") + + +if __name__ == "__main__": + main()