From c1447c484afdfada6e5cbed04c18e7ddfd2aa88a Mon Sep 17 00:00:00 2001 From: fettpl <38704082+fettpl@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:05:36 +0200 Subject: [PATCH 01/13] docs: design integrated release workflow fixes #926 fixes #927 fixes #934 fixes #949 --- .../2026-08-08-release-integration-design.md | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-08-release-integration-design.md diff --git a/docs/superpowers/specs/2026-08-08-release-integration-design.md b/docs/superpowers/specs/2026-08-08-release-integration-design.md new file mode 100644 index 0000000000..d06b1f89be --- /dev/null +++ b/docs/superpowers/specs/2026-08-08-release-integration-design.md @@ -0,0 +1,199 @@ +# Integrated Release and Package Distribution + +## Context + +Four independently reviewed pull requests modify overlapping release surfaces: + +- #1019 defines branded R2 SDK and library artifacts and the `prime-agent-ai` command identity. +- #1020 pins third-party workflow actions, applies exact job-permission allowlists, narrows release credentials, and protects beta promotion from stale runs. +- #1022 makes CI the release authority, retires local npm publication, defines immutable publication and retry behavior, and adds a protected pointer-only rollback. +- #1024 binds release verification, artifacts, manifests, and publication to one exact source SHA and gates publication on the full Node and Python suites. + +Each pull request is green in isolation, but they cannot be merged independently without losing reviewed behavior. In particular, #1022 removes branch-selectable manual dispatch in favor of default-branch issue-comment authority, while #1024 reintroduces `workflow_dispatch` and tag-triggered publication. The release packer, release-context resolver, CI workflow, and package manifests also overlap. + +Development and verification of this integration must not dispatch a release workflow or mutate npm, Git tags, R2, GitHub Releases, or channel pointers. + +## Considered Approaches + +### Separate integration pull request — selected + +Create a new branch from current `main` and integrate the four reviewed contracts deliberately. Keep the original pull requests unchanged as review history until maintainers decide how to close them. This provides one merge target and one combined CI result without rewriting reviewed branches. + +### Stack and rebase the original pull requests + +Merge one pull request, rebase the next, and repeat. This preserves individual issue commits but makes correctness depend on merge order and requires repeated conflict resolution in security-critical workflow files. It also makes it easy to restore `workflow_dispatch` or drop an action pin accidentally. + +### Rewrite #1022 as the umbrella pull request + +Force all changes into the release-lifecycle pull request and supersede the other branches. This reduces pull-request count but obscures the reviewed issue boundaries and invalidates existing exact-head reviews. It is rejected. + +## Scope + +The integration owns: + +- the combined behavior of issues #926, #927, #934, and #949; +- one automatic release trigger and one protected manual retry mechanism; +- exact-SHA full-suite verification; +- workflow action and permission security; +- branded R2 package artifacts and their clean-install contract; +- immutable release publication, beta freshness, idempotent retry, and pointer-only rollback; +- retirement of legacy local npm publishing; +- combined workflow, release, packaging, Python, and documentation verification. + +It does not merge, close, or rewrite the four original pull requests. It does not change daemon protocol behavior. It does not publish branded packages to npm. + +## Authority and Trigger Model + +A successful `CI` workflow run from a same-repository push to the protected default branch is the only automatic publication trigger. The release workflow must verify all of the following before planning a release: + +- the upstream workflow is the repository's canonical `CI` workflow; +- its conclusion is `success`; +- its event is `push`; +- its head repository is the current repository; +- its head branch is the current default branch; +- its head SHA is a complete 40-character commit SHA. + +The release workflow does not expose `workflow_dispatch`. A tag push does not independently publish. + +Every default-branch commit may produce a beta after exact-SHA verification. A new production release is planned only when the release-control version changes according to the release lifecycle policy. + +Manual production retry uses the exact issue-comment command `/prime-agent release retry v0.X.Y`, loaded from protected default-branch workflow code. An ungated preflight job: + +1. checks the actor's live repository permission through the GitHub API; +2. accepts only `admin` or `maintain`; +3. parses the command exactly; +4. resolves an existing immutable `vX.Y.Z` tag; +5. verifies that tag, package metadata, and default-branch ancestry agree; +6. emits the version and 40-character tag SHA as controlled outputs. + +Only a successful preflight can schedule the downstream verification and mutation jobs. Unauthorized or malformed comments cannot acquire release concurrency, enter a protected environment, obtain write permissions, or receive credentials. + +Manual rollback remains a separate exact issue-comment workflow. It requires two lines whose versions match: `/prime-agent release rollback v0.X.Y` followed by `ROLLBACK v0.X.Y`. Its authorization preflight follows the same isolation rules. Rollback may update only the stable channel pointers after verifying the existing immutable release. + +## Exact-SHA Verification + +All source checkouts, builds, package artifacts, workflow artifact names, manifests, checksums, GitHub Release targets, and R2 versioned paths bind to one resolved source SHA. + +Default-branch publication consumes the successful upstream CI result only after verifying that result belongs to the resolved SHA. Retry invokes the reusable full CI workflow against the existing tag's SHA. Failed, cancelled, skipped, fork-originated, wrong-branch, wrong-workflow, or wrong-SHA verification prevents every publication job. + +The reusable CI workflow includes: + +- build and non-mutating repository checks; +- all Node workspace and coding-agent shards; +- kernel and process smoke coverage; +- the complete locked Python runtime suite; +- one aggregate success job on which publication depends. + +Python dependencies use a committed uv lock generated by pinned uv 0.11.33 and the repository's seven-day cutoff. Tests are not skipped, stubbed, or reduced to avoid dependency failures. + +## Protected Tooling and Tagged Source + +Release tooling is checked out from the protected default-branch workflow commit. Release source is checked out separately at the resolved source SHA. This allows an old immutable tag to be retried even when that tag predates the current release scripts. + +The current protected tooling validates and packages the exact source checkout. Source files and lockfiles come from the source checkout; release policy and publication implementation come from the protected tooling checkout. Every command receives the source root explicitly and may not fall back to the tooling checkout. + +## Package Artifact Contract + +Every stable and beta release contains four branded npm-format tarballs: + +| Artifact | Public package/import | +| --- | --- | +| `prime-agent-.tgz` | `prime-agent` | +| `prime-agent-ai-.tgz` | `prime-agent-ai` | +| `prime-agent-core-.tgz` | `prime-agent-core` | +| `prime-agent-tui-.tgz` | `prime-agent-tui` | + +The AI package exposes `prime-agent-ai` as its supported command and retains `pi-ai` as a compatibility alias. Documentation uses branded public imports and immutable R2 URLs. Inherited `@earendil-works/pi-*` names remain internal compatibility specifiers and are not advertised as supported registry installs. + +The release manifest records the release version, channel, source SHA, artifact filenames, and SHA-256 values. `SHA256SUMS` and the manifest must describe the same complete set before publication starts. + +## Credential and Permission Boundaries + +Workflow permissions default to none or `contents: read`. Every workflow and job has an exact allowlisted permission map. Third-party actions use immutable full commit SHAs with version comments; both `.yml` and `.yaml` workflow files are covered by the security contract. + +Credential boundaries are phase-specific: + +- verification, build, pack, and artifact validation receive no publication credentials; +- GitHub tag, Release, and asset operations receive only `contents: write` through `GITHUB_TOKEN`; +- R2 immutable upload and verification receive only R2 credentials; +- R2 installer and pointer promotion receive only R2 credentials; +- rollback receives R2 credentials only after authorization and release verification. + +No step receives both R2 credentials and GitHub write credentials. Secrets do not exist at workflow or broad job scope when a narrower mutation-step scope is possible. + +## Production Transaction + +The production transaction is: + +1. Validate repository metadata, source SHA, version, tag, package lockstep, changelogs, internal dependency URLs, manifest, and checksums without publication credentials. +2. Verify or create the immutable Git tag and GitHub Release target using GitHub-only credentials. +3. Create each versioned R2 object if absent; if present, compare bytes/hashes and fail on mismatch. Never overwrite a different immutable object. +4. Create or verify every GitHub Release asset with the same compare-before-write rule. +5. Reverify the complete immutable R2 and GitHub asset sets. +6. Upload and verify the stable and beta installer scripts. +7. Read both current stable surfaces and refuse a version regression. +8. Write and verify `/stable`. +9. Write and verify `/latest.json` last as the stable commit marker. + +A retry with identical inputs is a no-op or converges a partial transaction. A mismatch fails before mutable channel promotion. Only the protected rollback path may lower stable pointers. + +## Beta Transaction + +Beta publication uses unique immutable versioned paths and the same compare-before-write behavior. After immutable uploads, it rechecks that the source SHA is still the default-branch head immediately before each mutable phase: + +1. GitHub beta tag/prerelease mirroring; +2. installer updates; +3. channel promotion. + +A stale run may leave unique immutable artifacts but cannot update mutable beta state. Promotion writes and verifies `/beta`, then writes `/beta.json` last as the beta commit marker. + +## Failure Handling + +- A missing or malformed trigger, unauthorized actor, non-successful CI result, or SHA mismatch fails closed before credentials. +- Missing artifacts, unexpected artifact names, checksum drift, source-SHA drift, or remote immutable drift fail without channel mutation. +- Remote authorization, availability, or metadata errors are not interpreted as object absence. +- Existing incomplete immutable prefixes cause refusal unless every existing object matches the local release contract. +- Temporary files, local package projects, servers, and subprocesses are cleaned on success and failure. +- No retry moves an immutable tag, overwrites a different artifact, or regresses a channel pointer. + +## Local Release Commands + +`release:prepare` is the only supported local version mutation. It updates only release-controlled manifests, internal dependency ranges, lockfile entries, and changelog headings. It never commits, tags, pushes, publishes, or dispatches a workflow, and it restores the original tree after a mid-operation failure. + +`release:dry-run` performs the same policy, packing, manifest, checksum, and clean-install validations without credentials or remote mutation. Legacy local `release:*`, `version:*`, `publish`, and `publish:dry` entry points hard-fail with migration guidance. The internal non-publishing packer remains available to CI. + +## Verification + +Combined deterministic tests cover: + +- exact permission allowlists and full-SHA action pins for `.yml` and `.yaml`; +- successful same-repository/default-branch `workflow_run` handling; +- rejection of fork, wrong-workflow, wrong-branch, failed, cancelled, skipped, and wrong-SHA upstream runs; +- absence of `workflow_dispatch` and independent tag publication; +- authorized and unauthorized retry and rollback comments; +- retry invocation of reusable full CI at the tag SHA; +- aggregate Node and Python gate results; +- source-SHA-bound workflow artifact names, manifests, checksums, and publication inputs; +- old-tag source with current protected tooling; +- immutable object create, identical retry, partial-prefix refusal, and mismatch failure; +- stable and beta ordering, stale-beta rejection, retry convergence, and pointer-only rollback; +- clean-project installation, compilation, and runtime loading of all four branded artifacts; +- `prime-agent-ai` and `pi-ai` command behavior; +- no publication, tag, release, pointer, or workflow-dispatch side effects during tests. + +Required validation before publication of the integration pull request: + +- focused workflow-security tests; +- focused release lifecycle/publication/workflow tests; +- focused package-artifact contract tests; +- the complete locked Python runtime suite; +- full `npm run check`; +- YAML parsing and `git diff --check`; +- independent exact-head code and security review; +- fully green exact-head GitHub CI. + +## Pull Request Handoff + +The integration pull request references #1019, #1020, #1022, and #1024 and explains that it is the intended merge target. Its body uses closing keywords for issues #926, #927, #934, and #949 so GitHub closes them only if the integration pull request merges. The four original pull requests remain unchanged while the integration pull request is under review. + +The integration pull request remains draft until independent review and exact-head CI are fully green. No maintainer is tagged before that point. It is never merged by the implementation agent. From 9ebf5693cbb739a95fcfb5e46806697bdda355ea Mon Sep 17 00:00:00 2001 From: fettpl <38704082+fettpl@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:16:44 +0200 Subject: [PATCH 02/13] docs: plan integrated release workflow fixes #926 fixes #927 fixes #934 fixes #949 --- .../plans/2026-08-08-release-integration.md | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-08-release-integration.md diff --git a/docs/superpowers/plans/2026-08-08-release-integration.md b/docs/superpowers/plans/2026-08-08-release-integration.md new file mode 100644 index 0000000000..80b32750e5 --- /dev/null +++ b/docs/superpowers/plans/2026-08-08-release-integration.md @@ -0,0 +1,194 @@ +# Integrated Release and Package Distribution — Implementation Plan + +## Objective + +Implement the approved design in `docs/superpowers/specs/2026-08-08-release-integration-design.md` on branch `agent/release-integration`. The result must combine the reviewed behavior from PRs #1019, #1020, #1022, and #1024 into one merge target without dispatching or mutating any release system. + +## Source revisions + +- Base: `a18809e00ea30638584d87b3afea7285a9d7296c` +- #1019: `85c63cc4da77cb1410aeaae32f71d853d74da784` +- #1020 implementation: `1133b855bb377203af0752ac77edcac53cbef932` +- #1022: `07e51ea852109274e8b69d04dfdcf214e09ee345` +- #1024: `259e267b703f35589d43cb261dd56cf2432b956d` +- Integration design: `c1447c48` + +Stop and report if any source head changes before publication. Do not silently substitute a newer revision. + +## Hard boundaries + +- Never dispatch `.github/workflows/build-binaries.yml` or `.github/workflows/rollback-release.yml`. +- Never create, move, or delete Git tags. +- Never upload to R2, create/edit a GitHub Release, publish to npm, mark another PR ready, merge, or push `main`. +- Preserve the four original PRs unchanged. +- Do not add `workflow_dispatch` or tag-push publication. +- Do not weaken full-SHA action pins, exact permission allowlists, seven-day dependency policy, exact-SHA verification, or the complete Python suite. +- Do not modify `packages/ai/src/models.generated.ts`. +- Use exact-file staging only. + +## Step 1 — Import non-conflicting package and documentation behavior + +Bring the logical changes from #1019 into the integration branch: + +- branded artifact package names and internal R2 dependency URLs; +- canonical artifact installation documentation; +- SDK/library documentation corrections; +- `prime-agent-ai` command alias and branded CLI text; +- package-documentation and CLI-branding tests; +- clean-project release-package contract script and CI hook; +- package and changelog updates. + +Files are the #1019 file set reported by GitHub. Preserve the integration design document when applying its design commit. + +Verification: + +- Run `cd packages/ai && npx tsx ../../node_modules/vitest/dist/cli.js --run test/cli-branding.test.ts`. +- Run the exact coding-agent documentation test from package root. +- Run the clean-project release-package contract after the required build artifact exists; do not run `npm run build` directly because repository instructions forbid it. Use the already-reviewed script only during the final CI-equivalent validation path provided by the integrated workflow tests. + +## Step 2 — Import the CI-authoritative release lifecycle + +Bring the release policy, scripts, tests, documentation, and local command retirement from #1022: + +- `release:prepare`, `release:dry-run`, and legacy-command tombstones; +- release lifecycle and publication libraries; +- protected-tooling/tagged-source separation; +- issue-comment retry and rollback parsers; +- immutable R2/GitHub decisions; +- stable/beta ordering and freshness checks; +- release documentation and focused tests. + +Resolve package/changelog conflicts additively. Preserve all #1019 artifact names and documentation. + +The release workflow authority after this step must be: + +- automatic default-branch behavior only through a successful canonical `CI` `workflow_run`; +- production retry only through `/prime-agent release retry v0.X.Y`; +- rollback only through the exact two-line rollback command; +- no `workflow_dispatch`; +- no tag-push publication. + +## Step 3 — Integrate exact-SHA verification + +Bring the reusable CI, runtime lock, source-SHA manifest, artifact verification, gate helper, and regression tests from #1024. + +Reconcile rather than copy its trigger model: + +- retain #1022 issue-comment retry and rollback authority; +- replace #1024's manual/tag triggers with the protected issue-comment retry; +- for retry, invoke reusable CI at the preflight-resolved tag SHA; +- for automatic default-branch runs, bind to and verify the successful upstream CI head SHA; +- give every workflow artifact a source-SHA-qualified name; +- validate local and remote manifest/checksum/source-SHA consistency before mutation. + +The complete Python runtime suite must use the committed uv lock, pinned uv 0.11.33, CPython 3.11.15, and the repository cutoff. Do not omit test groups or imports. + +## Step 4 — Apply workflow security as the final invariant + +Integrate #1020's workflow-security contract after the final workflow shapes exist: + +- full immutable action SHAs plus version comments; +- discovery of both `.yml` and `.yaml`; +- exact workflow/job permission allowlists; +- default-deny permissions; +- R2 credentials only on R2 mutation steps; +- GitHub write token only on GitHub tag/Release/asset steps; +- no step with both credential classes; +- beta mutable phases guarded by a fresh default-branch-head check; +- nightly and ordinary CI remain read-only. + +If a monolithic release helper currently requires both GitHub and R2 credentials, split its phase interface. Do not broaden the job environment to avoid the split. + +## Step 5 — Consolidate publication phases + +Refactor the release tooling only as needed to support phase-specific credentials while preserving #1022's transaction: + +1. credential-free policy and artifact validation; +2. GitHub-only tag/Release target verification or creation; +3. R2-only immutable upload/verification; +4. GitHub-only immutable asset mirror/verification; +5. R2-only installers and pointer promotion; +6. rollback as R2-only verified pointer mutation. + +Each phase must be independently idempotent and must revalidate its inputs. Existing mismatched immutable state fails closed. Remote errors other than confirmed absence must not enter create paths. + +For beta, recheck default-branch freshness immediately before the GitHub mirror, installers, and pointer promotion. + +## Step 6 — Consolidate packer and manifest behavior + +The final packer must produce exactly: + +- `prime-agent-.tgz`; +- `prime-agent-ai-.tgz`; +- `prime-agent-core-.tgz`; +- `prime-agent-tui-.tgz`; +- `SHA256SUMS`; +- the channel manifest with version, channel, complete source SHA, artifact names, and hashes; +- existing stable/beta compatibility pointer payloads. + +The package contract, source-SHA verifier, publication logic, and documentation must share the same artifact set. Add a regression that fails when any one consumer drifts. + +## Step 7 — Focused verification + +Run from the appropriate package or repository root: + +1. Workflow security contract: + - `node --test packages/coding-agent/scripts/check-workflow-security.mjs` +2. Release lifecycle/publication/workflow regressions: + - use the integrated `npm run release:test` command or the exact `node --test` file list it defines; +3. Exact-SHA workflow regressions: + - `node --test scripts/release-workflow.test.mjs` when not already included by `release:test`; +4. AI branding: + - `cd packages/ai && npx tsx ../../node_modules/vitest/dist/cli.js --run test/cli-branding.test.ts`; +5. Coding-agent package documentation: + - run the exact changed test from `packages/coding-agent` using the repository Vitest entrypoint; +6. Complete Python runtime: + - sync the explicit test group from `prime-agent-runtime/uv.lock` using uv 0.11.33 and CPython 3.11.15; + - run all `prime-agent-runtime/test/test_*.py` tests and require 64/64 or the current complete discovered count with zero collection errors/skips; +7. YAML parse and `git diff --check`. + +Every changed or new test file must be run directly and pass before proceeding. + +## Step 8 — Full repository validation + +Run `npm run check` and capture the full output. Fix every error, warning, and informational diagnostic. Confirm the command does not change tracked or staged files by comparing full binary worktree/index snapshots before and after, following #968's established contract. + +Do not run `npm test`, `npm run build`, or `npm run dev` locally. + +## Step 9 — Security and integration review + +Review the complete diff against the approved spec, not merely the four source PRs. Required review questions: + +- Can an untrusted issue comment or branch ref acquire release concurrency, an environment, write permission, or a secret? +- Can any failed/cancelled/skipped/wrong-SHA CI state reach publication? +- Can a retry overwrite an immutable object, move a tag, or regress a pointer? +- Can stale beta work update any mutable surface? +- Can one step access both R2 and GitHub write credentials? +- Do packer, manifest, checksum, clean-install tests, and publication enumerate the same four artifacts? +- Can an old tag be retried with current protected tooling and exact tagged source? +- Are the original public stable/beta paths and manifest shapes preserved? + +Address every Critical, Important, and Minor finding, then rerun affected focused tests and full `npm run check`. + +## Step 10 — Commit and draft PR + +Stage only explicitly changed integration files. Commit with closing references for #926, #927, #934, and #949. Push normally to `fettpl/prime-agent`; never force-push. + +Open a draft PR to `PrimeIntellect-ai/prime-agent:main` that: + +- references PRs #1019, #1020, #1022, and #1024 as reviewed source work; +- states it is the intended merge target; +- lists preserved security, release, exact-SHA, and package contracts; +- includes exact local verification results; +- states that no release workflow, tag, upload, publication, pointer update, readiness transition, or merge occurred. + +Keep the original PRs unchanged. Do not mark the integration PR ready or notify maintainers until exact-head GitHub CI is fully green and final review is clean. + +## Done criteria + +- The approved integration design is implemented without `workflow_dispatch` or tag-push publication. +- The four original issue contracts are simultaneously enforced by tests. +- All focused tests, the complete Python suite, YAML/diff checks, and `npm run check` pass. +- Independent exact-head review is clean. +- A draft integration PR exists at the reviewed SHA with fully green CI. +- No release-side mutation or merge occurred. From d30479954ab29c816cb686ee57c2c69dbc02054b Mon Sep 17 00:00:00 2001 From: fettpl <38704082+fettpl@users.noreply.github.com> Date: Sat, 8 Aug 2026 20:15:14 +0200 Subject: [PATCH 03/13] docs: define supported package artifacts (fixes #949) --- ...8-08-supported-package-artifacts-design.md | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-08-supported-package-artifacts-design.md diff --git a/docs/superpowers/specs/2026-08-08-supported-package-artifacts-design.md b/docs/superpowers/specs/2026-08-08-supported-package-artifacts-design.md new file mode 100644 index 0000000000..62b036e643 --- /dev/null +++ b/docs/superpowers/specs/2026-08-08-supported-package-artifacts-design.md @@ -0,0 +1,76 @@ +# Supported SDK and Library Artifacts + +## Context + +Prime Agent publishes four branded npm-format tarballs with every stable and beta R2 release, but its library documentation currently points at package names that do not resolve from npm. The branded registry names return 404, while the inherited `@earendil-works/pi-*` names resolve releases from another project. The current stable R2 artifacts install successfully and expose the documented branded imports. + +## Decision + +The R2 release channel is the only supported external distribution path for the SDK and libraries in this change. The supported public package identities are: + +| Artifact | Public package/import | Purpose | +| --- | --- | --- | +| `prime-agent-.tgz` | `prime-agent` | CLI and Node.js SDK | +| `prime-agent-ai-.tgz` | `prime-agent-ai` | Provider and model toolkit | +| `prime-agent-core-.tgz` | `prime-agent-core` | Stateful agent runtime | +| `prime-agent-tui-.tgz` | `prime-agent-tui` | Terminal UI primitives | + +Stable consumers resolve the version from `stable` or `latest.json`; beta consumers resolve it from `beta` or `beta.json`. Installation uses the resulting immutable `/releases/v/` URL. The JSON manifests and per-release `SHA256SUMS` remain the integrity metadata. + +The inherited `@earendil-works/pi-*` names remain internal workspace and extension-runtime compatibility specifiers. They are not supported registry install targets. Retiring the legacy local npm publishing commands is explicitly deferred to issue #934. + +## Documentation Contract + +A canonical SDK/library installation guide will: + +- list every supported public artifact, import name, and Node.js requirement; +- provide stable and beta version resolution for POSIX shells and PowerShell; +- install immutable tarball URLs rather than nonexistent registry packages; +- explain SHA-256 verification, lockfile integrity, and reproducible pinning; +- explain that updates require resolving the channel again and installing the new immutable URL; +- distinguish public branded imports from internal inherited compatibility specifiers. + +The AI, core, TUI, and SDK entry documentation will link to that guide and use branded imports. Programmatic SDK documentation will import from `prime-agent`; source-only examples may retain inherited workspace specifiers when clearly identified as repository development examples. Extension documentation may retain inherited runtime specifiers, but must not claim they are supported registry packages. + +The TUI quick start will be self-contained instead of importing an unpublished test theme. The SDK, AI, core, and TUI quick starts must compile against their packed artifacts. + +## AI Command Identity + +The AI package will expose `prime-agent-ai` as the supported command while retaining `pi-ai` as a compatibility alias. Help and error output will show `npx prime-agent-ai`; it will not direct users to the unrelated inherited npm package. + +## Automated Contract Verification + +A repository check dedicated to release packages will run after the existing build in pull-request CI. It will: + +1. Pack the four artifacts with the existing release packer and a loopback artifact base URL. +2. Serve the generated immutable artifacts from the same URL shape used by R2. +3. Install each advertised package into a separate clean temporary project. +4. Extract and compile the corresponding documentation quick start against the installed package. +5. Load representative public exports at runtime. +6. Reject reintroduced bare npm install instructions for the nonexistent branded registry packages or the inherited SDK package. + +The check will use the repository's existing compiler, a temporary npm cache, and no provider credentials. It will clean all temporary files and terminate the loopback server on success or failure. Ordinary `npm run check` will remain usable without prebuilt `dist` directories; CI will invoke the artifact contract check separately after `npm run build`. + +## Failure Handling + +The contract check will fail with the affected package and subprocess output when packing, installation, type checking, or runtime loading fails. The loopback server will only serve an allowlist of generated artifact filenames, so unexpected dependency requests fail instead of escaping the temporary artifact root. + +Documentation will state that channel pointers are mutable discovery metadata and immutable release URLs are the dependency identity. Consumers should commit the resulting lockfile and should not expect npm semver ranges, dist-tags, or `npm outdated` to update R2 artifacts. + +## Non-goals + +- Publishing branded packages to npm. +- Renaming source workspace manifests or internal imports. +- Retiring or redesigning legacy local npm publishing commands (#934). +- Changing R2 credentials, upload permissions, release tags, or production release sequencing. +- Providing backward compatibility for unsupported registry installation commands. + +## Acceptance Criteria + +- All documented external SDK/library installs resolve an artifact from the current stable or beta release. +- Public examples import `prime-agent`, `prime-agent-ai`, `prime-agent-core`, or `prime-agent-tui` as appropriate. +- Inherited names are described only as internal/runtime compatibility specifiers, not install targets. +- Stable and beta discovery, immutable pinning, SHA-256 verification, and update behavior are documented. +- The AI artifact exposes `prime-agent-ai`, retains `pi-ai`, and emits branded help text. +- Clean-project CI installs and validates every packed public artifact and compiles each package quick start. +- Legacy local npm publishing behavior and the production release workflow are unchanged. From 2f77516689409cd8ed245249eae4074cec7cac0f Mon Sep 17 00:00:00 2001 From: fettpl <38704082+fettpl@users.noreply.github.com> Date: Sat, 8 Aug 2026 20:34:12 +0200 Subject: [PATCH 04/13] fix(packaging): support branded release artifacts (fixes #949) --- .github/workflows/ci.yml | 3 + package-lock.json | 3 +- package.json | 1 + packages/agent/CHANGELOG.md | 2 + packages/agent/README.md | 14 +- packages/ai/CHANGELOG.md | 2 + packages/ai/README.md | 10 +- packages/ai/package.json | 3 +- packages/ai/src/cli.ts | 13 +- packages/ai/test/cli-branding.test.ts | 37 +++ packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/compaction.md | 4 +- packages/coding-agent/docs/index.md | 1 + .../coding-agent/docs/package-artifacts.md | 85 +++++ packages/coding-agent/docs/packages.md | 2 +- packages/coding-agent/docs/rpc.md | 2 +- packages/coding-agent/docs/sdk.md | 54 ++-- packages/coding-agent/docs/session-format.md | 2 +- packages/coding-agent/examples/sdk/README.md | 7 +- packages/coding-agent/src/core/sdk.ts | 2 +- .../release-package-documentation.test.ts | 78 +++++ packages/tui/CHANGELOG.md | 2 + packages/tui/README.md | 30 +- scripts/check-release-package-contract.mjs | 294 ++++++++++++++++++ 24 files changed, 589 insertions(+), 63 deletions(-) create mode 100644 packages/ai/test/cli-branding.test.ts create mode 100644 packages/coding-agent/docs/package-artifacts.md create mode 100644 packages/coding-agent/test/release-package-documentation.test.ts create mode 100644 scripts/check-release-package-contract.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 219dbfb593..5debd95003 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,9 @@ jobs: - name: Check run: npm run check + - name: Check release packages + run: npm run check:release-packages + test: name: Test (${{ matrix.name }}) runs-on: ubuntu-latest diff --git a/package-lock.json b/package-lock.json index f782584da0..f0be3720f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5323,7 +5323,8 @@ "zod-to-json-schema": "^3.24.6" }, "bin": { - "pi-ai": "dist/cli.js" + "pi-ai": "dist/cli.js", + "prime-agent-ai": "dist/cli.js" }, "devDependencies": { "@types/node": "^24.3.0", diff --git a/package.json b/package.json index b30b1d869e..5eba86973a 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "check": "biome check --write --error-on-warnings . && tsgo --noEmit && npm run check:installer && npm run check:browser-smoke", "check:installer": "node scripts/check-installer-render.mjs", "check:browser-smoke": "node scripts/check-browser-smoke.mjs", + "check:release-packages": "node scripts/check-release-package-contract.mjs", "profile:tui": "node scripts/profile-coding-agent-node.mjs --mode tui", "profile:rpc": "node scripts/profile-coding-agent-node.mjs --mode rpc", "test": "npm run test --workspaces --if-present", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 7edd20a53e..a60240cb0e 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +- Fixed the documented core installation path to use branded, immutable Prime Agent release artifacts ([#949](https://github.com/PrimeIntellect-ai/prime-agent/issues/949)). + ## [0.7.1] - 2026-08-07 ## [0.7.0] - 2026-08-05 diff --git a/packages/agent/README.md b/packages/agent/README.md index e28f449c89..9226701ffc 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -13,14 +13,20 @@ Stateful agent runtime.

-Release docs use the Prime Agent package names. The source workspace manifests still keep inherited package names until the namespace migration is complete. +External releases use the branded `prime-agent-core` and `prime-agent-ai` package names. The inherited source workspace names are not supported npm registry install targets. -## Workspace Package +## Installation ```bash -npm install prime-agent-core +release_base=https://pub-728493de92a943e2a9b2d17b4719f318.r2.dev +release_version="$(curl -fsSL "$release_base/stable")" +npm install \ + "$release_base/releases/$release_version/prime-agent-ai-${release_version#v}.tgz" \ + "$release_base/releases/$release_version/prime-agent-core-${release_version#v}.tgz" ``` +This installs the immutable artifacts for the current stable release. See [SDK and library artifacts](https://github.com/PrimeIntellect-ai/prime-agent/blob/main/packages/coding-agent/docs/package-artifacts.md) for beta releases, PowerShell, integrity verification, pinning, and updates. + ## Quick Start ```typescript @@ -30,7 +36,7 @@ import { getModel } from "prime-agent-ai"; const agent = new Agent({ initialState: { systemPrompt: "You are a helpful assistant.", - model: getModel("anthropic", "claude-sonnet-4-20250514"), + model: getModel("anthropic", "claude-sonnet-4-6"), }, }); diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index db26f73558..9f20db94a9 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +- Fixed the documented AI installation path and added the branded `prime-agent-ai` command alias ([#949](https://github.com/PrimeIntellect-ai/prime-agent/issues/949)). + ## [0.7.1] - 2026-08-07 ## [0.7.0] - 2026-08-05 diff --git a/packages/ai/README.md b/packages/ai/README.md index 9e45724243..552b07b9cd 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -13,7 +13,7 @@ LLM provider toolkit.

-Release docs use the Prime Agent package name. The source workspace manifest still keeps an inherited package name until the namespace migration is complete. +External releases use the branded `prime-agent-ai` package name. The inherited source workspace name is not a supported npm registry install target. Unified LLM API with automatic model discovery, provider configuration, token and cost tracking, and simple context persistence and hand-off to other models mid-session. @@ -91,9 +91,13 @@ Unified LLM API with automatic model discovery, provider configuration, token an ## Installation ```bash -npm install prime-agent-ai +release_base=https://pub-728493de92a943e2a9b2d17b4719f318.r2.dev +release_version="$(curl -fsSL "$release_base/stable")" +npm install "$release_base/releases/$release_version/prime-agent-ai-${release_version#v}.tgz" ``` +This installs the immutable artifact for the current stable release. See [SDK and library artifacts](https://github.com/PrimeIntellect-ai/prime-agent/blob/main/packages/coding-agent/docs/package-artifacts.md) for beta releases, PowerShell, integrity verification, pinning, and updates. + TypeBox exports are re-exported from `prime-agent-ai`: `Type`, `Static`, and `TSchema`. ## Quick Start @@ -116,7 +120,7 @@ const tools: Tool[] = [{ // Build a conversation context (easily serializable and transferable between models) const context: Context = { systemPrompt: 'You are a helpful assistant.', - messages: [{ role: 'user', content: 'What time is it?' }], + messages: [{ role: 'user', content: 'What time is it?', timestamp: Date.now() }], tools }; diff --git a/packages/ai/package.json b/packages/ai/package.json index b5ff3eecd9..8d93015a68 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -56,7 +56,8 @@ } }, "bin": { - "pi-ai": "./dist/cli.js" + "pi-ai": "./dist/cli.js", + "prime-agent-ai": "./dist/cli.js" }, "files": [ "dist", diff --git a/packages/ai/src/cli.ts b/packages/ai/src/cli.ts index 7a4ff1fd33..ea06a1c616 100644 --- a/packages/ai/src/cli.ts +++ b/packages/ai/src/cli.ts @@ -6,6 +6,7 @@ import { getOAuthProvider, getOAuthProviders } from "./utils/oauth/index.js"; import type { OAuthCredentials, OAuthProviderId } from "./utils/oauth/types.js"; const AUTH_FILE = "auth.json"; +const COMMAND_NAME = "prime-agent-ai"; const PROVIDERS = getOAuthProviders(); function prompt(rl: ReturnType, question: string): Promise { @@ -64,7 +65,7 @@ async function main(): Promise { if (!command || command === "help" || command === "--help" || command === "-h") { const providerList = PROVIDERS.map((p) => ` ${p.id.padEnd(20)} ${p.name}`).join("\n"); - console.log(`Usage: npx @earendil-works/pi-ai [provider] + console.log(`Usage: npx ${COMMAND_NAME} [provider] Commands: login [provider] Login to an OAuth provider @@ -74,9 +75,9 @@ Providers: ${providerList} Examples: - npx @earendil-works/pi-ai login # interactive provider selection - npx @earendil-works/pi-ai login anthropic # login to specific provider - npx @earendil-works/pi-ai list # list providers + npx ${COMMAND_NAME} login # interactive provider selection + npx ${COMMAND_NAME} login anthropic # login to specific provider + npx ${COMMAND_NAME} list # list providers `); return; } @@ -113,7 +114,7 @@ Examples: if (!PROVIDERS.some((p) => p.id === provider)) { console.error(`Unknown provider: ${provider}`); - console.error(`Use 'npx @earendil-works/pi-ai list' to see available providers`); + console.error(`Use 'npx ${COMMAND_NAME} list' to see available providers`); process.exit(1); } @@ -123,7 +124,7 @@ Examples: } console.error(`Unknown command: ${command}`); - console.error(`Use 'npx @earendil-works/pi-ai --help' for usage`); + console.error(`Use 'npx ${COMMAND_NAME} --help' for usage`); process.exit(1); } diff --git a/packages/ai/test/cli-branding.test.ts b/packages/ai/test/cli-branding.test.ts new file mode 100644 index 0000000000..2660da432c --- /dev/null +++ b/packages/ai/test/cli-branding.test.ts @@ -0,0 +1,37 @@ +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const require = createRequire(import.meta.url); +const tsxLoader = require.resolve("tsx/esm"); +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +describe("Prime Agent AI command branding", () => { + it("exposes the branded command and keeps the compatibility alias", () => { + const packageJson = require(join(packageRoot, "package.json")) as { + bin?: Record; + }; + + expect(packageJson.bin).toEqual({ + "pi-ai": "./dist/cli.js", + "prime-agent-ai": "./dist/cli.js", + }); + }); + + it("uses the branded command in help output", () => { + const result = spawnSync( + process.execPath, + ["--import", tsxLoader, join(packageRoot, "src", "cli.ts"), "--help"], + { + cwd: packageRoot, + encoding: "utf8", + }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("Usage: npx prime-agent-ai [provider]"); + expect(result.stdout).not.toContain("@earendil-works/pi-ai"); + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f7100b579a..7fca352034 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] +- Fixed SDK and library installation guidance to use branded, immutable Prime Agent release artifacts ([#949](https://github.com/PrimeIntellect-ai/prime-agent/issues/949)). - Added privacy-safe pseudonymous product analytics for onboarding, command use, execution modes, run outcomes, TTFT, latency, usage, tools, retries, and compactions, with disclosure and opt-out controls ([ENG-4682](https://linear.app/primeintellect/issue/ENG-4682/add-privacy-safe-posthog-analytics-to-prime-agent)). - Changed sent agent messages in the IPython cell UI to show only the message text with a `╰─` gutter when expanded, matching received messages, and hid the raw `agent_message.send` receipt dictionary. - Fixed Homebrew installs attempting to self-update their versioned Cellar keg instead of directing users to `brew upgrade prime-agent` ([#844](https://github.com/PrimeIntellect-ai/prime-agent/issues/844)) diff --git a/packages/coding-agent/docs/compaction.md b/packages/coding-agent/docs/compaction.md index 11f2837cfe..e723df1e5e 100644 --- a/packages/coding-agent/docs/compaction.md +++ b/packages/coding-agent/docs/compaction.md @@ -9,7 +9,7 @@ LLMs have limited context windows. When conversations grow too long, Prime Agent - [`session-manager.ts`](../src/core/session-manager.ts) - Entry types (`CompactionEntry`, `BranchSummaryEntry`) - [`extensions/types.ts`](../src/core/extensions/types.ts) - Extension event types -For TypeScript definitions in your project, inspect `node_modules/@earendil-works/pi-coding-agent/dist/`. +For TypeScript definitions in an external project, inspect `node_modules/prime-agent/dist/` after following the [SDK installation guide](package-artifacts.md). ## Overview @@ -309,6 +309,8 @@ pi.on("session_before_compact", async (event, ctx) => { To generate a summary with your own model, convert messages to text using `serializeConversation`: +The following snippet runs inside the extension host, so it uses the inherited `@earendil-works/pi-coding-agent` runtime compatibility specifier. External applications import the same APIs from `prime-agent`. + ```typescript import { convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent"; diff --git a/packages/coding-agent/docs/index.md b/packages/coding-agent/docs/index.md index 847fd73b63..b8d15d562c 100644 --- a/packages/coding-agent/docs/index.md +++ b/packages/coding-agent/docs/index.md @@ -47,6 +47,7 @@ Public releases are currently installed from versioned release artifacts. The in ## Programmatic Usage +- [SDK and library artifacts](package-artifacts.md) - install branded, immutable SDK and library releases. - [SDK](sdk.md) - embed Prime Agent in Node.js applications. - [ACP mode](acp.md) - drive Prime Agent from any Agent Client Protocol client. - [RPC mode](rpc.md) - integrate over stdin/stdout JSONL. diff --git a/packages/coding-agent/docs/package-artifacts.md b/packages/coding-agent/docs/package-artifacts.md new file mode 100644 index 0000000000..9e543ee451 --- /dev/null +++ b/packages/coding-agent/docs/package-artifacts.md @@ -0,0 +1,85 @@ +# SDK and Library Artifacts + +Prime Agent distributes its Node.js SDK and libraries as branded npm-format tarballs through the same R2 stable and beta channels as the CLI. These artifacts are the supported external install path; they are not published under the branded names in the npm registry. + +## Packages + +| Package and import | Artifact | Node.js | Use | +| --- | --- | --- | --- | +| `prime-agent` | `prime-agent-.tgz` | 22.8 or newer | CLI and Node.js SDK | +| `prime-agent-ai` | `prime-agent-ai-.tgz` | 20 or newer | Provider and model toolkit | +| `prime-agent-core` | `prime-agent-core-.tgz` | 20 or newer | Stateful agent runtime | +| `prime-agent-tui` | `prime-agent-tui-.tgz` | 20 or newer | Terminal UI primitives | + +`prime-agent-core` examples also use `prime-agent-ai`, and the full SDK guide uses both `prime-agent` and `prime-agent-ai`. Install both artifacts for those cases. + +## Install the Current Stable Release + +POSIX shell: + +```bash +release_base=https://pub-728493de92a943e2a9b2d17b4719f318.r2.dev +release_version="$(curl -fsSL "$release_base/stable")" +package=prime-agent-ai +npm install "$release_base/releases/$release_version/$package-${release_version#v}.tgz" +``` + +Set `package` to `prime-agent`, `prime-agent-ai`, `prime-agent-core`, or `prime-agent-tui`. Install multiple URLs in one `npm install` command when an example uses more than one package. + +PowerShell: + +```powershell +$ReleaseBase = "https://pub-728493de92a943e2a9b2d17b4719f318.r2.dev" +$ReleaseVersion = (Invoke-RestMethod "$ReleaseBase/stable").Trim() +$PlainVersion = $ReleaseVersion.TrimStart("v") +$Package = "prime-agent-ai" +npm install "$ReleaseBase/releases/$ReleaseVersion/$Package-$PlainVersion.tgz" +``` + +The `stable` pointer contains the active version with its `v` prefix. `latest.json` exposes the same version plus the CLI tarball path and SHA-256 metadata for every package. + +## Install a Beta Release + +Use the beta pointer instead of the stable pointer: + +```bash +release_base=https://pub-728493de92a943e2a9b2d17b4719f318.r2.dev +release_version="$(curl -fsSL "$release_base/beta")" +package=prime-agent-ai +npm install "$release_base/releases/$release_version/$package-${release_version#v}.tgz" +``` + +In PowerShell, resolve `"$ReleaseBase/beta"` instead. `beta.json` provides the structured beta manifest. Beta versions follow the latest successful `main` build and are not promoted to stable automatically. + +## Integrity and Reproducible Installs + +Channel pointers are mutable discovery metadata. The resolved `/releases/v/...` URL is immutable and is the dependency identity to commit to `package.json` and `package-lock.json`. + +Verify an artifact before installation when you do not want npm to download it directly: + +```bash +release_base=https://pub-728493de92a943e2a9b2d17b4719f318.r2.dev +release_version="$(curl -fsSL "$release_base/stable")" +artifact="prime-agent-ai-${release_version#v}.tgz" +release_url="$release_base/releases/$release_version" + +curl -fsSLO "$release_url/$artifact" +curl -fsSLO "$release_url/SHA256SUMS" +grep -F " $artifact" SHA256SUMS > SHA256SUMS.selected +sha256sum -c SHA256SUMS.selected +npm install "./$artifact" +``` + +On macOS, replace the verification command with `shasum -a 256 -c SHA256SUMS.selected`. On PowerShell, compare `(Get-FileHash -Algorithm SHA256 $Artifact).Hash.ToLower()` with the package's `sha256` value from `latest.json` or `beta.json` before installation. + +npm records the resolved URL and integrity in `package-lock.json`. Commit the lockfile so CI and collaborators use the same release. + +## Updating + +R2 artifacts do not have npm dist-tags or semver range discovery. `npm outdated` does not discover a newer Prime Agent artifact. + +To update, resolve `stable` or `beta` again, review the new version and manifest checksum, then rerun `npm install` with the new immutable URL. Review and commit the resulting `package.json` and `package-lock.json` changes. To stay pinned, keep the existing immutable URL. + +## Internal Compatibility Names + +The source workspace and extension runtime retain `@earendil-works/pi-*` specifiers for compatibility. Those names are internal implementation or extension peer-dependency identifiers, not supported registry install targets for Prime Agent SDK/library consumers. External applications should import only the branded names in the table above. diff --git a/packages/coding-agent/docs/packages.md b/packages/coding-agent/docs/packages.md index e5fc166b21..67ad3d9200 100644 --- a/packages/coding-agent/docs/packages.md +++ b/packages/coding-agent/docs/packages.md @@ -161,7 +161,7 @@ If no `pi` manifest is present, Prime Agent auto-discovers resources from these Third party runtime dependencies belong in `dependencies` in `package.json`. Dependencies that do not register extensions, skills, prompt templates, or themes also belong in `dependencies`. When Prime Agent installs a package from npm or git, it runs `npm install`, so those dependencies are installed automatically. -Prime Agent bundles core packages for extensions and skills. The workspace still publishes these inherited package names; if you import any of them, list them in `peerDependencies` with a `"*"` range and do not bundle them: `@earendil-works/pi-ai`, `@earendil-works/pi-agent-core`, `@earendil-works/pi-coding-agent`, `@earendil-works/pi-tui`, `typebox`. +Prime Agent exposes inherited package names as runtime compatibility specifiers for extensions and skills. They are not supported npm registry install targets. If you import any of them, list them in `peerDependencies` with a `"*"` range and do not bundle them: `@earendil-works/pi-ai`, `@earendil-works/pi-agent-core`, `@earendil-works/pi-coding-agent`, `@earendil-works/pi-tui`, `typebox`. Other resource packages must be bundled in your tarball. Add them to `dependencies` and `bundledDependencies`, then reference their resources through `node_modules/` paths. Prime Agent loads packages with separate module roots, so separate installs do not collide or share modules. diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index aab84704d3..174746a976 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -2,7 +2,7 @@ RPC mode enables headless operation of the coding agent via a JSON protocol over stdin/stdout. This is useful for embedding the agent in other applications, IDEs, or custom UIs. -**Note for Node.js/TypeScript users**: If you're building a Node.js application, consider using `AgentSession` directly from `@earendil-works/pi-coding-agent` instead of spawning a subprocess. See [`src/core/agent-session.ts`](../src/core/agent-session.ts) for the API. For a subprocess-based TypeScript client, see [`src/modes/rpc/rpc-client.ts`](../src/modes/rpc/rpc-client.ts). +**Note for Node.js/TypeScript users**: If you're building a Node.js application, consider using `AgentSession` directly from `prime-agent` instead of spawning a subprocess. Follow the [SDK installation guide](package-artifacts.md), then see [`src/core/agent-session.ts`](../src/core/agent-session.ts) for the API. For a subprocess-based TypeScript client, see [`src/modes/rpc/rpc-client.ts`](../src/modes/rpc/rpc-client.ts). ## Starting RPC Mode diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index b3c3d3dd8e..1bf0faeee2 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -16,7 +16,7 @@ See [examples/sdk/](../examples/sdk/) for working examples from minimal to full ## Quick Start ```typescript -import { AuthStorage, createAgentSession, ModelRegistry, SessionManager } from "@earendil-works/pi-coding-agent"; +import { AuthStorage, createAgentSession, ModelRegistry, SessionManager } from "prime-agent"; // Set up credential storage and model registry const authStorage = AuthStorage.create(); @@ -40,10 +40,14 @@ await session.prompt("What files are in the current directory?"); ## Installation ```bash -npm install @earendil-works/pi-coding-agent +release_base=https://pub-728493de92a943e2a9b2d17b4719f318.r2.dev +release_version="$(curl -fsSL "$release_base/stable")" +npm install \ + "$release_base/releases/$release_version/prime-agent-${release_version#v}.tgz" \ + "$release_base/releases/$release_version/prime-agent-ai-${release_version#v}.tgz" ``` -The SDK is included in the main package. No separate installation needed. +The SDK is included in the `prime-agent` artifact. This guide also imports `getModel` from `prime-agent-ai`, so the command installs both branded artifacts from the same immutable release. See [SDK and library artifacts](package-artifacts.md) for beta releases, PowerShell, integrity verification, pinning, and updates. ## Core Concepts @@ -54,7 +58,7 @@ The main factory function for a single `AgentSession`. `createAgentSession()` uses a `ResourceLoader` to supply extensions, skills, prompt templates, themes, and context files. If you do not provide one, it uses `DefaultResourceLoader` with standard discovery. ```typescript -import { createAgentSession } from "@earendil-works/pi-coding-agent"; +import { createAgentSession } from "prime-agent"; // Minimal: defaults with DefaultResourceLoader const { session } = await createAgentSession(); @@ -132,7 +136,7 @@ import { createAgentSessionServices, getAgentDir, SessionManager, -} from "@earendil-works/pi-coding-agent"; +} from "prime-agent"; const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => { const services = await createAgentSessionServices({ cwd }); @@ -239,7 +243,7 @@ Both `steer()` and `followUp()` expand file-based prompt templates but error on ### Agent and AgentState -The `Agent` class (from `@earendil-works/pi-agent-core`) handles the core LLM interaction. Access it via `session.agent`. +The `Agent` class (from `prime-agent-core`) handles the core LLM interaction. Access it via `session.agent`. ```typescript // Access current state @@ -368,8 +372,8 @@ When you pass a custom `ResourceLoader`, `cwd` and `agentDir` no longer control ### Model ```typescript -import { getModel } from "@earendil-works/pi-ai"; -import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; +import { getModel } from "prime-agent-ai"; +import { AuthStorage, ModelRegistry } from "prime-agent"; const authStorage = AuthStorage.create(); const modelRegistry = ModelRegistry.create(authStorage); @@ -416,7 +420,7 @@ API key resolution priority (handled by AuthStorage): 4. Fallback resolver (for custom provider keys from `models.json`) ```typescript -import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; +import { AuthStorage, ModelRegistry } from "prime-agent"; // Default: uses ~/.prime/agent/auth.json and ~/.prime/agent/models.json const authStorage = AuthStorage.create(); @@ -452,7 +456,7 @@ const simpleRegistry = ModelRegistry.inMemory(authStorage); Use a `ResourceLoader` to override the system prompt: ```typescript -import { createAgentSession, DefaultResourceLoader } from "@earendil-works/pi-coding-agent"; +import { createAgentSession, DefaultResourceLoader } from "prime-agent"; const loader = new DefaultResourceLoader({ systemPromptOverride: () => "You are a helpful assistant.", @@ -487,7 +491,7 @@ import { createIpythonToolDefinition, createBashToolDefinition, createEditToolDefinition, -} from "@earendil-works/pi-coding-agent"; +} from "prime-agent"; const cwd = "/path/to/project"; @@ -514,7 +518,7 @@ const { session } = await createAgentSession({ ```typescript import { Type } from "typebox"; -import { createAgentSession, defineTool } from "@earendil-works/pi-coding-agent"; +import { createAgentSession, defineTool } from "prime-agent"; // Inline custom tool const myTool = defineTool({ @@ -547,7 +551,7 @@ Custom tools passed via `customTools` are combined with extension-registered too Extensions are loaded by the `ResourceLoader`. `DefaultResourceLoader` discovers extensions from `~/.prime/agent/extensions/`, `.prime/agent/extensions/`, and `settings.json` extension sources. ```typescript -import { createAgentSession, DefaultResourceLoader } from "@earendil-works/pi-coding-agent"; +import { createAgentSession, DefaultResourceLoader } from "prime-agent"; const loader = new DefaultResourceLoader({ additionalExtensionPaths: ["/path/to/my-extension.ts"], @@ -569,7 +573,7 @@ Extensions can register tools, subscribe to events, add commands, and more. See **Event Bus:** Extensions can communicate via `pi.events`. Pass a shared `eventBus` to `DefaultResourceLoader` if you need to emit or listen from outside: ```typescript -import { createEventBus, DefaultResourceLoader } from "@earendil-works/pi-coding-agent"; +import { createEventBus, DefaultResourceLoader } from "prime-agent"; const eventBus = createEventBus(); const loader = new DefaultResourceLoader({ @@ -589,7 +593,7 @@ import { createAgentSession, DefaultResourceLoader, type Skill, -} from "@earendil-works/pi-coding-agent"; +} from "prime-agent"; const customSkill: Skill = { name: "my-skill", @@ -615,7 +619,7 @@ const { session } = await createAgentSession({ resourceLoader: loader }); ### Context Files ```typescript -import { createAgentSession, DefaultResourceLoader } from "@earendil-works/pi-coding-agent"; +import { createAgentSession, DefaultResourceLoader } from "prime-agent"; const loader = new DefaultResourceLoader({ agentsFilesOverride: (current) => ({ @@ -639,7 +643,7 @@ import { createAgentSession, DefaultResourceLoader, type PromptTemplate, -} from "@earendil-works/pi-coding-agent"; +} from "prime-agent"; const customCommand: PromptTemplate = { name: "deploy", @@ -674,7 +678,7 @@ import { createAgentSessionServices, getAgentDir, SessionManager, -} from "@earendil-works/pi-coding-agent"; +} from "prime-agent"; // In-memory (no persistence) const { session } = await createAgentSession({ @@ -768,7 +772,7 @@ sm.createBranchedSession(leafId); // Extract path to new file ### Settings Management ```typescript -import { createAgentSession, SettingsManager, SessionManager } from "@earendil-works/pi-coding-agent"; +import { createAgentSession, SettingsManager, SessionManager } from "prime-agent"; // Default: loads from files (global + project merged) const { session } = await createAgentSession({ @@ -824,7 +828,7 @@ Use `DefaultResourceLoader` to discover extensions, skills, prompts, themes, and import { DefaultResourceLoader, getAgentDir, -} from "@earendil-works/pi-coding-agent"; +} from "prime-agent"; const loader = new DefaultResourceLoader({ cwd, @@ -865,7 +869,7 @@ interface LoadExtensionsResult { ## Complete Example ```typescript -import { getModel } from "@earendil-works/pi-ai"; +import { getModel } from "prime-agent-ai"; import { Type } from "typebox"; import { AuthStorage, @@ -875,7 +879,7 @@ import { ModelRegistry, SessionManager, SettingsManager, -} from "@earendil-works/pi-coding-agent"; +} from "prime-agent"; // Set up auth storage (custom location) const authStorage = AuthStorage.create("/custom/agent/auth.json"); @@ -960,7 +964,7 @@ import { getAgentDir, InteractiveMode, SessionManager, -} from "@earendil-works/pi-coding-agent"; +} from "prime-agent"; const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => { const services = await createAgentSessionServices({ cwd }); @@ -1000,7 +1004,7 @@ import { getAgentDir, runPrintMode, SessionManager, -} from "@earendil-works/pi-coding-agent"; +} from "prime-agent"; const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => { const services = await createAgentSessionServices({ cwd }); @@ -1037,7 +1041,7 @@ import { getAgentDir, runRpcMode, SessionManager, -} from "@earendil-works/pi-coding-agent"; +} from "prime-agent"; const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => { const services = await createAgentSessionServices({ cwd }); diff --git a/packages/coding-agent/docs/session-format.md b/packages/coding-agent/docs/session-format.md index 7437e65acb..d547e94032 100644 --- a/packages/coding-agent/docs/session-format.md +++ b/packages/coding-agent/docs/session-format.md @@ -33,7 +33,7 @@ Existing sessions are automatically migrated to the current version (v3) when lo - [`packages/ai/src/types.ts`](../../ai/src/types.ts) - Base message types (`UserMessage`, `AssistantMessage`, `ToolResultMessage`) - [`packages/agent/src/types.ts`](../../agent/src/types.ts) - `AgentMessage` union type -For TypeScript definitions in your project, inspect `node_modules/@earendil-works/pi-coding-agent/dist/` and `node_modules/@earendil-works/pi-ai/dist/`. +For TypeScript definitions in an external project, inspect `node_modules/prime-agent/dist/` and `node_modules/prime-agent-ai/dist/` after following the [SDK installation guide](package-artifacts.md). ## Message Types diff --git a/packages/coding-agent/examples/sdk/README.md b/packages/coding-agent/examples/sdk/README.md index aac8b4ea6b..870434af7f 100644 --- a/packages/coding-agent/examples/sdk/README.md +++ b/packages/coding-agent/examples/sdk/README.md @@ -2,9 +2,10 @@ Programmatic usage of the Prime Agent SDK via `createAgentSession()` and `createAgentSessionRuntime()`. -The published TypeScript packages still use inherited `@earendil-works/pi-*` -identifiers. Those identifiers are API names, not a dependency on the upstream -Pi monorepo. +These repository examples use inherited `@earendil-works/pi-*` workspace +specifiers so they run directly from source. External consumers install and +import the branded release artifacts documented in +[SDK and library artifacts](../../docs/package-artifacts.md). The runtime example shows how to build a recreate function that closes over process-global fixed inputs and recreates cwd-bound services and sessions as the active session cwd changes. diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index fcd2e774ca..58d385784e 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -126,7 +126,7 @@ function getDefaultAgentDir(): string { * const { session } = await createAgentSession(); * * // With explicit model - * import { getModel } from '@earendil-works/pi-ai'; + * import { getModel } from 'prime-agent-ai'; * const { session } = await createAgentSession({ * model: getModel('anthropic', 'claude-opus-4-5'), * thinkingLevel: 'high', diff --git a/packages/coding-agent/test/release-package-documentation.test.ts b/packages/coding-agent/test/release-package-documentation.test.ts new file mode 100644 index 0000000000..4b02a3d2c9 --- /dev/null +++ b/packages/coding-agent/test/release-package-documentation.test.ts @@ -0,0 +1,78 @@ +import { readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolve(packageRoot, "..", ".."); + +function readRepoFile(relativePath: string): string { + return readFileSync(join(repoRoot, relativePath), "utf8"); +} + +describe("release package documentation", () => { + it("defines stable, beta, integrity, and update guidance for immutable artifacts", () => { + const guide = readRepoFile("packages/coding-agent/docs/package-artifacts.md"); + + expect(guide).toContain("/stable"); + expect(guide).toContain("/beta"); + expect(guide).toContain("SHA256SUMS"); + expect(guide).toContain("package-lock.json"); + expect(guide).toContain("prime-agent-ai"); + expect(guide).toContain("prime-agent-core"); + expect(guide).toContain("prime-agent-tui"); + }); + + it("does not advertise unsupported registry installs in public entry documentation", () => { + const publicEntryDocs = [ + "packages/agent/README.md", + "packages/ai/README.md", + "packages/tui/README.md", + "packages/coding-agent/docs/sdk.md", + ].map(readRepoFile); + + for (const document of publicEntryDocs) { + expect(document).not.toMatch( + /npm install (?:prime-agent(?:-(?:ai|core|tui))?|@earendil-works\/pi-(?:agent-core|ai|coding-agent|tui))/, + ); + } + }); + + it("uses branded package identities in external programmatic documentation", () => { + const externalProgrammaticDocs = [ + "packages/coding-agent/docs/sdk.md", + "packages/coding-agent/docs/rpc.md", + "packages/coding-agent/docs/session-format.md", + ].map(readRepoFile); + + for (const document of externalProgrammaticDocs) { + expect(document).not.toContain("@earendil-works/pi-"); + } + + const sdk = externalProgrammaticDocs[0]; + expect(sdk).toContain('from "prime-agent"'); + expect(sdk).toContain('from "prime-agent-ai"'); + const sdkSource = readRepoFile("packages/coding-agent/src/core/sdk.ts"); + expect(sdkSource).not.toContain("import { getModel } from '@earendil-works/pi-ai';"); + expect(sdkSource).toContain("import { getModel } from 'prime-agent-ai';"); + + const compaction = readRepoFile("packages/coding-agent/docs/compaction.md"); + expect(compaction).toContain("node_modules/prime-agent/dist/"); + expect(compaction).toContain("runtime compatibility specifier"); + }); + + it("keeps the TUI quick start independent of unpublished test files", () => { + const tuiReadme = readRepoFile("packages/tui/README.md"); + const quickStart = tuiReadme.split("## Quick Start", 2)[1]?.split("## Core API", 1)[0]; + + expect(quickStart).toBeDefined(); + expect(quickStart).not.toContain("./test/"); + }); + + it("labels inherited package names as runtime compatibility specifiers", () => { + const packageGuide = readRepoFile("packages/coding-agent/docs/packages.md"); + + expect(packageGuide).toContain("runtime compatibility specifiers"); + expect(packageGuide).not.toContain("The workspace still publishes these inherited package names"); + }); +}); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index abf78757af..214643ef7a 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +- Fixed the documented TUI installation path and made the published quick start self-contained ([#949](https://github.com/PrimeIntellect-ai/prime-agent/issues/949)). + ## [0.7.1] - 2026-08-07 ## [0.7.0] - 2026-08-05 diff --git a/packages/tui/README.md b/packages/tui/README.md index c28647549b..e7192e6508 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -13,7 +13,7 @@ Terminal UI primitives.

-Release docs use the Prime Agent package name. The source workspace manifest still keeps an inherited package name until the namespace migration is complete. +External releases use the branded `prime-agent-tui` package name. The inherited source workspace name is not a supported npm registry install target. Minimal terminal UI framework with differential rendering and synchronized output for flicker-free interactive CLI applications. @@ -28,10 +28,20 @@ Minimal terminal UI framework with differential rendering and synchronized outpu - **Image Support**: Renders terminal graphics or compact image metadata - **Autocomplete Support**: File paths and slash commands +## Installation + +```bash +release_base=https://pub-728493de92a943e2a9b2d17b4719f318.r2.dev +release_version="$(curl -fsSL "$release_base/stable")" +npm install "$release_base/releases/$release_version/prime-agent-tui-${release_version#v}.tgz" +``` + +This installs the immutable artifact for the current stable release. See [SDK and library artifacts](https://github.com/PrimeIntellect-ai/prime-agent/blob/main/packages/coding-agent/docs/package-artifacts.md) for beta releases, PowerShell, integrity verification, pinning, and updates. + ## Quick Start ```typescript -import { TUI, Text, Editor, ProcessTerminal, matchesKey } from "prime-agent-tui"; +import { Key, ProcessTerminal, Text, TUI, matchesKey } from "prime-agent-tui"; // Create terminal const terminal = new ProcessTerminal(); @@ -42,23 +52,13 @@ const tui = new TUI(terminal); // Add components tui.addChild(new Text("Welcome to my app!")); -import { defaultEditorTheme as editorTheme } from './test/test-themes.ts'; -const editor = new Editor(tui, editorTheme); -editor.onSubmit = (text) => { - console.log("Submitted:", text); - tui.addChild(new Text(`You said: ${text}`)); -}; -tui.addChild(editor); - -// Focus the editor so it receives keyboard input -tui.setFocus(editor); - // In raw mode Ctrl+C doesn't send SIGINT — intercept it here to allow exit tui.addInputListener((data) => { - if (matchesKey(data, 'ctrl+c')) { + if (matchesKey(data, Key.ctrl("c"))) { tui.stop(); - process.exit(0); + return { consume: true }; } + return undefined; }); // Start diff --git a/scripts/check-release-package-contract.mjs b/scripts/check-release-package-contract.mjs new file mode 100644 index 0000000000..bf7425bb72 --- /dev/null +++ b/scripts/check-release-package-contract.mjs @@ -0,0 +1,294 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { createReadStream } from "node:fs"; +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const releaseRoot = join(repoRoot, "packages", "coding-agent", "release"); +const contractVersion = "0.0.0-contract"; +const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; +const typeScriptCli = join(repoRoot, "node_modules", "typescript", "bin", "tsc"); + +const packages = [ + { + name: "prime-agent-ai", + readme: "packages/ai/README.md", + install: ["prime-agent-ai"], + runtime: `import { Type, getModel } from "prime-agent-ai"; +if (typeof Type !== "object" || typeof getModel !== "function") throw new Error("prime-agent-ai exports missing"); +`, + }, + { + name: "prime-agent-core", + readme: "packages/agent/README.md", + install: ["prime-agent-ai", "prime-agent-core"], + runtime: `import { Agent } from "prime-agent-core"; +import { getModel } from "prime-agent-ai"; +if (typeof Agent !== "function" || typeof getModel !== "function") throw new Error("prime-agent-core exports missing"); +`, + }, + { + name: "prime-agent-tui", + readme: "packages/tui/README.md", + install: ["prime-agent-tui"], + runtime: `import { TUI, Text } from "prime-agent-tui"; +if (typeof TUI !== "function" || typeof Text !== "function") throw new Error("prime-agent-tui exports missing"); +`, + }, + { + name: "prime-agent", + readme: "packages/coding-agent/docs/sdk.md", + install: ["prime-agent", "prime-agent-ai"], + runtime: `import { AuthStorage, createAgentSession } from "prime-agent"; +import { getModel } from "prime-agent-ai"; +if (typeof AuthStorage !== "function" || typeof createAgentSession !== "function" || typeof getModel !== "function") { + throw new Error("prime-agent SDK exports missing"); +} +`, + }, +]; + +function artifactFile(packageName) { + return `${packageName}-${contractVersion}.tgz`; +} + +function readRepoFile(relativePath) { + return readFileSync(join(repoRoot, relativePath), "utf8"); +} + +function extractQuickStart(relativePath) { + const markdown = readRepoFile(relativePath); + const heading = "## Quick Start"; + const headingIndex = markdown.indexOf(heading); + if (headingIndex === -1) { + throw new Error(`${relativePath}: missing ${heading}`); + } + + const afterHeading = markdown.slice(headingIndex + heading.length); + const nextHeadingIndex = afterHeading.search(/^## /m); + const section = nextHeadingIndex === -1 ? afterHeading : afterHeading.slice(0, nextHeadingIndex); + const codeBlock = section.match(/```typescript\r?\n([\s\S]*?)\r?\n```/); + if (!codeBlock) { + throw new Error(`${relativePath}: missing TypeScript quick start block`); + } + return codeBlock[1]; +} + +function assertDocumentationContract() { + const unsupportedInstalls = + /npm install (?:prime-agent(?:-(?:ai|core|tui))?|@earendil-works\/pi-(?:agent-core|ai|coding-agent|tui))/; + for (const packageContract of packages) { + const readme = readRepoFile(packageContract.readme); + if (unsupportedInstalls.test(readme)) { + throw new Error(`${packageContract.readme}: contains an unsupported registry install command`); + } + } + + const externalProgrammaticDocs = [ + "packages/coding-agent/docs/sdk.md", + "packages/coding-agent/docs/rpc.md", + "packages/coding-agent/docs/session-format.md", + ]; + for (const relativePath of externalProgrammaticDocs) { + if (readRepoFile(relativePath).includes("@earendil-works/pi-")) { + throw new Error(`${relativePath}: contains inherited external package guidance`); + } + } + const sdkSource = readRepoFile("packages/coding-agent/src/core/sdk.ts"); + if (sdkSource.includes("import { getModel } from '@earendil-works/pi-ai';")) { + throw new Error("packages/coding-agent/src/core/sdk.ts: contains an inherited package import in public JSDoc"); + } + + const compaction = readRepoFile("packages/coding-agent/docs/compaction.md"); + if (!compaction.includes("runtime compatibility specifier")) { + throw new Error("packages/coding-agent/docs/compaction.md: does not identify its extension runtime import"); + } + + const tuiQuickStart = extractQuickStart("packages/tui/README.md"); + if (tuiQuickStart.includes("./test/")) { + throw new Error("packages/tui/README.md: quick start imports an unpublished test file"); + } +} + +function run(command, args, options = {}) { + return new Promise((resolveRun, rejectRun) => { + const child = spawn(command, args, { + cwd: options.cwd ?? repoRoot, + env: { ...process.env, ...options.env }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.once("error", rejectRun); + child.once("close", (code) => { + if (code === 0) { + resolveRun({ stdout, stderr }); + return; + } + rejectRun( + new Error( + `${command} ${args.join(" ")} failed with exit code ${code ?? "unknown"}\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`, + ), + ); + }); + }); +} + +async function listen(server) { + await new Promise((resolveListen, rejectListen) => { + const onError = (error) => rejectListen(error); + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + server.off("error", onError); + resolveListen(); + }); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Loopback artifact server did not report a TCP port"); + } + return `http://127.0.0.1:${address.port}`; +} + +async function close(server) { + if (!server.listening) return; + await new Promise((resolveClose, rejectClose) => { + server.close((error) => (error ? rejectClose(error) : resolveClose())); + }); +} + +function createArtifactServer(artifactsDir) { + const prefix = `/releases/v${contractVersion}/`; + const allowedFiles = new Set(packages.map((packageContract) => artifactFile(packageContract.name))); + + return createServer((request, response) => { + const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1"); + if (!requestUrl.pathname.startsWith(prefix)) { + response.writeHead(404).end(); + return; + } + + const requestedFile = decodeURIComponent(requestUrl.pathname.slice(prefix.length)); + if (requestedFile !== basename(requestedFile) || !allowedFiles.has(requestedFile)) { + response.writeHead(404).end(); + return; + } + + const artifactPath = join(artifactsDir, requestedFile); + if (!existsSync(artifactPath)) { + response.writeHead(404).end(); + return; + } + + response.writeHead(200, { "content-type": "application/gzip" }); + const stream = createReadStream(artifactPath); + stream.once("error", (error) => response.destroy(error)); + stream.pipe(response); + }); +} + +async function validatePackage(packageContract, baseUrl, tempRoot, npmCache) { + const consumerDir = join(tempRoot, packageContract.name); + mkdirSync(consumerDir, { recursive: true }); + writeFileSync( + join(consumerDir, "package.json"), + `${JSON.stringify({ name: `${packageContract.name}-contract`, private: true, type: "module" }, null, 2)}\n`, + ); + + const installUrls = packageContract.install.map( + (packageName) => `${baseUrl}/releases/v${contractVersion}/${artifactFile(packageName)}`, + ); + await run( + npmCommand, + ["install", "--ignore-scripts", "--no-audit", "--no-fund", "--package-lock=false", ...installUrls], + { + cwd: consumerDir, + env: { npm_config_cache: npmCache }, + }, + ); + + writeFileSync(join(consumerDir, "quick-start.ts"), `${extractQuickStart(packageContract.readme)}\n`); + writeFileSync(join(consumerDir, "runtime.mjs"), packageContract.runtime); + writeFileSync( + join(consumerDir, "tsconfig.json"), + `${JSON.stringify( + { + compilerOptions: { + module: "NodeNext", + moduleResolution: "NodeNext", + noEmit: true, + skipLibCheck: true, + strict: true, + target: "ES2022", + }, + files: ["quick-start.ts"], + }, + null, + 2, + )}\n`, + ); + + await run(process.execPath, [typeScriptCli, "--project", "tsconfig.json"], { cwd: consumerDir }); + await run(process.execPath, ["runtime.mjs"], { cwd: consumerDir }); + console.log(`Validated ${packageContract.name}`); +} + +async function main() { + assertDocumentationContract(); + mkdirSync(releaseRoot, { recursive: true }); + const outputDir = mkdtempSync(join(releaseRoot, "contract-")); + const tempRoot = mkdtempSync(join(tmpdir(), "prime-agent-release-contract-")); + const npmCache = join(tempRoot, "npm-cache"); + const artifactsDir = join(outputDir, "artifacts"); + const server = createArtifactServer(artifactsDir); + + try { + const baseUrl = await listen(server); + await run(process.execPath, [ + join(repoRoot, "scripts", "pack-prime-agent-release.mjs"), + "--channel", + "stable", + "--version", + contractVersion, + "--base-url", + baseUrl, + "--out-dir", + outputDir, + ]); + + for (const packageContract of packages) { + await validatePackage(packageContract, baseUrl, tempRoot, npmCache); + } + } finally { + await close(server); + rmSync(outputDir, { force: true, recursive: true }); + rmSync(tempRoot, { force: true, recursive: true }); + } + + console.log("Release package contract passed."); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); From cc494c2837f339312c28b8759d7f8f0c056da15f Mon Sep 17 00:00:00 2001 From: fettpl <38704082+fettpl@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:50:16 +0200 Subject: [PATCH 05/13] docs: define CI-authoritative release lifecycle (fixes #934) --- .../2026-08-08-release-authority-design.md | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-08-release-authority-design.md diff --git a/docs/superpowers/specs/2026-08-08-release-authority-design.md b/docs/superpowers/specs/2026-08-08-release-authority-design.md new file mode 100644 index 0000000000..2c2bb5b1c5 --- /dev/null +++ b/docs/superpowers/specs/2026-08-08-release-authority-design.md @@ -0,0 +1,119 @@ +# CI-Authoritative Release Lifecycle + +## Context + +Issue [#934](https://github.com/PrimeIntellect-ai/prime-agent/issues/934) retires an inherited local npm release path that can version every workspace, publish packages, create commits and tags, and push `main`. Prime Agent's supported distribution is instead a set of branded, versioned tarballs published by GitHub Actions to R2 and mirrored in a GitHub Release. + +The implementation must not dispatch a release workflow or mutate npm, Git tags, R2, or GitHub Releases while it is developed or tested. + +## Scope + +This change owns: + +- one CI-authoritative production and beta release lifecycle; +- local version preparation and non-publishing validation; +- retirement of the local npm publish, commit, tag, and push path; +- immutable version, commit, tag, and artifact rules; +- idempotent production retry; +- ordered stable-channel promotion and a protected pointer-only rollback; +- release tests and maintainer documentation. + +Related work remains separate: + +- #926 owns action pinning, job permissions, and credential scope; +- #927 owns the full exact-commit Node and Python test gate; +- #949 owns supported public SDK and library package identities. + +There is no daemon protocol change. + +## Authority and Triggers + +A merged release-preparation commit on the protected default branch is the only source of a new production version. CI derives the version and commit from repository state; it does not accept a free-form version paired with the current branch head. + +Every default-branch commit may produce a beta. A production release is additionally planned only when the root release-control version changes to a valid higher version. Tag pushes do not independently publish. + +A manual production retry accepts only an existing immutable `vX.Y.Z` tag. CI derives both the version and commit from that tag, verifies that the tag is on the default branch and that repository metadata at the tagged commit agrees, and then reruns the same publication transaction. + +## Local Commands + +`release:prepare` is the only supported version mutation command. It updates the root release-control manifest, the four R2-packaged workspaces, their internal dependency ranges, the matching lockfile entries, and their changelog headings. It never stages, commits, tags, pushes, publishes, or invokes a release workflow. Private and example workspaces remain untouched. + +`release:dry-run` performs repository and artifact validation without publication credentials or remote mutation. It writes packaging output only to a temporary release directory and removes that directory on success or failure. The dry run verifies: + +- patch/minor-only plain semantic versions; +- lockstep root and R2-packaged workspace versions; +- internal dependency and lockfile consistency; +- a matching released changelog section for production candidates; +- branded tarball names and manifest paths; +- SHA-256 sums and manifest hashes; +- tarball package manifests and internal R2 dependency URLs. + +The existing `release:*`, `version:*`, `publish`, and `publish:dry` entry points become non-mutating tombstones with migration instructions. `release:pack` remains internal non-publishing packaging machinery. + +## Repository Validation + +The same pure policy code is used by local validation, tests, and workflow context resolution. Production validation requires: + +- the root and the four packaged workspace versions to match; +- the major version to remain zero; +- the candidate to be strictly newer than the previous production version; +- all packaged-workspace dependency ranges and lockfile metadata to match; +- each packaged workspace changelog to contain the candidate release heading; +- a `vX.Y.Z` tag to be absent or point to the exact candidate commit; +- retry tags to exist, point to the validated commit, and be ancestors of the default branch. + +Private and example workspace versions are explicitly outside release lockstep. + +## Publication Transaction + +CI builds each artifact once. The uploaded workflow artifact is the only input to publication and retry within that run. + +The production transaction is: + +1. Validate repository, version, commit, and tag invariants. +2. Verify or create the immutable `vX.Y.Z` tag and GitHub Release target. +3. For each versioned R2 object and GitHub Release asset, create it if absent; if present, compare its hash and fail on any mismatch. Never overwrite a different object. +4. Verify all versioned objects and GitHub Release assets. +5. Upload and verify the stable and beta installer scripts. +6. Write and verify the legacy `/stable` text pointer. +7. Write and verify `/latest.json` last. This JSON manifest is the stable commit marker. + +If a failure occurs before step 6, stable clients do not observe the candidate. If step 6 succeeds but step 7 fails, fresh installs may resolve the new complete release while update checks still see the previous release; rerunning the same tagged release converges safely. Repeating any completed step with identical content is a no-op. + +Beta publication keeps its stale-default-branch guard. It applies the same compare-before-write rule to versioned objects, updates `/beta` before `/beta.json`, and treats `/beta.json` as the beta commit marker. The mutable `beta` Git tag and prerelease remain beta-only compatibility surfaces. + +## Rollback + +Rollback is a separate manually dispatched workflow using the protected production environment. It requires an existing stable `vX.Y.Z` tag and exact confirmation text. + +The rollback workflow verifies the tag target, GitHub Release, saved release manifest, checksums, and every referenced R2 artifact before changing channel state. It changes only `/stable` and `/latest.json`, in that order. It never creates or moves an immutable version tag and never deletes or overwrites a versioned object. + +Rollback changes what fresh installations and later update checks select. It does not force already-installed newer clients to downgrade. A forward fix is preferred when possible. + +## Compatibility + +The following public release contracts remain unchanged: + +- `releases/vX.Y.Z/*` object paths and branded tarball names; +- `/stable` and `/beta` text pointers used by installers; +- `/latest.json` and `/beta.json` response shapes used by update checks; +- checksum verification and installer URLs. + +Existing npm package identities are neither unpublished nor redefined. Direct SDK/library distribution is deferred to #949. + +## Verification + +Deterministic tests cover: + +- default-branch beta-only and version-bump production plans; +- manual retry from an existing tag and rejection of free-form or conflicting targets; +- public-package version, dependency, lockfile, changelog, and tag drift; +- rejection of major, equal, and lower versions; +- private/example workspace preservation during preparation; +- immutable object create, identical retry, and mismatch failure decisions; +- stable and beta pointer ordering; +- rollback confirmation, target verification, and pointer-only behavior; +- dry-run cleanup and absence of publish, commit, tag, push, GitHub Release, and R2 commands; +- packed manifest, checksum, and internal URL correctness. + +The release workflow runs the focused release tests and dry run after the existing build and check. Issue #927 will separately add the repository's full test-suite publication gate. From 0031a321d7bb6f505260901191e7ff77f81cfc93 Mon Sep 17 00:00:00 2001 From: fettpl <38704082+fettpl@users.noreply.github.com> Date: Sat, 8 Aug 2026 20:12:50 +0200 Subject: [PATCH 06/13] fix(release): make CI the release authority (fixes #934) --- .github/workflows/build-binaries.yml | 300 +++---------- .github/workflows/rollback-release.yml | 58 +++ AGENTS.md | 20 +- RELEASING.md | 99 +++++ package.json | 23 +- packages/agent/CHANGELOG.md | 1 + packages/ai/CHANGELOG.md | 1 + packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/development.md | 2 + packages/tui/CHANGELOG.md | 1 + scripts/lib/release-lifecycle.mjs | 476 +++++++++++++++++++++ scripts/lib/release-publication.mjs | 109 +++++ scripts/pack-prime-agent-release.mjs | 3 +- scripts/prepare-release.mjs | 53 +++ scripts/publish-release.mjs | 445 +++++++++++++++++++ scripts/release-dry-run.mjs | 97 +++++ scripts/release-lifecycle.test.mjs | 396 +++++++++++++++++ scripts/release-publication.test.mjs | 136 ++++++ scripts/release-workflow-contract.test.mjs | 56 +++ scripts/release.mjs | 199 +-------- scripts/render-release-installers.mjs | 19 + scripts/resolve-release-context.mjs | 101 +++++ scripts/sync-versions.js | 97 +---- 23 files changed, 2152 insertions(+), 541 deletions(-) create mode 100644 .github/workflows/rollback-release.yml create mode 100644 RELEASING.md create mode 100644 scripts/lib/release-lifecycle.mjs create mode 100644 scripts/lib/release-publication.mjs create mode 100755 scripts/prepare-release.mjs create mode 100755 scripts/publish-release.mjs create mode 100755 scripts/release-dry-run.mjs create mode 100644 scripts/release-lifecycle.test.mjs create mode 100644 scripts/release-publication.test.mjs create mode 100644 scripts/release-workflow-contract.test.mjs create mode 100755 scripts/render-release-installers.mjs create mode 100755 scripts/resolve-release-context.mjs diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index d7022d365d..fd9721c98f 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -4,12 +4,16 @@ on: push: branches: - main - tags: - - 'v*' workflow_dispatch: inputs: + operation: + description: 'Release operation' + required: true + type: choice + options: + - retry-production release_tag: - description: 'Production release tag to create or update (e.g., v0.0.1)' + description: 'Existing immutable production tag to retry (for example v0.7.1)' required: true type: string @@ -40,6 +44,11 @@ jobs: fetch-tags: true persist-credentials: false + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + - name: Resolve release context id: context env: @@ -47,73 +56,12 @@ jobs: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} EVENT_NAME: ${{ github.event_name }} GITHUB_SHA_VALUE: ${{ github.sha }} - INPUT_RELEASE_TAG: ${{ github.event.inputs.release_tag || '' }} + INPUT_OPERATION: ${{ inputs.operation || '' }} + INPUT_RELEASE_TAG: ${{ inputs.release_tag || '' }} REF_NAME: ${{ github.ref_name }} - REF_TYPE: ${{ github.ref_type }} RUN_ATTEMPT: ${{ github.run_attempt }} RUN_NUMBER: ${{ github.run_number }} - run: | - beta_version= - build_ref= - production_version= - publish_beta=false - publish_production=false - - if [ "$EVENT_NAME" = workflow_dispatch ]; then - if [ "$REF_NAME" != "$DEFAULT_BRANCH" ]; then - echo "Manual releases must run from the default branch (${DEFAULT_BRANCH}), not ${REF_NAME}." >&2 - exit 1 - fi - production_version="${INPUT_RELEASE_TAG#v}" - build_ref="$GITHUB_SHA_VALUE" - publish_production=true - elif [ "$REF_TYPE" = tag ]; then - production_version="${REF_NAME#v}" - build_ref="$REF_NAME" - publish_production=true - else - production_version=$(node -p "require('./package.json').version") - build_ref="$GITHUB_SHA_VALUE" - beta_version="${production_version}-beta.${RUN_NUMBER}.${RUN_ATTEMPT}.${GITHUB_SHA_VALUE::7}" - publish_beta=true - - previous_version= - if [ -n "$BEFORE_SHA" ] && ! printf '%s\n' "$BEFORE_SHA" | grep -Eq '^0+$' && git cat-file -e "${BEFORE_SHA}:package.json"; then - git show "${BEFORE_SHA}:package.json" > /tmp/previous-package.json - previous_version=$(node -p "require('/tmp/previous-package.json').version") - fi - - if [ -z "$previous_version" ] || [ "$production_version" != "$previous_version" ]; then - if git show-ref --verify --quiet "refs/tags/v${production_version}"; then - tagged_commit=$(git rev-list -n 1 "v${production_version}") - if [ "$tagged_commit" != "$GITHUB_SHA_VALUE" ]; then - echo "Production v${production_version} already points to ${tagged_commit}, not ${GITHUB_SHA_VALUE}." >&2 - exit 1 - fi - echo "Retrying production v${production_version} for ${GITHUB_SHA_VALUE}." - fi - publish_production=true - elif ! git show-ref --verify --quiet "refs/tags/v${production_version}"; then - echo "Production v${production_version} has no tag; retrying the failed release." - publish_production=true - else - echo "Package version is unchanged at ${production_version}; only beta will advance." - fi - fi - - if [ "$publish_production" = true ] && ! printf '%s\n' "$production_version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then - echo "Production version must be plain semver like 0.0.1: ${production_version}" >&2 - exit 1 - fi - - echo "beta_version=$beta_version" >> "$GITHUB_OUTPUT" - echo "build_ref=$build_ref" >> "$GITHUB_OUTPUT" - echo "production_version=$production_version" >> "$GITHUB_OUTPUT" - echo "publish_beta=$publish_beta" >> "$GITHUB_OUTPUT" - echo "publish_production=$publish_production" >> "$GITHUB_OUTPUT" - echo "Build ref: $build_ref" - echo "Production: $publish_production ${production_version:+v${production_version}}" - echo "Beta: $publish_beta ${beta_version:+v${beta_version}}" + run: node scripts/resolve-release-context.mjs build: runs-on: ubuntu-latest @@ -127,9 +75,11 @@ jobs: PUBLISH_BETA: ${{ needs.release-context.outputs.publish_beta }} PUBLISH_PRODUCTION: ${{ needs.release-context.outputs.publish_production }} steps: - - name: Checkout + - name: Checkout validated release commit uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: + fetch-depth: 0 + fetch-tags: true ref: ${{ env.BUILD_REF }} persist-credentials: false @@ -148,6 +98,9 @@ jobs: - name: Check run: npm run check + - name: Test release lifecycle + run: npm run release:test + - name: Pack production release if: env.PUBLISH_PRODUCTION == 'true' env: @@ -160,6 +113,17 @@ jobs: --base-url "$PRIME_AGENT_DOWNLOAD_BASE_URL" \ --out-dir packages/coding-agent/release/production + - name: Dry-run production release + if: env.PUBLISH_PRODUCTION == 'true' + env: + PRIME_AGENT_DOWNLOAD_BASE_URL: ${{ vars.R2_PUBLIC_BASE_URL }} + run: | + npm run release:dry-run -- \ + --channel stable \ + --version "$PRODUCTION_VERSION" \ + --base-url "$PRIME_AGENT_DOWNLOAD_BASE_URL" \ + --artifacts-dir packages/coding-agent/release/production/artifacts + - name: Pack beta release if: env.PUBLISH_BETA == 'true' env: @@ -172,6 +136,17 @@ jobs: --base-url "$PRIME_AGENT_DOWNLOAD_BASE_URL" \ --out-dir packages/coding-agent/release/beta + - name: Dry-run beta release + if: env.PUBLISH_BETA == 'true' + env: + PRIME_AGENT_DOWNLOAD_BASE_URL: ${{ vars.R2_PUBLIC_BASE_URL }} + run: | + npm run release:dry-run -- \ + --channel beta \ + --version "$BETA_VERSION" \ + --base-url "$PRIME_AGENT_DOWNLOAD_BASE_URL" \ + --artifacts-dir packages/coding-agent/release/beta/artifacts + - name: Upload production artifacts if: env.PUBLISH_PRODUCTION == 'true' uses: actions/upload-artifact@v7 @@ -199,6 +174,7 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} BETA_VERSION: ${{ needs.release-context.outputs.beta_version }} BUILD_REF: ${{ needs.release-context.outputs.build_ref }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} PRODUCTION_VERSION: ${{ needs.release-context.outputs.production_version }} PUBLISH_BETA: ${{ needs.release-context.outputs.publish_beta }} PUBLISH_PRODUCTION: ${{ needs.release-context.outputs.publish_production }} @@ -206,9 +182,11 @@ jobs: R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} R2_PUBLIC_BASE_URL: ${{ vars.R2_PUBLIC_BASE_URL }} steps: - - name: Checkout + - name: Checkout validated release commit uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: + fetch-depth: 0 + fetch-tags: true ref: ${{ env.BUILD_REF }} persist-credentials: false @@ -226,23 +204,14 @@ jobs: name: prime-agent-beta path: release-artifacts/beta - - name: Prepare installer + - name: Prepare installers run: | INSTALL_BASE_URL="${R2_PUBLIC_BASE_URL%/}" - export INSTALL_BASE_URL test -n "$INSTALL_BASE_URL" - - node - <<'NODE' - const fs = require("node:fs"); - const baseUrl = process.env.INSTALL_BASE_URL; - if (!baseUrl) throw new Error("INSTALL_BASE_URL is required"); - const installer = fs.readFileSync("install.sh", "utf8"); - const renderInstaller = (channel) => installer - .replaceAll("__PRIME_AGENT_DOWNLOAD_BASE_URL__", baseUrl) - .replaceAll("__PRIME_AGENT_DEFAULT_RELEASE_CHANNEL__", channel); - fs.writeFileSync("/tmp/prime-agent-install.sh", renderInstaller("stable")); - fs.writeFileSync("/tmp/prime-agent-install-beta.sh", renderInstaller("beta")); - NODE + node scripts/render-release-installers.mjs \ + "$INSTALL_BASE_URL" \ + /tmp/prime-agent-install.sh \ + /tmp/prime-agent-install-beta.sh - name: Extract production release notes if: env.PUBLISH_PRODUCTION == 'true' @@ -252,161 +221,28 @@ jobs: echo "Release v${PRODUCTION_VERSION}" > /tmp/release-notes.md fi - - name: Publish production channel to R2 - if: env.PUBLISH_PRODUCTION == 'true' - run: | - PRODUCTION_DIR=release-artifacts/production - RELEASE_PREFIX="releases/v${PRODUCTION_VERSION}" - TARBALL="$PRODUCTION_DIR/prime-agent-${PRODUCTION_VERSION}.tgz" - - test -f "$TARBALL" - test -f "$PRODUCTION_DIR/SHA256SUMS" - test -f "$PRODUCTION_DIR/stable" - test -f "$PRODUCTION_DIR/latest.json" - test -n "$R2_BUCKET" - test -n "$R2_ENDPOINT_URL" - - for artifact in "$PRODUCTION_DIR"/*.tgz; do - aws s3 cp "$artifact" "s3://${R2_BUCKET}/${RELEASE_PREFIX}/$(basename "$artifact")" \ - --endpoint-url "$R2_ENDPOINT_URL" \ - --content-type application/gzip \ - --cache-control 'public, max-age=31536000, immutable' - done - - aws s3 cp "$PRODUCTION_DIR/SHA256SUMS" "s3://${R2_BUCKET}/${RELEASE_PREFIX}/SHA256SUMS" \ - --endpoint-url "$R2_ENDPOINT_URL" \ - --content-type text/plain \ - --cache-control 'public, max-age=31536000, immutable' - - aws s3 cp "$PRODUCTION_DIR/latest.json" "s3://${R2_BUCKET}/latest.json" \ - --endpoint-url "$R2_ENDPOINT_URL" \ - --content-type application/json \ - --cache-control no-cache - - aws s3 cp "$PRODUCTION_DIR/stable" "s3://${R2_BUCKET}/stable" \ - --endpoint-url "$R2_ENDPOINT_URL" \ - --content-type text/plain \ - --cache-control no-cache - - aws s3 cp /tmp/prime-agent-install.sh "s3://${R2_BUCKET}/install.sh" \ - --endpoint-url "$R2_ENDPOINT_URL" \ - --content-type text/x-shellscript \ - --cache-control no-cache - - aws s3 cp /tmp/prime-agent-install-beta.sh "s3://${R2_BUCKET}/install-beta.sh" \ - --endpoint-url "$R2_ENDPOINT_URL" \ - --content-type text/x-shellscript \ - --cache-control no-cache - - - name: Create production GitHub release + - name: Publish production transaction if: env.PUBLISH_PRODUCTION == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - RELEASE_TAG="v${PRODUCTION_VERSION}" - PRODUCTION_DIR=release-artifacts/production - target_args=() - if [ "$BUILD_REF" != "$RELEASE_TAG" ]; then - target_args=(--target "$BUILD_REF") - fi - - if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then - gh release upload "$RELEASE_TAG" "$PRODUCTION_DIR"/* --clobber - else - gh release create "$RELEASE_TAG" \ - --title "$RELEASE_TAG" \ - "${target_args[@]}" \ - --notes-file /tmp/release-notes.md \ - "$PRODUCTION_DIR"/* - fi - - - name: Publish immutable beta artifacts to R2 - if: env.PUBLISH_BETA == 'true' - run: | - BETA_DIR=release-artifacts/beta - RELEASE_PREFIX="releases/v${BETA_VERSION}" - TARBALL="$BETA_DIR/prime-agent-${BETA_VERSION}.tgz" - - test -f "$TARBALL" - test -f "$BETA_DIR/SHA256SUMS" - test -f "$BETA_DIR/beta" - test -f "$BETA_DIR/beta.json" - test -n "$R2_BUCKET" - test -n "$R2_ENDPOINT_URL" - - for artifact in "$BETA_DIR"/*.tgz; do - aws s3 cp "$artifact" "s3://${R2_BUCKET}/${RELEASE_PREFIX}/$(basename "$artifact")" \ - --endpoint-url "$R2_ENDPOINT_URL" \ - --content-type application/gzip \ - --cache-control 'public, max-age=31536000, immutable' - done - - aws s3 cp "$BETA_DIR/SHA256SUMS" "s3://${R2_BUCKET}/${RELEASE_PREFIX}/SHA256SUMS" \ - --endpoint-url "$R2_ENDPOINT_URL" \ - --content-type text/plain \ - --cache-control 'public, max-age=31536000, immutable' + node scripts/publish-release.mjs production \ + --artifacts-dir release-artifacts/production \ + --version "$PRODUCTION_VERSION" \ + --build-ref "$BUILD_REF" \ + --notes-file /tmp/release-notes.md \ + --stable-installer /tmp/prime-agent-install.sh \ + --beta-installer /tmp/prime-agent-install-beta.sh - - name: Advance beta release + - name: Publish beta transaction if: env.PUBLISH_BETA == 'true' env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - latest_main_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${DEFAULT_BRANCH}" --jq .sha) - if [ "$latest_main_sha" != "$BUILD_REF" ]; then - echo "A newer main commit exists; keeping its beta pointers in place." - exit 0 - fi - - BETA_DIR=release-artifacts/beta - aws s3 cp "$BETA_DIR/beta.json" "s3://${R2_BUCKET}/beta.json" \ - --endpoint-url "$R2_ENDPOINT_URL" \ - --content-type application/json \ - --cache-control no-cache - - aws s3 cp "$BETA_DIR/beta" "s3://${R2_BUCKET}/beta" \ - --endpoint-url "$R2_ENDPOINT_URL" \ - --content-type text/plain \ - --cache-control no-cache - - aws s3 cp /tmp/prime-agent-install.sh "s3://${R2_BUCKET}/install.sh" \ - --endpoint-url "$R2_ENDPOINT_URL" \ - --content-type text/x-shellscript \ - --cache-control no-cache - - aws s3 cp /tmp/prime-agent-install-beta.sh "s3://${R2_BUCKET}/install-beta.sh" \ - --endpoint-url "$R2_ENDPOINT_URL" \ - --content-type text/x-shellscript \ - --cache-control no-cache - - printf 'Automated beta build from `%s` (`%s`).\n' "$DEFAULT_BRANCH" "$BUILD_REF" > /tmp/beta-release-notes.md - - if gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/beta" >/dev/null 2>&1; then - gh api --method PATCH "repos/${GITHUB_REPOSITORY}/git/refs/tags/beta" \ - -F sha="$BUILD_REF" \ - -F force=true >/dev/null - else - gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs" \ - -f ref=refs/tags/beta \ - -f sha="$BUILD_REF" >/dev/null - fi - - if release_id=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/beta" --jq .id 2>/dev/null); then - gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets" --jq '.[].id' | while read -r asset_id; do - gh api --method DELETE "repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" - done - gh release edit beta \ - --title "Beta (v${BETA_VERSION})" \ - --target "$BUILD_REF" \ - --notes-file /tmp/beta-release-notes.md \ - --prerelease - else - gh release create beta \ - --title "Beta (v${BETA_VERSION})" \ - --target "$BUILD_REF" \ - --notes-file /tmp/beta-release-notes.md \ - --prerelease - fi - - gh release upload beta "$BETA_DIR"/* --clobber - echo "Beta installer: ${R2_PUBLIC_BASE_URL%/}/install-beta.sh" + node scripts/publish-release.mjs beta \ + --artifacts-dir release-artifacts/beta \ + --version "$BETA_VERSION" \ + --build-ref "$BUILD_REF" \ + --default-branch "$DEFAULT_BRANCH" \ + --stable-installer /tmp/prime-agent-install.sh \ + --beta-installer /tmp/prime-agent-install-beta.sh diff --git a/.github/workflows/rollback-release.yml b/.github/workflows/rollback-release.yml new file mode 100644 index 0000000000..49e802c590 --- /dev/null +++ b/.github/workflows/rollback-release.yml @@ -0,0 +1,58 @@ +name: Rollback Prime Agent stable channel + +on: + workflow_dispatch: + inputs: + release_tag: + description: 'Existing stable release tag to restore (for example v0.7.1)' + required: true + type: string + confirmation: + description: 'Type ROLLBACK vX.Y.Z using the exact target tag' + required: true + type: string + +concurrency: + group: release-prime-agent + cancel-in-progress: false + queue: max + +permissions: + contents: read + +jobs: + rollback: + runs-on: ubuntu-latest + environment: production + permissions: + contents: read + steps: + - name: Checkout release tooling and tags + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + + - name: Verify target and roll back stable pointers + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_DEFAULT_REGION: auto + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + CONFIRMATION: ${{ inputs.confirmation }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + R2_BUCKET: ${{ secrets.R2_BUCKET }} + R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} + R2_PUBLIC_BASE_URL: ${{ vars.R2_PUBLIC_BASE_URL }} + RELEASE_TAG: ${{ inputs.release_tag }} + run: | + node scripts/publish-release.mjs rollback \ + --release-tag "$RELEASE_TAG" \ + --confirmation "$CONFIRMATION" \ + --default-branch "$DEFAULT_BRANCH" diff --git a/AGENTS.md b/AGENTS.md index d8c18dd87e..15219c7907 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -187,24 +187,24 @@ Create provider file exporting: ## Releasing -**Lockstep versioning**: All packages always share the same version number. Every release updates all packages together. +The protected GitHub Actions workflows are the only release authority. Local release commands must never publish packages, create or move tags, push branches, upload artifacts, or promote a release channel. See [`RELEASING.md`](RELEASING.md) for the artifact lifecycle, retry, and rollback procedures. + +**Lockstep versioning**: the root release-control manifest and `packages/agent`, `packages/ai`, `packages/coding-agent`, and `packages/tui` share one version. Private and example workspaces are not release packages and must remain untouched. **Version semantics** (no major releases): - `patch`: Bug fixes and new features - `minor`: API breaking changes -### Steps - -1. **Update CHANGELOGs**: Ensure all changes since last release are documented in the `[Unreleased]` section of each affected package's CHANGELOG.md +### Local preparation -2. **Run release script**: - ```bash - npm run release:patch # Fixes and additions - npm run release:minor # API breaking changes - ``` +1. Ensure every affected package's `[Unreleased]` section is complete. +2. From a clean feature branch, run `npm run release:prepare -- patch`, `npm run release:prepare -- minor`, or an explicit newer `0.x.y` version. +3. Review the complete version, changelog, dependency-range, and lockfile diff. +4. Build through the normal repository process, then run `npm run release:dry-run`, `npm run release:test`, and `npm run check`. +5. Open a release-preparation pull request. CI publishes only after that exact version commit merges to the protected default branch. -The script handles: version bump, CHANGELOG finalization, commit, tag, publish, and adding new `[Unreleased]` sections. +The inherited `release:patch`, `release:minor`, `release:major`, `version:*`, root `publish*`, and `scripts/sync-versions.js` paths intentionally hard-fail. Never bypass them. Never dispatch a publishing or rollback workflow unless the user explicitly authorizes that external state change. ## **CRITICAL** Git Rules for Parallel Agents **CRITICAL** diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000000..e0c42ec61c --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,99 @@ +# Releasing Prime Agent + +The protected GitHub Actions workflows are the only release authority. Local commands may prepare and validate release files, but they never publish packages, create or move tags, push branches, upload artifacts, or promote a release channel. + +Prime Agent's supported distribution is the branded R2 tarball bundle installed by `install.sh`. The inherited npm workspace names are source-level implementation details. Direct SDK and library distribution is tracked separately in issue #949. + +## Release Contract + +A stable version consists of: + +- immutable objects under `releases/vX.Y.Z/`; +- an immutable `vX.Y.Z` Git tag and matching GitHub Release assets; +- the `/stable` text pointer used by existing installers; +- `/latest.json`, the stable commit marker used by update checks. + +Beta uses the same versioned R2 object rules, plus the mutable `/beta`, `/beta.json`, `beta` tag, and beta prerelease compatibility surfaces. Installers continue to read the text pointers, while running clients read the JSON manifests. Do not remove or change either shape without a separate compatibility plan. + +The root release-control version and these four packaged workspaces are lockstep: + +- `packages/agent` +- `packages/ai` +- `packages/coding-agent` +- `packages/tui` + +Private and example workspaces are not release packages and must remain untouched. Prime Agent supports patch and minor releases while the major version remains zero. + +## Prepare a Release Pull Request + +Start from a clean feature branch after reviewing every package's `[Unreleased]` section. Run one of: + +```bash +npm run release:prepare -- patch +npm run release:prepare -- minor +npm run release:prepare -- 0.8.1 +``` + +The command updates only the root release-control manifest, the four packaged manifests, internal dependency ranges, corresponding lockfile metadata, and those four changelogs. It does not stage or commit the result. + +Review the complete diff. Confirm that each changelog release section is accurate, private/example manifests did not change, and the lockfile contains only expected version and internal-range changes. + +Build the candidate using the normal repository build process, then run the non-publishing validation: + +```bash +npm run release:dry-run +npm run release:test +npm run check +``` + +`release:dry-run` packages into a temporary directory, validates tarball names and manifests, verifies every SHA-256 digest and internal R2 URL, and removes its output. It has no npm, GitHub Release, Git tag, Git push, or R2 publication path. + +Open and review the release-preparation pull request. Merging the exact version-preparation commit to the protected default branch is the only way to introduce a new production candidate. + +## CI Publication + +For each default-branch push, the Release Prime Agent workflow builds a beta. It additionally publishes production only when the root version changed to a strictly newer validated version. + +The production transaction is ordered as follows: + +1. Resolve and validate the exact version, commit, lockfile, package manifests, changelogs, and existing tag state. +2. Build, check, run the release lifecycle tests, pack once, and validate the workflow artifacts without publication credentials. +3. Create or verify the immutable version tag and GitHub Release target. +4. Create missing versioned R2 objects using conditional writes. Existing objects must be byte-identical. +5. Upload missing GitHub Release assets. Existing assets must be byte-identical and are never clobbered. +6. Verify all versioned R2 and GitHub Release objects. +7. Upload and verify the stable and beta installer scripts. +8. Write and verify `/stable`. +9. Write and verify `/latest.json` last. + +The workflow serializes publication and refuses to move stable pointers backward. Issue #927 separately owns the full exact-commit Node and Python suite gate; do not treat the focused release tests as that broader gate. + +## Retry + +Use the Release Prime Agent workflow's `retry-production` dispatch only after the immutable `vX.Y.Z` tag exists. The workflow derives both the version and commit from that tag, verifies that it belongs to the default branch, and rebuilds the exact tagged commit. A free-form version paired with current `main` is not supported. + +If the original production run failed before creating the version tag, rerun that exact failed workflow run instead. Do not use a later default-branch run or create the tag locally. Once the immutable tag exists, use `retry-production` for subsequent recovery attempts. + +Retries are idempotent: + +- a failure before tag creation has no release state; +- an empty release or partially uploaded immutable set can be completed by retry; +- identical existing R2 and GitHub Release objects are reused; +- any existing object with different bytes hard-fails; +- if `/stable` advanced but `/latest.json` did not, retry completes the JSON commit marker. + +Never delete or replace an immutable version tag or artifact to make a retry pass. Investigate the mismatch and prepare a new version. + +## Rollback + +Prefer a forward fix. If stable must be restored immediately, dispatch the Rollback Prime Agent stable channel workflow. Select an existing stable `vX.Y.Z` tag and enter the exact confirmation `ROLLBACK vX.Y.Z`. + +The rollback job uses the protected `production` environment. Configure that environment with required reviewers before enabling rollback credentials. The job verifies the immutable tag, GitHub Release assets, manifest, checksums, and every referenced R2 tarball before writing `/stable` and then `/latest.json`. It does not create or move tags, rewrite immutable objects, or change GitHub Releases. + +Rollback changes what fresh installations and future update checks select. It does not force already-installed newer clients to downgrade. + +## Retired Commands + +The inherited `release:*`, `version:*`, root `publish`, root `publish:dry`, and `scripts/sync-versions.js` paths intentionally hard-fail. They previously operated on unrelated workspaces, npm publication, local commits and tags, and direct pushes to `main`. Do not restore or bypass them. + +`release:pack` remains internal non-publishing packaging machinery used by the dry run and CI. diff --git a/package.json b/package.json index 5eba86973a..a5a0b8c5c5 100644 --- a/package.json +++ b/package.json @@ -21,17 +21,20 @@ "profile:tui": "node scripts/profile-coding-agent-node.mjs --mode tui", "profile:rpc": "node scripts/profile-coding-agent-node.mjs --mode rpc", "test": "npm run test --workspaces --if-present", - "version:patch": "npm version patch -ws --no-git-tag-version && node scripts/sync-versions.js && shx rm -rf node_modules packages/*/node_modules package-lock.json && npm install", - "version:minor": "npm version minor -ws --no-git-tag-version && node scripts/sync-versions.js && shx rm -rf node_modules packages/*/node_modules package-lock.json && npm install", - "version:major": "npm version major -ws --no-git-tag-version && node scripts/sync-versions.js && shx rm -rf node_modules packages/*/node_modules package-lock.json && npm install", - "version:set": "npm version -ws", - "prepublishOnly": "npm run clean && npm run build && npm run check", - "publish": "npm run prepublishOnly && npm publish -ws --access public", - "publish:dry": "npm run prepublishOnly && npm publish -ws --access public --dry-run", + "version:patch": "node scripts/release.mjs", + "version:minor": "node scripts/release.mjs", + "version:major": "node scripts/release.mjs", + "version:set": "node scripts/release.mjs", + "prepublishOnly": "node scripts/release.mjs", + "publish": "node scripts/release.mjs", + "publish:dry": "node scripts/release.mjs", "release:pack": "node scripts/pack-prime-agent-release.mjs", - "release:patch": "node scripts/release.mjs patch", - "release:minor": "node scripts/release.mjs minor", - "release:major": "node scripts/release.mjs major", + "release:prepare": "node scripts/prepare-release.mjs", + "release:dry-run": "node scripts/release-dry-run.mjs", + "release:test": "node --test scripts/release-lifecycle.test.mjs scripts/release-publication.test.mjs scripts/release-workflow-contract.test.mjs", + "release:patch": "node scripts/release.mjs", + "release:minor": "node scripts/release.mjs", + "release:major": "node scripts/release.mjs", "prepare": "husky" }, "devDependencies": { diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index a60240cb0e..d607e9619e 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] - Fixed the documented core installation path to use branded, immutable Prime Agent release artifacts ([#949](https://github.com/PrimeIntellect-ai/prime-agent/issues/949)). +- Changed Prime Agent releases to use an idempotent CI/R2 lifecycle with local preparation and validation commands that cannot publish or push ([#934](https://github.com/PrimeIntellect-ai/prime-agent/issues/934)). ## [0.7.1] - 2026-08-07 diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 9f20db94a9..c0635d2ac1 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] - Fixed the documented AI installation path and added the branded `prime-agent-ai` command alias ([#949](https://github.com/PrimeIntellect-ai/prime-agent/issues/949)). +- Changed Prime Agent releases to use an idempotent CI/R2 lifecycle with local preparation and validation commands that cannot publish or push ([#934](https://github.com/PrimeIntellect-ai/prime-agent/issues/934)). ## [0.7.1] - 2026-08-07 diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7fca352034..219d397fcf 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] - Fixed SDK and library installation guidance to use branded, immutable Prime Agent release artifacts ([#949](https://github.com/PrimeIntellect-ai/prime-agent/issues/949)). +- Changed Prime Agent releases to use an idempotent CI/R2 lifecycle with local preparation and validation commands that cannot publish or push ([#934](https://github.com/PrimeIntellect-ai/prime-agent/issues/934)). - Added privacy-safe pseudonymous product analytics for onboarding, command use, execution modes, run outcomes, TTFT, latency, usage, tools, retries, and compactions, with disclosure and opt-out controls ([ENG-4682](https://linear.app/primeintellect/issue/ENG-4682/add-privacy-safe-posthog-analytics-to-prime-agent)). - Changed sent agent messages in the IPython cell UI to show only the message text with a `╰─` gutter when expanded, matching received messages, and hid the raw `agent_message.send` receipt dictionary. - Fixed Homebrew installs attempting to self-update their versioned Cellar keg instead of directing users to `brew upgrade prime-agent` ([#844](https://github.com/PrimeIntellect-ai/prime-agent/issues/844)) diff --git a/packages/coding-agent/docs/development.md b/packages/coding-agent/docs/development.md index 56aa1a74a4..7ca27904b7 100644 --- a/packages/coding-agent/docs/development.md +++ b/packages/coding-agent/docs/development.md @@ -26,6 +26,8 @@ Prime Agent is the product, public CLI, release artifact, and repository name. T Public releases are currently versioned tarball artifacts installed by the stable and beta installer scripts. `scripts/pack-prime-agent-release.mjs` rewrites the coding-agent package name, executable, config metadata, and internal dependency URLs for that distribution. Do not document the inherited npm workspace package as the public Prime Agent install path. +Maintainers should follow the repository [`RELEASING.md`](../../../RELEASING.md) for version preparation, non-publishing validation, CI publication, retry, and rollback. No supported local command publishes a release or pushes the default branch. + ## Local Configuration User configuration lives under `~/.prime/agent/`. Project-local settings, prompts, themes, extensions, skills, and system-prompt files live under `.prime/agent/` in the project root. Override the user config directory with `PRIME_AGENT_CODING_AGENT_DIR` and the session directory with `PRIME_AGENT_SESSION_DIR`. diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 214643ef7a..0a11894771 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] - Fixed the documented TUI installation path and made the published quick start self-contained ([#949](https://github.com/PrimeIntellect-ai/prime-agent/issues/949)). +- Changed Prime Agent releases to use an idempotent CI/R2 lifecycle with local preparation and validation commands that cannot publish or push ([#934](https://github.com/PrimeIntellect-ai/prime-agent/issues/934)). ## [0.7.1] - 2026-08-07 diff --git a/scripts/lib/release-lifecycle.mjs b/scripts/lib/release-lifecycle.mjs new file mode 100644 index 0000000000..47dd28afe0 --- /dev/null +++ b/scripts/lib/release-lifecycle.mjs @@ -0,0 +1,476 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { basename, dirname, join, relative } from "node:path"; +import { isDeepStrictEqual } from "node:util"; + +export const RELEASE_PACKAGES = [ + { dir: "packages/agent", name: "@earendil-works/pi-agent-core" }, + { dir: "packages/ai", name: "@earendil-works/pi-ai" }, + { dir: "packages/coding-agent", name: "@earendil-works/pi-coding-agent" }, + { dir: "packages/tui", name: "@earendil-works/pi-tui" }, +]; + +export const RELEASE_PACKAGE_DIRS = RELEASE_PACKAGES.map((releasePackage) => releasePackage.dir); + +const INTERNAL_PACKAGE_NAMES = new Set(RELEASE_PACKAGES.map((releasePackage) => releasePackage.name)); +const EXPECTED_INTERNAL_DEPENDENCIES = new Map([ + ["package.json", ["@earendil-works/pi-coding-agent"]], + ["packages/agent/package.json", ["@earendil-works/pi-ai"]], + ["packages/ai/package.json", []], + [ + "packages/coding-agent/package.json", + ["@earendil-works/pi-agent-core", "@earendil-works/pi-ai", "@earendil-works/pi-tui"], + ], + ["packages/tui/package.json", []], +]); +const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +const BETA_VERSION_PATTERN = /^0\.\d+\.\d+-beta\.\d+\.\d+\.[0-9a-f]{7}$/; +const RELEASE_ARTIFACTS = [ + { filePrefix: "prime-agent-ai", packageName: "prime-agent-ai", sourceName: "@earendil-works/pi-ai" }, + { filePrefix: "prime-agent-core", packageName: "prime-agent-core", sourceName: "@earendil-works/pi-agent-core" }, + { filePrefix: "prime-agent-tui", packageName: "prime-agent-tui", sourceName: "@earendil-works/pi-tui" }, + { filePrefix: "prime-agent", packageName: "prime-agent", sourceName: "@earendil-works/pi-coding-agent" }, +]; + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function formatJson(value) { + return `${JSON.stringify(value, null, "\t")}\n`; +} + +function parseVersion(version, label = "release version") { + if (typeof version !== "string") { + throw new Error(`${label} must be a plain semantic version`); + } + const match = version.match(VERSION_PATTERN); + if (!match) { + throw new Error(`${label} must be a plain semantic version like 0.7.2: ${version}`); + } + const parsed = { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + version, + }; + if (parsed.major !== 0) { + throw new Error(`${label} major version must remain 0: ${version}`); + } + return parsed; +} + +export function compareVersions(leftVersion, rightVersion) { + const left = parseVersion(leftVersion, "candidate version"); + const right = parseVersion(rightVersion, "current version"); + return left.major - right.major || left.minor - right.minor || left.patch - right.patch; +} + +export function bumpVersion(currentVersion, target) { + const current = parseVersion(currentVersion, "current version"); + if (target === "major") { + throw new Error("Major releases are not supported; use patch or minor"); + } + if (target === "patch") { + return `${current.major}.${current.minor}.${current.patch + 1}`; + } + if (target === "minor") { + return `${current.major}.${current.minor + 1}.0`; + } + const explicit = parseVersion(target, "release version"); + if (compareVersions(explicit.version, current.version) <= 0) { + throw new Error(`Release version ${explicit.version} must be newer than ${current.version}`); + } + return explicit.version; +} + +function expectedRange(version) { + return `^${version}`; +} + +function validateInternalRanges(label, packageJson, version, errors, expectedPackagePath = label) { + for (const name of EXPECTED_INTERNAL_DEPENDENCIES.get(expectedPackagePath) ?? []) { + const range = packageJson.dependencies?.[name]; + if (range !== expectedRange(version)) { + errors.push(`${label} dependencies.${name} is ${range ?? "missing"}; expected ${expectedRange(version)}`); + } + } + for (const field of ["dependencies", "devDependencies", "optionalDependencies"]) { + for (const [name, range] of Object.entries(packageJson[field] ?? {})) { + if (INTERNAL_PACKAGE_NAMES.has(name) && range !== expectedRange(version)) { + errors.push(`${label} ${field}.${name} is ${range}; expected ${expectedRange(version)}`); + } + } + } +} + +function validateChangelog(root, packageDir, version, errors) { + const path = join(root, packageDir, "CHANGELOG.md"); + if (!existsSync(path)) { + errors.push(`${packageDir}/CHANGELOG.md is missing`); + return; + } + const escapedVersion = version.replaceAll(".", "\\."); + if (!new RegExp(`^## \\[${escapedVersion}\\] - \\d{4}-\\d{2}-\\d{2}$`, "m").test(readFileSync(path, "utf8"))) { + errors.push(`${packageDir}/CHANGELOG.md has no dated [${version}] release section`); + } +} + +export function validateReleaseRepository(root, options = {}) { + const rootPackage = readJson(join(root, "package.json")); + const version = options.version ?? rootPackage.version; + parseVersion(version); + const errors = []; + if (rootPackage.version !== version) { + errors.push(`package.json version is ${rootPackage.version}; expected ${version}`); + } + validateInternalRanges("package.json", rootPackage, version, errors); + + const lockPath = join(root, "package-lock.json"); + const lock = readJson(lockPath); + if (lock.version !== version) { + errors.push(`package-lock.json version is ${lock.version}; expected ${version}`); + } + const lockRoot = lock.packages?.[""]; + if (!lockRoot) { + errors.push("package-lock.json is missing its root package metadata"); + } else { + if (lockRoot.version !== version) { + errors.push(`package-lock.json root package version is ${lockRoot.version}; expected ${version}`); + } + validateInternalRanges("package-lock.json root package", lockRoot, version, errors, "package.json"); + } + + for (const releasePackage of RELEASE_PACKAGES) { + const packagePath = `${releasePackage.dir}/package.json`; + const packageJson = readJson(join(root, packagePath)); + if (packageJson.name !== releasePackage.name) { + errors.push(`${packagePath} name is ${packageJson.name}; expected ${releasePackage.name}`); + } + if (packageJson.version !== version) { + errors.push(`${releasePackage.dir} version is ${packageJson.version}; expected ${version}`); + } + validateInternalRanges(packagePath, packageJson, version, errors); + + const lockPackage = lock.packages?.[releasePackage.dir]; + if (!lockPackage) { + errors.push(`package-lock.json is missing ${releasePackage.dir}`); + } else { + if (lockPackage.version !== version) { + errors.push(`package-lock.json ${releasePackage.dir} version is ${lockPackage.version}; expected ${version}`); + } + validateInternalRanges( + `package-lock.json ${releasePackage.dir}`, + lockPackage, + version, + errors, + `${releasePackage.dir}/package.json`, + ); + } + + if (options.requireChangelogs) { + validateChangelog(root, releasePackage.dir, version, errors); + } + } + + if (errors.length > 0) { + throw new Error(`Release metadata is inconsistent:\n- ${errors.join("\n- ")}`); + } + return { version }; +} + +function updateInternalRanges(packageJson, version) { + for (const field of ["dependencies", "devDependencies", "optionalDependencies"]) { + if (!packageJson[field]) continue; + for (const name of Object.keys(packageJson[field])) { + if (INTERNAL_PACKAGE_NAMES.has(name)) { + packageJson[field][name] = expectedRange(version); + } + } + } +} + +function releaseChangelog(content, version, date, path) { + if (!/^## \[Unreleased\]$/m.test(content)) { + throw new Error(`${path} has no [Unreleased] section`); + } + if (new RegExp(`^## \\[${version.replaceAll(".", "\\.")}\\](?: - |$)`, "m").test(content)) { + throw new Error(`${path} already contains release ${version}`); + } + return content.replace(/^## \[Unreleased\]\n/m, `## [Unreleased]\n\n## [${version}] - ${date}\n`); +} + +function writePreparedFiles(root, files) { + const staged = []; + try { + for (const [index, [relativePath, content]] of files.entries()) { + const path = join(root, relativePath); + const temporaryPath = join(dirname(path), `.${basename(path)}.release-${process.pid}-${index}.tmp`); + writeFileSync(temporaryPath, content, { flag: "wx" }); + staged.push({ path, temporaryPath }); + } + for (const file of staged) { + renameSync(file.temporaryPath, file.path); + } + } finally { + for (const file of staged) { + rmSync(file.temporaryPath, { force: true }); + } + } +} + +export function prepareRelease(root, target, options = {}) { + const date = options.date ?? new Date().toISOString().slice(0, 10); + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { + throw new Error(`Release date must use YYYY-MM-DD: ${date}`); + } + const rootPackage = readJson(join(root, "package.json")); + validateReleaseRepository(root, { version: rootPackage.version }); + const version = bumpVersion(rootPackage.version, target); + const files = new Map(); + rootPackage.version = version; + updateInternalRanges(rootPackage, version); + files.set("package.json", formatJson(rootPackage)); + + for (const releasePackage of RELEASE_PACKAGES) { + const packagePath = `${releasePackage.dir}/package.json`; + const packageJson = readJson(join(root, packagePath)); + packageJson.version = version; + updateInternalRanges(packageJson, version); + files.set(packagePath, formatJson(packageJson)); + + const changelogPath = `${releasePackage.dir}/CHANGELOG.md`; + const changelog = readFileSync(join(root, changelogPath), "utf8"); + files.set(changelogPath, releaseChangelog(changelog, version, date, changelogPath)); + } + + const lock = readJson(join(root, "package-lock.json")); + lock.version = version; + lock.packages[""].version = version; + updateInternalRanges(lock.packages[""], version); + for (const releasePackage of RELEASE_PACKAGES) { + const lockPackage = lock.packages[releasePackage.dir]; + lockPackage.version = version; + updateInternalRanges(lockPackage, version); + } + files.set("package-lock.json", formatJson(lock)); + + writePreparedFiles(root, [...files.entries()]); + validateReleaseRepository(root, { version, requireChangelogs: true }); + return [...files.keys()].map((path) => relative(root, join(root, path))); +} + +export function createReleasePlan(input) { + const version = parseVersion(input.version).version; + if (input.eventName === "push") { + const publishProduction = + input.previousVersion !== undefined && compareVersions(version, input.previousVersion) !== 0; + if (publishProduction && compareVersions(version, input.previousVersion) <= 0) { + throw new Error(`Production version ${version} must be newer than ${input.previousVersion}`); + } + if (publishProduction && input.tagTarget && input.tagTarget !== input.sha) { + throw new Error(`Production tag v${version} points to ${input.tagTarget}, not ${input.sha}`); + } + const runNumber = input.runNumber ?? "0"; + const runAttempt = input.runAttempt ?? "0"; + return { + betaVersion: `${version}-beta.${runNumber}.${runAttempt}.${input.sha.slice(0, 7)}`, + buildRef: input.sha, + productionVersion: publishProduction ? version : "", + publishBeta: true, + publishProduction, + }; + } + + if (input.eventName === "workflow_dispatch" && input.operation === "retry-production") { + const expectedTag = `v${version}`; + if (input.releaseTag !== expectedTag) { + throw new Error(`Retry tag ${input.releaseTag ?? ""} does not match package version ${expectedTag}`); + } + if (!input.tagTarget) { + throw new Error(`Retry tag ${expectedTag} does not exist`); + } + if (!input.tagOnDefaultBranch) { + throw new Error(`Retry tag ${expectedTag} is not on the default branch`); + } + return { + betaVersion: "", + buildRef: input.tagTarget, + productionVersion: version, + publishBeta: false, + publishProduction: true, + }; + } + + throw new Error(`Unsupported release event or operation: ${input.eventName}/${input.operation ?? ""}`); +} + +export function decideImmutableWrite(localSha256, remoteSha256) { + if (!remoteSha256) return "create"; + if (localSha256 === remoteSha256) return "reuse"; + throw new Error(`Existing immutable object differs: local ${localSha256}, remote ${remoteSha256}`); +} + +export function promotionKeys(channel) { + if (channel === "stable") return ["stable", "latest.json"]; + if (channel === "beta") return ["beta", "beta.json"]; + throw new Error(`Unsupported release channel: ${channel}`); +} + +export function validateRollbackRequest(releaseTag, confirmation) { + if (!/^v0\.\d+\.\d+$/.test(releaseTag)) { + throw new Error(`Rollback target must be a plain stable release tag like v0.7.1: ${releaseTag}`); + } + const expectedConfirmation = `ROLLBACK ${releaseTag}`; + if (confirmation !== expectedConfirmation) { + throw new Error(`Rollback confirmation must be exactly: ${expectedConfirmation}`); + } + return { + manifestKey: "latest.json", + pointerKey: "stable", + version: releaseTag.slice(1), + }; +} + +function sha256File(path) { + const hash = createHash("sha256"); + hash.update(readFileSync(path)); + return hash.digest("hex"); +} + +function parseChecksums(content) { + const checksums = new Map(); + for (const line of content.trim().split("\n")) { + const match = line.match(/^([0-9a-f]{64}) ([^/]+)$/); + if (!match) throw new Error(`Invalid SHA256SUMS line: ${line}`); + if (checksums.has(match[2])) throw new Error(`Duplicate SHA256SUMS entry: ${match[2]}`); + checksums.set(match[2], match[1]); + } + return checksums; +} + +function readPackedPackageJson(tarballPath) { + const result = spawnSync("tar", ["-xOf", tarballPath, "package/package.json"], { + encoding: "utf8", + stdio: "pipe", + }); + if (result.status !== 0) { + throw new Error(`Unable to read package/package.json from ${tarballPath}: ${result.stderr.trim()}`); + } + try { + return JSON.parse(result.stdout); + } catch (error) { + throw new Error(`Invalid packed package.json in ${tarballPath}: ${String(error)}`); + } +} + +function validatePackedPackage(packageJson, artifact, version, baseUrl, artifactFiles) { + if (packageJson.name !== artifact.packageName) { + throw new Error(`${artifact.file} package name is ${packageJson.name}; expected ${artifact.packageName}`); + } + if (packageJson.version !== version) { + throw new Error(`${artifact.file} package version is ${packageJson.version}; expected ${version}`); + } + for (const forbiddenField of ["devDependencies", "overrides", "private"]) { + if (forbiddenField in packageJson) { + throw new Error(`${artifact.file} must not contain ${forbiddenField}`); + } + } + for (const field of ["dependencies", "optionalDependencies"]) { + for (const [name, range] of Object.entries(packageJson[field] ?? {})) { + const internalArtifact = RELEASE_ARTIFACTS.find((candidate) => candidate.sourceName === name); + if (!internalArtifact || internalArtifact.packageName === "prime-agent") continue; + const expectedUrl = `${baseUrl}/releases/v${version}/${artifactFiles.get(internalArtifact.packageName)}`; + if (range !== expectedUrl) { + throw new Error(`${artifact.file} ${field}.${name} is ${range}; expected ${expectedUrl}`); + } + } + } + const expectedInternalDependencies = + artifact.packageName === "prime-agent-core" + ? ["@earendil-works/pi-ai"] + : artifact.packageName === "prime-agent" + ? ["@earendil-works/pi-agent-core", "@earendil-works/pi-ai", "@earendil-works/pi-tui"] + : []; + for (const name of expectedInternalDependencies) { + const internalArtifact = RELEASE_ARTIFACTS.find((candidate) => candidate.sourceName === name); + const expectedUrl = `${baseUrl}/releases/v${version}/${artifactFiles.get(internalArtifact.packageName)}`; + if (packageJson.dependencies?.[name] !== expectedUrl) { + throw new Error(`${artifact.file} is missing the required internal dependency ${name}`); + } + } + if (artifact.packageName === "prime-agent") { + if (packageJson.bin?.["prime-agent"] !== "dist/bundle/cli.js") { + throw new Error(`${artifact.file} does not expose the prime-agent executable`); + } + if (packageJson.piConfig?.name !== "prime-agent" || packageJson.piConfig?.configDir !== ".prime/agent") { + throw new Error(`${artifact.file} has invalid Prime Agent package configuration`); + } + } +} + +export function verifyReleaseArtifacts(artifactsDir, options) { + const version = options.version; + if (options.channel !== "stable" && options.channel !== "beta") { + throw new Error(`Artifact channel must be stable or beta: ${options.channel}`); + } + if (options.channel === "stable") { + parseVersion(version, "artifact version"); + } else if (!BETA_VERSION_PATTERN.test(version)) { + throw new Error(`Artifact version must be a workflow beta version: ${version}`); + } + const baseUrl = options.baseUrl?.replace(/\/+$/, ""); + if (!baseUrl) throw new Error("Artifact base URL is required"); + const manifestName = options.channel === "stable" ? "latest.json" : "beta.json"; + const artifactFiles = new Map( + RELEASE_ARTIFACTS.map((artifact) => [artifact.packageName, `${artifact.filePrefix}-${version}.tgz`]), + ); + const expectedFiles = [ + ...artifactFiles.values(), + "SHA256SUMS", + options.channel, + manifestName, + ].sort(); + assertSameFiles(readdirSync(artifactsDir).sort(), expectedFiles); + + const checksums = parseChecksums(readFileSync(join(artifactsDir, "SHA256SUMS"), "utf8")); + if (checksums.size !== RELEASE_ARTIFACTS.length) { + throw new Error(`SHA256SUMS has ${checksums.size} entries; expected ${RELEASE_ARTIFACTS.length}`); + } + const tarballs = []; + for (const artifact of RELEASE_ARTIFACTS) { + const releaseArtifact = { ...artifact, file: artifactFiles.get(artifact.packageName) }; + const artifactPath = join(artifactsDir, releaseArtifact.file); + const actualSha256 = sha256File(artifactPath); + if (checksums.get(releaseArtifact.file) !== actualSha256) { + throw new Error(`${releaseArtifact.file} checksum mismatch`); + } + const packageJson = readPackedPackageJson(artifactPath); + validatePackedPackage(packageJson, releaseArtifact, version, baseUrl, artifactFiles); + tarballs.push({ file: releaseArtifact.file, package: releaseArtifact.packageName, sha256: actualSha256 }); + } + tarballs.sort((left, right) => left.file.localeCompare(right.file)); + + const pointer = readFileSync(join(artifactsDir, options.channel), "utf8"); + if (pointer !== `v${version}\n`) { + throw new Error(`${options.channel} pointer must contain v${version}`); + } + const manifest = readJson(join(artifactsDir, manifestName)); + const expectedManifest = { + version: `v${version}`, + package: "prime-agent", + tarball: `releases/v${version}/${artifactFiles.get("prime-agent")}`, + tarballs, + }; + if (!isDeepStrictEqual(manifest, expectedManifest)) { + throw new Error(`${manifestName} does not match the verified release artifacts`); + } + return expectedManifest; +} + +function assertSameFiles(actual, expected) { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`Artifact directory contains ${actual.join(", ")}; expected ${expected.join(", ")}`); + } +} diff --git a/scripts/lib/release-publication.mjs b/scripts/lib/release-publication.mjs new file mode 100644 index 0000000000..d74a14b1e8 --- /dev/null +++ b/scripts/lib/release-publication.mjs @@ -0,0 +1,109 @@ +import { createHash } from "node:crypto"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +import { compareVersions, decideImmutableWrite, promotionKeys } from "./release-lifecycle.mjs"; + +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} + +function requireRemoteMatch(store, key, local) { + const remote = store.read(key); + if (!remote) throw new Error(`Remote release object is missing after publication: ${key}`); + if (sha256(local) !== sha256(remote)) { + throw new Error(`Remote release object differs from the validated artifact: ${key}`); + } +} + +export function publishImmutableArtifacts(artifactsDir, version, store) { + let created = 0; + let reused = 0; + for (const file of readdirSync(artifactsDir).sort()) { + const path = join(artifactsDir, file); + const key = `releases/v${version}/${file}`; + const local = readFileSync(path); + const remote = store.read(key); + const decision = decideImmutableWrite(sha256(local), remote ? sha256(remote) : undefined); + if (decision === "create") { + const wasCreated = store.putImmutable(key, path, { + cacheControl: "public, max-age=31536000, immutable", + contentType: contentTypeFor(file), + }); + if (wasCreated) created += 1; + else reused += 1; + } else { + reused += 1; + } + requireRemoteMatch(store, key, local); + } + return { created, reused }; +} + +export function verifyRemoteRelease(artifactsDir, version, store, options = {}) { + let verified = 0; + const files = options.files ?? readdirSync(artifactsDir).sort(); + for (const file of files) { + const key = `releases/v${version}/${file}`; + requireRemoteMatch(store, key, readFileSync(join(artifactsDir, file))); + verified += 1; + } + return { verified }; +} + +export function validatePromotion(artifactsDir, channel, store, options = {}) { + const [pointerKey, manifestKey] = promotionKeys(channel); + const manifestPath = join(artifactsDir, manifestKey); + const manifest = readFileSync(manifestPath); + const candidateVersion = JSON.parse(manifest).version.replace(/^v/, ""); + const currentManifest = store.read(manifestKey); + if (channel === "stable" && currentManifest) { + const currentVersion = JSON.parse(currentManifest).version?.replace(/^v/, ""); + const comparison = compareVersions(candidateVersion, currentVersion); + if (comparison < 0 && !options.allowRegression) { + throw new Error(`Stable promotion to ${candidateVersion} would regress the current ${currentVersion} release`); + } + if (comparison === 0 && sha256(currentManifest) !== sha256(manifest)) { + throw new Error(`Stable manifest for ${candidateVersion} differs from the current manifest`); + } + } + return { manifestKey, pointerKey }; +} + +export function promoteChannel(artifactsDir, channel, store, options = {}) { + const { manifestKey, pointerKey } = validatePromotion(artifactsDir, channel, store, options); + const pointerPath = join(artifactsDir, pointerKey); + const manifestPath = join(artifactsDir, manifestKey); + const pointer = readFileSync(pointerPath); + const manifest = readFileSync(manifestPath); + + store.putMutable(pointerKey, pointerPath, { cacheControl: "no-cache", contentType: "text/plain" }); + requireRemoteMatch(store, pointerKey, pointer); + store.putMutable(manifestKey, manifestPath, { cacheControl: "no-cache", contentType: "application/json" }); + requireRemoteMatch(store, manifestKey, manifest); + return { manifestKey, pointerKey }; +} + +export function publishChannel(options) { + const immutable = publishImmutableArtifacts(options.artifactsDir, options.version, options.store); + options.mirror?.(); + verifyRemoteRelease(options.artifactsDir, options.version, options.store); + for (const installer of options.installers ?? []) { + const local = readFileSync(installer.path); + options.store.putMutable(installer.key, installer.path, { + cacheControl: "no-cache", + contentType: "text/x-shellscript", + }); + requireRemoteMatch(options.store, installer.key, local); + } + const promotion = promoteChannel(options.artifactsDir, options.channel, options.store, { + allowRegression: options.allowRegression, + }); + return { ...immutable, ...promotion }; +} + +function contentTypeFor(file) { + if (file.endsWith(".tgz")) return "application/gzip"; + if (file.endsWith(".json")) return "application/json"; + return "text/plain"; +} diff --git a/scripts/pack-prime-agent-release.mjs b/scripts/pack-prime-agent-release.mjs index 9460443d16..9babc389f6 100644 --- a/scripts/pack-prime-agent-release.mjs +++ b/scripts/pack-prime-agent-release.mjs @@ -1,7 +1,6 @@ #!/usr/bin/env node -// TODO: Remove this R2 tarball packer once prime-agent and its internal workspace -// dependencies are published through a real npm release flow. +// This is the non-publishing packer for the CI-authoritative R2 release lifecycle. import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs new file mode 100755 index 0000000000..cdf2bc52fb --- /dev/null +++ b/scripts/prepare-release.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { prepareRelease } from "./lib/release-lifecycle.mjs"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +function readArgs(args) { + let date; + let target; + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument === "--date") { + date = args[index + 1]; + if (!date) throw new Error("--date requires YYYY-MM-DD"); + index += 1; + continue; + } + if (argument === "--help" || argument === "-h") { + console.log("Usage: npm run release:prepare -- [--date YYYY-MM-DD]"); + process.exit(0); + } + if (target) throw new Error(`Unexpected argument: ${argument}`); + target = argument; + } + if (!target) throw new Error("Release target is required: patch, minor, or an explicit 0.x.y version"); + return { date, target }; +} + +function requireCleanWorktree() { + const result = spawnSync("git", ["status", "--porcelain"], { cwd: root, encoding: "utf8" }); + if (result.status !== 0) { + throw new Error(result.stderr.trim() || "Unable to inspect the Git worktree"); + } + if (result.stdout.trim()) { + throw new Error("Release preparation requires a clean worktree; commit or discard unrelated changes first"); + } +} + +try { + const { date, target } = readArgs(process.argv.slice(2)); + requireCleanWorktree(); + const changed = prepareRelease(root, target, { date }); + console.log("Prepared release metadata without staging, committing, tagging, pushing, or publishing:"); + for (const path of changed) console.log(`- ${path}`); + console.log("Review the diff, run `npm run release:dry-run`, and open a release-preparation pull request."); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/scripts/publish-release.mjs b/scripts/publish-release.mjs new file mode 100755 index 0000000000..f013efd160 --- /dev/null +++ b/scripts/publish-release.mjs @@ -0,0 +1,445 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { validateReleaseRepository, validateRollbackRequest, verifyReleaseArtifacts } from "./lib/release-lifecycle.mjs"; +import { + promoteChannel, + publishChannel, + validatePromotion, + verifyRemoteRelease, +} from "./lib/release-publication.mjs"; + +function parseArgs(args) { + const operation = args[0]; + if (!["beta", "production", "rollback"].includes(operation)) { + throw new Error("Usage: node scripts/publish-release.mjs [options]"); + } + const options = { operation }; + for (let index = 1; index < args.length; index += 1) { + const argument = args[index]; + if (!argument.startsWith("--")) throw new Error(`Unexpected argument: ${argument}`); + const value = args[index + 1]; + if (!value) throw new Error(`${argument} requires a value`); + options[argument.slice(2).replaceAll(/-([a-z])/g, (_match, letter) => letter.toUpperCase())] = value; + index += 1; + } + return options; +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd, + encoding: "utf8", + env: { ...process.env, AWS_PAGER: "" }, + maxBuffer: 10 * 1024 * 1024, + stdio: "pipe", + }); + if (result.status !== 0 && !options.allowFailure) { + throw new Error(result.stderr.trim() || result.stdout.trim() || `${command} ${args.join(" ")} failed`); + } + return result.status === 0 ? result.stdout.trim() : undefined; +} + +class AwsR2Store { + constructor(bucket, endpoint) { + if (!bucket || !endpoint) throw new Error("R2_BUCKET and R2_ENDPOINT_URL are required"); + this.bucket = bucket; + this.endpoint = endpoint; + this.temporaryDir = mkdtempSync(join(tmpdir(), "prime-agent-r2-")); + this.temporaryIndex = 0; + } + + dispose() { + rmSync(this.temporaryDir, { force: true, recursive: true }); + } + + read(key) { + const output = join(this.temporaryDir, `object-${this.temporaryIndex++}`); + const result = spawnSync( + "aws", + [ + "s3api", + "get-object", + "--bucket", + this.bucket, + "--key", + key, + "--endpoint-url", + this.endpoint, + output, + ], + { encoding: "utf8", env: { ...process.env, AWS_PAGER: "" }, stdio: "pipe" }, + ); + if (result.status !== 0) { + const error = `${result.stderr}\n${result.stdout}`; + if (/NoSuchKey|Not Found|404/i.test(error)) return undefined; + throw new Error(error.trim() || `Unable to read R2 object ${key}`); + } + return readFileSync(output); + } + + putImmutable(key, path, metadata) { + const result = spawnSync( + "aws", + [ + "s3api", + "put-object", + "--bucket", + this.bucket, + "--key", + key, + "--body", + path, + "--if-none-match", + "*", + "--content-type", + metadata.contentType, + "--cache-control", + metadata.cacheControl, + "--endpoint-url", + this.endpoint, + ], + { encoding: "utf8", env: { ...process.env, AWS_PAGER: "" }, stdio: "pipe" }, + ); + if (result.status === 0) return true; + const error = `${result.stderr}\n${result.stdout}`; + if (/PreconditionFailed|412/i.test(error)) return false; + throw new Error(error.trim() || `Unable to create immutable R2 object ${key}`); + } + + putMutable(key, path, metadata) { + run("aws", [ + "s3api", + "put-object", + "--bucket", + this.bucket, + "--key", + key, + "--body", + path, + "--content-type", + metadata.contentType, + "--cache-control", + metadata.cacheControl, + "--endpoint-url", + this.endpoint, + ]); + } +} + +class GitHubReleaseMirror { + constructor(repository) { + if (!repository) throw new Error("GITHUB_REPOSITORY is required"); + this.repository = repository; + } + + gh(args, options = {}) { + return run("gh", [...args, "--repo", this.repository], options); + } + + viewRelease(tag) { + const output = this.gh( + ["release", "view", tag, "--json", "assets,isDraft,isPrerelease,targetCommitish"], + { allowFailure: true }, + ); + return output ? JSON.parse(output) : undefined; + } + + readTagTarget(tag) { + return run( + "gh", + ["api", `repos/${this.repository}/git/ref/tags/${tag}`, "--jq", ".object.sha"], + { allowFailure: true }, + ); + } + + ensureTag(tag, buildRef) { + const target = this.readTagTarget(tag); + if (target && target !== buildRef) { + throw new Error(`Immutable tag ${tag} points to ${target}, not ${buildRef}`); + } + if (!target) { + run("gh", [ + "api", + "--method", + "POST", + `repos/${this.repository}/git/refs`, + "-f", + `ref=refs/tags/${tag}`, + "-f", + `sha=${buildRef}`, + ]); + } + } + + ensureProductionRelease(tag, buildRef, notesFile) { + this.ensureTag(tag, buildRef); + const release = this.viewRelease(tag); + if (!release) { + this.gh([ + "release", + "create", + tag, + "--verify-tag", + "--target", + buildRef, + "--title", + tag, + "--notes-file", + notesFile, + ]); + return; + } + if (release.targetCommitish !== buildRef || release.isDraft || release.isPrerelease) { + throw new Error(`Existing GitHub Release ${tag} does not match production commit ${buildRef}`); + } + } + + downloadAssets(tag, destination, assets) { + if (assets.length === 0) return; + this.gh(["release", "download", tag, "--dir", destination]); + } + + verifyAssetDirectory(artifactsDir, downloadedDir, expectedNames) { + const downloadedNames = readdirSync(downloadedDir).sort(); + if (JSON.stringify(downloadedNames) !== JSON.stringify(expectedNames)) { + throw new Error(`GitHub Release assets are ${downloadedNames.join(", ")}; expected ${expectedNames.join(", ")}`); + } + for (const name of expectedNames) { + if (!readFileSync(join(artifactsDir, name)).equals(readFileSync(join(downloadedDir, name)))) { + throw new Error(`Existing GitHub Release asset differs: ${name}`); + } + } + } + + ensureProductionAssets(tag, artifactsDir) { + const expectedNames = readdirSync(artifactsDir).sort(); + let release = this.viewRelease(tag); + if (!release) throw new Error(`GitHub Release ${tag} disappeared during publication`); + const remoteNames = release.assets.map((asset) => asset.name).sort(); + for (const remoteName of remoteNames) { + if (!expectedNames.includes(remoteName)) throw new Error(`Unexpected GitHub Release asset: ${remoteName}`); + } + const existingDir = mkdtempSync(join(tmpdir(), "prime-agent-gh-existing-")); + try { + this.downloadAssets(tag, existingDir, remoteNames); + this.verifyAssetDirectory(artifactsDir, existingDir, remoteNames); + } finally { + rmSync(existingDir, { force: true, recursive: true }); + } + for (const name of expectedNames) { + if (!remoteNames.includes(name)) this.gh(["release", "upload", tag, join(artifactsDir, name)]); + } + + release = this.viewRelease(tag); + const finalNames = release?.assets.map((asset) => asset.name).sort() ?? []; + const finalDir = mkdtempSync(join(tmpdir(), "prime-agent-gh-final-")); + try { + this.downloadAssets(tag, finalDir, finalNames); + this.verifyAssetDirectory(artifactsDir, finalDir, expectedNames); + } finally { + rmSync(finalDir, { force: true, recursive: true }); + } + } + + verifyExistingRelease(tag, artifactsDir) { + const release = this.viewRelease(tag); + if (!release || release.isDraft || release.isPrerelease) { + throw new Error(`Stable GitHub Release ${tag} does not exist`); + } + const tagTarget = this.readTagTarget(tag); + if (!tagTarget || release.targetCommitish !== tagTarget) { + throw new Error(`Stable GitHub Release ${tag} does not match its immutable tag`); + } + const names = release.assets.map((asset) => asset.name).sort(); + this.downloadAssets(tag, artifactsDir, names); + return tagTarget; + } + + replaceBetaRelease(buildRef, version, artifactsDir, defaultBranch) { + const betaTarget = this.readTagTarget("beta"); + if (betaTarget) { + run("gh", [ + "api", + "--method", + "PATCH", + `repos/${this.repository}/git/refs/tags/beta`, + "-F", + `sha=${buildRef}`, + "-F", + "force=true", + ]); + } else { + run("gh", [ + "api", + "--method", + "POST", + `repos/${this.repository}/git/refs`, + "-f", + "ref=refs/tags/beta", + "-f", + `sha=${buildRef}`, + ]); + } + + const notesDir = mkdtempSync(join(tmpdir(), "prime-agent-beta-notes-")); + const notesFile = join(notesDir, "notes.md"); + try { + writeFileSync(notesFile, `Automated beta build from \`${defaultBranch}\` (\`${buildRef}\`).\n`); + const release = this.viewRelease("beta"); + if (release) { + const assetIds = run( + "gh", + ["api", `repos/${this.repository}/releases/tags/beta`, "--jq", ".assets[].id"], + ) + .split("\n") + .filter(Boolean); + for (const assetId of assetIds) { + run("gh", ["api", "--method", "DELETE", `repos/${this.repository}/releases/assets/${assetId}`]); + } + this.gh([ + "release", + "edit", + "beta", + "--title", + `Beta (v${version})`, + "--target", + buildRef, + "--notes-file", + notesFile, + "--prerelease", + ]); + } else { + this.gh([ + "release", + "create", + "beta", + "--title", + `Beta (v${version})`, + "--target", + buildRef, + "--notes-file", + notesFile, + "--prerelease", + ]); + } + for (const name of readdirSync(artifactsDir).sort()) { + this.gh(["release", "upload", "beta", join(artifactsDir, name)]); + } + } finally { + rmSync(notesDir, { force: true, recursive: true }); + } + } + + latestDefaultBranchSha(defaultBranch) { + return run("gh", ["api", `repos/${this.repository}/commits/${defaultBranch}`, "--jq", ".sha"]); + } +} + +function requireOption(options, name) { + if (!options[name]) throw new Error(`--${name.replaceAll(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)} is required`); + return options[name]; +} + +function releaseFilesForRemoteVerification(artifactsDir) { + return readdirSync(artifactsDir) + .filter((file) => file === "SHA256SUMS" || file.endsWith(".tgz")) + .sort(); +} + +function installers(options) { + return [ + { key: "install.sh", path: resolve(requireOption(options, "stableInstaller")) }, + { key: "install-beta.sh", path: resolve(requireOption(options, "betaInstaller")) }, + ]; +} + +function publishProduction(options, store, github, baseUrl) { + const version = requireOption(options, "version"); + const buildRef = requireOption(options, "buildRef"); + const artifactsDir = resolve(requireOption(options, "artifactsDir")); + validateReleaseRepository(process.cwd(), { requireChangelogs: true, version }); + verifyReleaseArtifacts(artifactsDir, { baseUrl, channel: "stable", version }); + validatePromotion(artifactsDir, "stable", store); + const tag = `v${version}`; + github.ensureProductionRelease(tag, buildRef, resolve(requireOption(options, "notesFile"))); + const result = publishChannel({ + artifactsDir, + channel: "stable", + installers: installers(options), + mirror: () => github.ensureProductionAssets(tag, artifactsDir), + store, + version, + }); + console.log(`Published production ${tag}: ${result.created} immutable objects created, ${result.reused} reused.`); +} + +function publishBeta(options, store, github, baseUrl) { + const version = requireOption(options, "version"); + const buildRef = requireOption(options, "buildRef"); + const defaultBranch = requireOption(options, "defaultBranch"); + const artifactsDir = resolve(requireOption(options, "artifactsDir")); + if (github.latestDefaultBranchSha(defaultBranch) !== buildRef) { + console.log("A newer default-branch commit exists; leaving beta release state unchanged."); + return; + } + validateReleaseRepository(process.cwd(), { requireChangelogs: false, version: version.split("-", 1)[0] }); + verifyReleaseArtifacts(artifactsDir, { baseUrl, channel: "beta", version }); + const result = publishChannel({ + artifactsDir, + channel: "beta", + installers: installers(options), + mirror: () => github.replaceBetaRelease(buildRef, version, artifactsDir, defaultBranch), + store, + version, + }); + console.log(`Published beta v${version}: ${result.created} immutable objects created, ${result.reused} reused.`); +} + +function rollbackProduction(options, store, github, baseUrl) { + const releaseTag = requireOption(options, "releaseTag"); + validateRollbackRequest(releaseTag, requireOption(options, "confirmation")); + const artifactsDir = mkdtempSync(join(tmpdir(), "prime-agent-rollback-assets-")); + try { + const tagTarget = github.verifyExistingRelease(releaseTag, artifactsDir); + const defaultBranch = requireOption(options, "defaultBranch"); + if ( + run("git", ["merge-base", "--is-ancestor", tagTarget, `origin/${defaultBranch}`], { + allowFailure: true, + cwd: process.cwd(), + }) === undefined + ) { + throw new Error(`Rollback tag ${releaseTag} is not on the default branch`); + } + const version = releaseTag.slice(1); + verifyReleaseArtifacts(artifactsDir, { baseUrl, channel: "stable", version }); + verifyRemoteRelease(artifactsDir, version, store, { + files: releaseFilesForRemoteVerification(artifactsDir), + }); + promoteChannel(artifactsDir, "stable", store, { allowRegression: true }); + console.log(`Rolled back stable pointers to ${releaseTag}. Installed newer clients were not downgraded.`); + } finally { + rmSync(artifactsDir, { force: true, recursive: true }); + } +} + +let store; +try { + const options = parseArgs(process.argv.slice(2)); + const baseUrl = (process.env.R2_PUBLIC_BASE_URL || "").replace(/\/+$/, ""); + if (!baseUrl) throw new Error("R2_PUBLIC_BASE_URL is required"); + store = new AwsR2Store(process.env.R2_BUCKET, process.env.R2_ENDPOINT_URL); + const github = new GitHubReleaseMirror(process.env.GITHUB_REPOSITORY); + if (options.operation === "production") publishProduction(options, store, github, baseUrl); + else if (options.operation === "beta") publishBeta(options, store, github, baseUrl); + else rollbackProduction(options, store, github, baseUrl); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +} finally { + store?.dispose(); +} diff --git a/scripts/release-dry-run.mjs b/scripts/release-dry-run.mjs new file mode 100755 index 0000000000..b8ce974e7d --- /dev/null +++ b/scripts/release-dry-run.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { existsSync, rmSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { validateReleaseRepository, verifyReleaseArtifacts } from "./lib/release-lifecycle.mjs"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const defaultBaseUrl = "https://release.invalid"; + +function parseArgs(args) { + const parsed = { + artifactsDir: undefined, + baseUrl: defaultBaseUrl, + channel: "stable", + version: undefined, + }; + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument === "--help" || argument === "-h") { + console.log( + "Usage: npm run release:dry-run -- [--channel stable|beta] [--version version] [--base-url url] [--artifacts-dir path]", + ); + process.exit(0); + } + if (!["--artifacts-dir", "--base-url", "--channel", "--version"].includes(argument)) { + throw new Error(`Unknown argument: ${argument}`); + } + const key = { + "--artifacts-dir": "artifactsDir", + "--base-url": "baseUrl", + "--channel": "channel", + "--version": "version", + }[argument]; + const value = args[index + 1]; + if (!value) throw new Error(`${argument} requires a value`); + parsed[key] = value; + index += 1; + } + if (parsed.channel !== "stable" && parsed.channel !== "beta") { + throw new Error("--channel must be stable or beta"); + } + return parsed; +} + +function runPacker(options, outDir) { + const result = spawnSync( + process.execPath, + [ + join(root, "scripts/pack-prime-agent-release.mjs"), + "--channel", + options.channel, + "--version", + options.version, + "--base-url", + options.baseUrl, + "--out-dir", + outDir, + ], + { cwd: root, encoding: "utf8", stdio: "pipe" }, + ); + if (result.status !== 0) { + throw new Error(result.stderr.trim() || result.stdout.trim() || "Release packer failed"); + } + if (result.stdout) process.stdout.write(result.stdout); +} + +try { + const options = parseArgs(process.argv.slice(2)); + const repository = validateReleaseRepository(root, { + requireChangelogs: true, + version: options.channel === "beta" && options.version ? options.version.split("-", 1)[0] : options.version, + }); + options.version ??= repository.version; + const temporaryOutDir = join( + root, + "packages/coding-agent/release", + `dry-run-${process.pid}-${Date.now().toString(36)}`, + ); + const suppliedArtifactsDir = options.artifactsDir + ? resolve(root, options.artifactsDir) + : undefined; + const artifactsDir = suppliedArtifactsDir ?? join(temporaryOutDir, "artifacts"); + try { + if (!suppliedArtifactsDir) runPacker(options, temporaryOutDir); + if (!existsSync(artifactsDir)) throw new Error(`Artifact directory does not exist: ${artifactsDir}`); + const manifest = verifyReleaseArtifacts(artifactsDir, options); + console.log(`Validated ${manifest.version} ${options.channel} artifacts without publishing.`); + } finally { + if (!suppliedArtifactsDir) rmSync(temporaryOutDir, { force: true, recursive: true }); + } +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/scripts/release-lifecycle.test.mjs b/scripts/release-lifecycle.test.mjs new file mode 100644 index 0000000000..2040bb7d4f --- /dev/null +++ b/scripts/release-lifecycle.test.mjs @@ -0,0 +1,396 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + RELEASE_PACKAGE_DIRS, + bumpVersion, + createReleasePlan, + decideImmutableWrite, + prepareRelease, + promotionKeys, + validateReleaseRepository, + validateRollbackRequest, + verifyReleaseArtifacts, +} from "./lib/release-lifecycle.mjs"; + +const packageNames = { + agent: "@earendil-works/pi-agent-core", + ai: "@earendil-works/pi-ai", + "coding-agent": "@earendil-works/pi-coding-agent", + tui: "@earendil-works/pi-tui", +}; + +function writeJson(path, value) { + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +} + +function createRepositoryFixture(version = "0.7.1") { + const root = mkdtempSync(join(tmpdir(), "prime-agent-release-test-")); + mkdirSync(join(root, "packages"), { recursive: true }); + const rootPackage = { + name: "prime-agent", + private: true, + version, + dependencies: { "@earendil-works/pi-coding-agent": `^${version}` }, + }; + writeJson(join(root, "package.json"), rootPackage); + + const lockPackages = { + "": structuredClone(rootPackage), + }; + for (const packageDir of RELEASE_PACKAGE_DIRS) { + const shortName = packageDir.replace("packages/", ""); + const dependencies = {}; + if (shortName === "agent") dependencies[packageNames.ai] = `^${version}`; + if (shortName === "coding-agent") { + dependencies[packageNames.agent] = `^${version}`; + dependencies[packageNames.ai] = `^${version}`; + dependencies[packageNames.tui] = `^${version}`; + } + const packageJson = { name: packageNames[shortName], version, dependencies }; + mkdirSync(join(root, packageDir), { recursive: true }); + writeJson(join(root, packageDir, "package.json"), packageJson); + writeFileSync(join(root, packageDir, "CHANGELOG.md"), `# Changelog\n\n## [Unreleased]\n\n- Changed fixture.\n\n## [${version}] - 2026-08-07\n`); + lockPackages[packageDir] = structuredClone(packageJson); + } + + const privateDir = join(root, "packages", "private-example"); + mkdirSync(privateDir, { recursive: true }); + writeJson(join(privateDir, "package.json"), { name: "private-example", private: true, version: "9.9.9" }); + writeJson(join(root, "package-lock.json"), { + name: "prime-agent", + version, + lockfileVersion: 3, + packages: lockPackages, + }); + return root; +} + +function fileSha256(path) { + const hash = createHash("sha256"); + hash.update(readFileSync(path)); + return hash.digest("hex"); +} + +function createArtifactFixture(version = "0.7.2", channel = "stable", options = {}) { + const root = mkdtempSync(join(tmpdir(), "prime-agent-artifacts-test-")); + const artifactsDir = join(root, "artifacts"); + mkdirSync(artifactsDir, { recursive: true }); + const packageFixtures = [ + { file: `prime-agent-ai-${version}.tgz`, name: "prime-agent-ai" }, + { file: `prime-agent-core-${version}.tgz`, name: "prime-agent-core", dependencies: { [packageNames.ai]: `https://release.invalid/releases/v${version}/prime-agent-ai-${version}.tgz` } }, + { file: `prime-agent-tui-${version}.tgz`, name: "prime-agent-tui" }, + { + file: `prime-agent-${version}.tgz`, + name: "prime-agent", + bin: { "prime-agent": "dist/bundle/cli.js" }, + dependencies: options.omitCliInternalDependencies + ? {} + : { + [packageNames.agent]: `https://release.invalid/releases/v${version}/prime-agent-core-${version}.tgz`, + [packageNames.ai]: `https://release.invalid/releases/v${version}/prime-agent-ai-${version}.tgz`, + [packageNames.tui]: `https://release.invalid/releases/v${version}/prime-agent-tui-${version}.tgz`, + }, + piConfig: { configDir: ".prime/agent", name: "prime-agent" }, + }, + ]; + const tarballs = []; + for (const packageFixture of packageFixtures) { + const staging = join(root, `staging-${packageFixture.name}`, "package"); + mkdirSync(staging, { recursive: true }); + writeJson(join(staging, "package.json"), { ...packageFixture, file: undefined, version }); + const artifactPath = join(artifactsDir, packageFixture.file); + const tar = spawnSync("tar", ["-czf", artifactPath, "-C", dirname(staging), "package"], { encoding: "utf8" }); + assert.equal(tar.status, 0, tar.stderr); + tarballs.push({ package: packageFixture.name, file: packageFixture.file, sha256: fileSha256(artifactPath) }); + } + tarballs.sort((left, right) => left.file.localeCompare(right.file)); + writeFileSync(join(artifactsDir, "SHA256SUMS"), tarballs.map(({ file, sha256 }) => `${sha256} ${file}`).join("\n") + "\n"); + const pointerName = channel; + const manifestName = channel === "stable" ? "latest.json" : "beta.json"; + writeFileSync(join(artifactsDir, pointerName), `v${version}\n`); + writeJson(join(artifactsDir, manifestName), { + version: `v${version}`, + package: "prime-agent", + tarball: `releases/v${version}/prime-agent-${version}.tgz`, + tarballs, + }); + return { artifactsDir, root }; +} + +test("release versions support patch and minor but reject major releases", () => { + assert.equal(bumpVersion("0.7.1", "patch"), "0.7.2"); + assert.equal(bumpVersion("0.7.1", "minor"), "0.8.0"); + assert.equal(bumpVersion("0.7.1", "0.9.0"), "0.9.0"); + assert.throws(() => bumpVersion("0.7.1", "major"), /major releases are not supported/i); + assert.throws(() => bumpVersion("0.7.1", "1.0.0"), /major version must remain 0/i); + assert.throws(() => bumpVersion("0.7.1", "0.7.1"), /newer than/i); +}); + +test("repository validation enforces release-package lockstep but ignores private workspaces", () => { + const root = createRepositoryFixture(); + try { + assert.equal(validateReleaseRepository(root, { version: "0.7.1", requireChangelogs: true }).version, "0.7.1"); + writeJson(join(root, "packages/private-example/package.json"), { + name: "private-example", + private: true, + version: "10.0.0", + }); + assert.equal(validateReleaseRepository(root, { version: "0.7.1" }).version, "0.7.1"); + const agentPath = join(root, "packages/agent/package.json"); + const agent = JSON.parse(readFileSync(agentPath, "utf8")); + agent.version = "0.7.0"; + writeJson(agentPath, agent); + assert.throws(() => validateReleaseRepository(root, { version: "0.7.1" }), /packages\/agent.*0\.7\.0/i); + agent.version = "0.7.1"; + delete agent.dependencies[packageNames.ai]; + writeJson(agentPath, agent); + assert.throws(() => validateReleaseRepository(root, { version: "0.7.1" }), /dependencies.*pi-ai.*missing/i); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("release preparation updates only release metadata and preserves private workspaces", () => { + const root = createRepositoryFixture(); + try { + const privateBefore = readFileSync(join(root, "packages/private-example/package.json"), "utf8"); + const changed = prepareRelease(root, "patch", { date: "2026-08-08" }); + assert.equal(JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version, "0.7.2"); + assert.deepEqual( + changed.sort(), + [ + "package-lock.json", + "package.json", + ...RELEASE_PACKAGE_DIRS.flatMap((packageDir) => [ + `${packageDir}/CHANGELOG.md`, + `${packageDir}/package.json`, + ]), + ].sort(), + ); + assert.equal(readFileSync(join(root, "packages/private-example/package.json"), "utf8"), privateBefore); + for (const packageDir of RELEASE_PACKAGE_DIRS) { + const changelog = readFileSync(join(root, packageDir, "CHANGELOG.md"), "utf8"); + assert.match(changelog, /^## \[Unreleased\]\n\n## \[0\.7\.2\] - 2026-08-08\n\n- Changed fixture\./m); + } + validateReleaseRepository(root, { version: "0.7.2", requireChangelogs: true }); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("main pushes publish beta and only a strictly newer version publishes production", () => { + const changed = createReleasePlan({ + eventName: "push", + sha: "0123456789abcdef", + version: "0.7.2", + previousVersion: "0.7.1", + runNumber: "42", + runAttempt: "1", + tagTarget: undefined, + }); + assert.deepEqual(changed, { + betaVersion: "0.7.2-beta.42.1.0123456", + buildRef: "0123456789abcdef", + productionVersion: "0.7.2", + publishBeta: true, + publishProduction: true, + }); + + const unchanged = createReleasePlan({ + eventName: "push", + sha: "0123456789abcdef", + version: "0.7.2", + previousVersion: "0.7.2", + runNumber: "43", + runAttempt: "1", + }); + assert.equal(unchanged.publishProduction, false); + assert.equal(unchanged.publishBeta, true); + assert.throws( + () => createReleasePlan({ eventName: "push", sha: "new", version: "0.7.2", previousVersion: "0.7.3" }), + /newer than/i, + ); + assert.throws( + () => + createReleasePlan({ + eventName: "push", + sha: "new", + version: "0.7.2", + previousVersion: "0.7.1", + tagTarget: "other", + }), + /points to other, not new/i, + ); +}); + +test("manual production retry is bound to an existing tag and its immutable commit", () => { + assert.deepEqual( + createReleasePlan({ + eventName: "workflow_dispatch", + operation: "retry-production", + releaseTag: "v0.7.2", + version: "0.7.2", + tagTarget: "feedface", + tagOnDefaultBranch: true, + }), + { + betaVersion: "", + buildRef: "feedface", + productionVersion: "0.7.2", + publishBeta: false, + publishProduction: true, + }, + ); + assert.throws( + () => + createReleasePlan({ + eventName: "workflow_dispatch", + operation: "retry-production", + releaseTag: "v0.7.2", + version: "0.7.2", + }), + /tag v0\.7\.2 does not exist/i, + ); + assert.throws( + () => + createReleasePlan({ + eventName: "workflow_dispatch", + operation: "retry-production", + releaseTag: "v0.7.2", + version: "0.7.2", + tagTarget: "feedface", + tagOnDefaultBranch: false, + }), + /default branch/i, + ); +}); + +test("immutable objects are created once, reused when identical, and never overwritten", () => { + assert.equal(decideImmutableWrite("abc", undefined), "create"); + assert.equal(decideImmutableWrite("abc", "abc"), "reuse"); + assert.throws(() => decideImmutableWrite("abc", "def"), /immutable object differs/i); +}); + +test("channel manifests are commit markers written after legacy text pointers", () => { + assert.deepEqual(promotionKeys("stable"), ["stable", "latest.json"]); + assert.deepEqual(promotionKeys("beta"), ["beta", "beta.json"]); +}); + +test("rollback requires exact confirmation and remains pointer-only", () => { + assert.deepEqual(validateRollbackRequest("v0.7.1", "ROLLBACK v0.7.1"), { + manifestKey: "latest.json", + pointerKey: "stable", + version: "0.7.1", + }); + assert.throws(() => validateRollbackRequest("v0.7.1", "v0.7.1"), /ROLLBACK v0\.7\.1/); + assert.throws(() => validateRollbackRequest("beta", "ROLLBACK beta"), /plain stable release tag/i); +}); + +test("artifact verification checks branded manifests, internal URLs, and every checksum", () => { + const { artifactsDir, root } = createArtifactFixture(); + try { + const result = verifyReleaseArtifacts(artifactsDir, { + baseUrl: "https://release.invalid", + channel: "stable", + version: "0.7.2", + }); + assert.equal(result.tarballs.length, 4); + writeFileSync(join(artifactsDir, "prime-agent-0.7.2.tgz"), "corrupt"); + assert.throws( + () => + verifyReleaseArtifacts(artifactsDir, { + baseUrl: "https://release.invalid", + channel: "stable", + version: "0.7.2", + }), + /checksum mismatch/i, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("artifact verification accepts only workflow-shaped beta versions", () => { + const { artifactsDir, root } = createArtifactFixture("0.7.2-beta.42.1.0123456", "beta"); + try { + assert.equal( + verifyReleaseArtifacts(artifactsDir, { + baseUrl: "https://release.invalid", + channel: "beta", + version: "0.7.2-beta.42.1.0123456", + }).version, + "v0.7.2-beta.42.1.0123456", + ); + assert.throws( + () => + verifyReleaseArtifacts(artifactsDir, { + baseUrl: "https://release.invalid", + channel: "beta", + version: "0.7.2-custom", + }), + /workflow beta version/i, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("artifact verification rejects a packed CLI missing required internal R2 dependencies", () => { + const { artifactsDir, root } = createArtifactFixture("0.7.2", "stable", { + omitCliInternalDependencies: true, + }); + try { + assert.throws( + () => + verifyReleaseArtifacts(artifactsDir, { + baseUrl: "https://release.invalid", + channel: "stable", + version: "0.7.2", + }), + /missing the required internal dependency/i, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("dry-run CLI validates supplied artifacts without invoking publication tooling", () => { + const { artifactsDir, root } = createArtifactFixture("0.7.1", "stable"); + try { + const result = spawnSync( + process.execPath, + [ + fileURLToPath(new URL("release-dry-run.mjs", import.meta.url)), + "--channel", + "stable", + "--version", + "0.7.1", + "--base-url", + "https://release.invalid", + "--artifacts-dir", + artifactsDir, + ], + { + cwd: fileURLToPath(new URL("..", import.meta.url)), + encoding: "utf8", + env: { + ...process.env, + AWS_ACCESS_KEY_ID: "must-not-be-used", + GH_TOKEN: "must-not-be-used", + R2_BUCKET: "must-not-be-used", + }, + }, + ); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Validated v0\.7\.1 stable artifacts without publishing/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/release-publication.test.mjs b/scripts/release-publication.test.mjs new file mode 100644 index 0000000000..87af2bfb95 --- /dev/null +++ b/scripts/release-publication.test.mjs @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + promoteChannel, + publishChannel, + publishImmutableArtifacts, + verifyRemoteRelease, +} from "./lib/release-publication.mjs"; + +class MemoryStore { + constructor(initial = {}) { + this.events = []; + this.objects = new Map(Object.entries(initial).map(([key, value]) => [key, Buffer.from(value)])); + } + + read(key) { + this.events.push(`read:${key}`); + return this.objects.get(key); + } + + putImmutable(key, path) { + this.events.push(`immutable:${key}`); + if (this.objects.has(key)) return false; + this.objects.set(key, readFileSync(path)); + return true; + } + + putMutable(key, path) { + this.events.push(`mutable:${key}`); + this.objects.set(key, readFileSync(path)); + } +} + +function createPublicationFixture(version = "0.7.2", channel = "stable") { + const root = mkdtempSync(join(tmpdir(), "prime-agent-publication-test-")); + const artifactsDir = join(root, "artifacts"); + mkdirSync(artifactsDir); + writeFileSync(join(artifactsDir, `prime-agent-${version}.tgz`), "cli"); + writeFileSync(join(artifactsDir, "SHA256SUMS"), "checksums"); + writeFileSync(join(artifactsDir, channel), `v${version}\n`); + const manifestName = channel === "stable" ? "latest.json" : "beta.json"; + writeFileSync( + join(artifactsDir, manifestName), + `${JSON.stringify({ + version: `v${version}`, + package: "prime-agent", + tarball: `releases/v${version}/prime-agent-${version}.tgz`, + tarballs: [], + })}\n`, + ); + return { artifactsDir, manifestName, root }; +} + +test("immutable publication creates missing objects, reuses identical objects, and rejects drift", () => { + const { artifactsDir, root } = createPublicationFixture(); + const store = new MemoryStore(); + try { + const first = publishImmutableArtifacts(artifactsDir, "0.7.2", store); + assert.equal(first.created, 4); + assert.equal(first.reused, 0); + const second = publishImmutableArtifacts(artifactsDir, "0.7.2", store); + assert.equal(second.created, 0); + assert.equal(second.reused, 4); + writeFileSync(join(artifactsDir, "prime-agent-0.7.2.tgz"), "different"); + assert.throws(() => publishImmutableArtifacts(artifactsDir, "0.7.2", store), /immutable object differs/i); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("immutable publication rejects a conflicting object created by a concurrent publisher", () => { + const { artifactsDir, root } = createPublicationFixture(); + const store = new MemoryStore(); + store.putImmutable = (key) => { + store.events.push(`immutable-race:${key}`); + store.objects.set(key, Buffer.from("conflicting concurrent bytes")); + return false; + }; + try { + assert.throws(() => publishImmutableArtifacts(artifactsDir, "0.7.2", store), /remote release object differs/i); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("channel transaction completes the mirror before pointer and manifest promotion", () => { + const { artifactsDir, root } = createPublicationFixture(); + const store = new MemoryStore(); + try { + publishChannel({ + artifactsDir, + channel: "stable", + mirror: () => store.events.push("mirror"), + store, + version: "0.7.2", + }); + assert.ok(store.events.indexOf("mirror") > store.events.findIndex((event) => event.startsWith("immutable:"))); + assert.ok(store.events.indexOf("mirror") < store.events.indexOf("mutable:stable")); + assert.ok(store.events.indexOf("mutable:stable") < store.events.indexOf("mutable:latest.json")); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("stable promotion is monotonic unless an explicit rollback allows regression", () => { + const { artifactsDir, root } = createPublicationFixture(); + const store = new MemoryStore({ + "latest.json": `${JSON.stringify({ version: "v0.7.3" })}\n`, + stable: "v0.7.3\n", + }); + try { + assert.throws(() => promoteChannel(artifactsDir, "stable", store), /would regress.*0\.7\.3/i); + promoteChannel(artifactsDir, "stable", store, { allowRegression: true }); + assert.equal(store.objects.get("stable").toString(), "v0.7.2\n"); + assert.match(store.objects.get("latest.json").toString(), /v0\.7\.2/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("remote verification proves every saved release object before rollback", () => { + const { artifactsDir, root } = createPublicationFixture(); + const store = new MemoryStore(); + try { + publishImmutableArtifacts(artifactsDir, "0.7.2", store); + assert.equal(verifyRemoteRelease(artifactsDir, "0.7.2", store).verified, 4); + store.objects.set("releases/v0.7.2/SHA256SUMS", Buffer.from("drift")); + assert.throws(() => verifyRemoteRelease(artifactsDir, "0.7.2", store), /remote release object differs/i); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/release-workflow-contract.test.mjs b/scripts/release-workflow-contract.test.mjs new file mode 100644 index 0000000000..7391cfe1c9 --- /dev/null +++ b/scripts/release-workflow-contract.test.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); +const dryRun = readFileSync(new URL("release-dry-run.mjs", import.meta.url), "utf8"); +const legacyRelease = readFileSync(new URL("release.mjs", import.meta.url), "utf8"); +const legacySync = readFileSync(new URL("sync-versions.js", import.meta.url), "utf8"); +const releaseWorkflow = readFileSync(new URL("../.github/workflows/build-binaries.yml", import.meta.url), "utf8"); +const rollbackWorkflow = readFileSync(new URL("../.github/workflows/rollback-release.yml", import.meta.url), "utf8"); + +test("legacy local release and publish commands are non-mutating tombstones", () => { + for (const scriptName of [ + "publish", + "publish:dry", + "release:major", + "release:minor", + "release:patch", + "version:major", + "version:minor", + "version:patch", + "version:set", + ]) { + assert.match(packageJson.scripts[scriptName], /^node scripts\/release\.mjs/); + } + assert.doesNotMatch(legacyRelease, /execSync|spawnSync|writeFile|npm publish|git (?:commit|tag|push)/); + assert.match(legacyRelease, /release:prepare/); + assert.match(legacyRelease, /release:dry-run/); + assert.doesNotMatch(legacySync, /writeFile|npm version|npm install/); + assert.doesNotMatch(dryRun, /npm publish|git (?:commit|tag|push)|\bgh\b|\baws\b/); +}); + +test("release workflow publishes from main or an immutable retry tag, never a tag push", () => { + assert.doesNotMatch(releaseWorkflow, /^\s+tags:/m); + assert.match(releaseWorkflow, /operation:/); + assert.match(releaseWorkflow, /retry-production/); + assert.match(releaseWorkflow, /node scripts\/resolve-release-context\.mjs/); + assert.doesNotMatch(releaseWorkflow, /Production release tag to create or update/); +}); + +test("production publication compares immutable assets and commits latest.json last", () => { + assert.match(releaseWorkflow, /node scripts\/publish-release\.mjs production/); + assert.doesNotMatch(releaseWorkflow, /gh release upload[^\n]*--clobber/); + const productionStep = releaseWorkflow.indexOf("node scripts/publish-release.mjs production"); + const betaStep = releaseWorkflow.indexOf("node scripts/publish-release.mjs beta"); + assert.ok(productionStep > -1); + assert.ok(betaStep > productionStep); +}); + +test("rollback is a separately protected pointer-only workflow", () => { + assert.match(rollbackWorkflow, /^name: Rollback Prime Agent stable channel$/m); + assert.match(rollbackWorkflow, /environment: production/); + assert.match(rollbackWorkflow, /ROLLBACK vX\.Y\.Z/); + assert.match(rollbackWorkflow, /node scripts\/publish-release\.mjs rollback/); + assert.doesNotMatch(rollbackWorkflow, /gh release (?:create|edit|upload)|git (?:tag|push)|npm publish/); +}); diff --git a/scripts/release.mjs b/scripts/release.mjs index 73ae06f9dc..478fa49704 100755 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -1,196 +1,9 @@ #!/usr/bin/env node -/** - * Release script for pi-mono - * - * Usage: - * node scripts/release.mjs - * node scripts/release.mjs - * - * Steps: - * 1. Check for uncommitted changes - * 2. Bump version via npm run version:xxx or set an explicit version - * 3. Update CHANGELOG.md files: [Unreleased] -> [version] - date - * 4. Commit and tag - * 5. Publish to npm - * 6. Add new [Unreleased] section to changelogs - * 7. Commit - */ -import { execSync } from "child_process"; -import { readFileSync, writeFileSync, readdirSync, existsSync } from "fs"; -import { join } from "path"; +const command = process.env.npm_lifecycle_event || "legacy release command"; -const RELEASE_TARGET = process.argv[2]; -const BUMP_TYPES = new Set(["major", "minor", "patch"]); -const SEMVER_RE = /^\d+\.\d+\.\d+$/; - -if (!RELEASE_TARGET || (!BUMP_TYPES.has(RELEASE_TARGET) && !SEMVER_RE.test(RELEASE_TARGET))) { - console.error("Usage: node scripts/release.mjs "); - process.exit(1); -} - -function run(cmd, options = {}) { - console.log(`$ ${cmd}`); - try { - return execSync(cmd, { encoding: "utf-8", stdio: options.silent ? "pipe" : "inherit", ...options }); - } catch (e) { - if (!options.ignoreError) { - console.error(`Command failed: ${cmd}`); - process.exit(1); - } - return null; - } -} - -function getVersion() { - const pkg = JSON.parse(readFileSync("packages/ai/package.json", "utf-8")); - return pkg.version; -} - -function compareVersions(a, b) { - const aParts = a.split(".").map(Number); - const bParts = b.split(".").map(Number); - - for (let i = 0; i < 3; i++) { - const diff = (aParts[i] || 0) - (bParts[i] || 0); - if (diff !== 0) { - return diff; - } - } - - return 0; -} - -function shellQuote(value) { - return `'${value.replace(/'/g, `'\\''`)}'`; -} - -function stageChangedFiles() { - const output = run("git ls-files -m -o -d --exclude-standard", { silent: true }); - const paths = [...new Set((output || "").split("\n").map((line) => line.trim()).filter(Boolean))]; - if (paths.length === 0) { - return; - } - - run(`git add -- ${paths.map(shellQuote).join(" ")}`); -} - -function bumpOrSetVersion(target) { - const currentVersion = getVersion(); - - if (BUMP_TYPES.has(target)) { - console.log(`Bumping version (${target})...`); - run(`npm run version:${target}`); - return getVersion(); - } - - if (compareVersions(target, currentVersion) <= 0) { - console.error(`Error: explicit version ${target} must be greater than current version ${currentVersion}.`); - process.exit(1); - } - - console.log(`Setting explicit version (${target})...`); - run( - `npm version ${target} -ws --no-git-tag-version && node scripts/sync-versions.js && npx shx rm -rf node_modules packages/*/node_modules package-lock.json && npm install`, - ); - return getVersion(); -} - -function getChangelogs() { - const packagesDir = "packages"; - const packages = readdirSync(packagesDir); - return packages - .map((pkg) => join(packagesDir, pkg, "CHANGELOG.md")) - .filter((path) => existsSync(path)); -} - -function updateChangelogsForRelease(version) { - const date = new Date().toISOString().split("T")[0]; - const changelogs = getChangelogs(); - - for (const changelog of changelogs) { - const content = readFileSync(changelog, "utf-8"); - - if (!content.includes("## [Unreleased]")) { - console.log(` Skipping ${changelog}: no [Unreleased] section`); - continue; - } - - const updated = content.replace( - "## [Unreleased]", - `## [${version}] - ${date}` - ); - writeFileSync(changelog, updated); - console.log(` Updated ${changelog}`); - } -} - -function addUnreleasedSection() { - const changelogs = getChangelogs(); - const unreleasedSection = "## [Unreleased]\n\n"; - - for (const changelog of changelogs) { - const content = readFileSync(changelog, "utf-8"); - - // Insert after "# Changelog\n\n" - const updated = content.replace( - /^(# Changelog\n\n)/, - `$1${unreleasedSection}` - ); - writeFileSync(changelog, updated); - console.log(` Added [Unreleased] to ${changelog}`); - } -} - -// Main flow -console.log("\n=== Release Script ===\n"); - -// 1. Check for uncommitted changes -console.log("Checking for uncommitted changes..."); -const status = run("git status --porcelain", { silent: true }); -if (status && status.trim()) { - console.error("Error: Uncommitted changes detected. Commit or stash first."); - console.error(status); - process.exit(1); -} -console.log(" Working directory clean\n"); - -// 2. Bump or set version -const version = bumpOrSetVersion(RELEASE_TARGET); -console.log(` New version: ${version}\n`); - -// 3. Update changelogs -console.log("Updating CHANGELOG.md files..."); -updateChangelogsForRelease(version); -console.log(); - -// 4. Commit and tag -console.log("Committing and tagging..."); -stageChangedFiles(); -run(`git commit -m "Release v${version}"`); -run(`git tag v${version}`); -console.log(); - -// 5. Publish -console.log("Publishing to npm..."); -run("npm run publish"); -console.log(); - -// 6. Add new [Unreleased] sections -console.log("Adding [Unreleased] sections for next cycle..."); -addUnreleasedSection(); -console.log(); - -// 7. Commit -console.log("Committing changelog updates..."); -stageChangedFiles(); -run(`git commit -m "Add [Unreleased] section for next cycle"`); -console.log(); - -// 8. Push -console.log("Pushing to remote..."); -run("git push origin main"); -run(`git push origin v${version}`); -console.log(); - -console.log(`=== Released v${version} ===`); +console.error(`${command} is retired and cannot mutate or publish a Prime Agent release.`); +console.error("Use `npm run release:prepare -- patch|minor|0.x.y` to prepare version metadata."); +console.error("Use `npm run release:dry-run` to validate a built release candidate without publishing."); +console.error("Production releases and retries are owned by the protected Release Prime Agent workflow."); +process.exit(1); diff --git a/scripts/render-release-installers.mjs b/scripts/render-release-installers.mjs new file mode 100755 index 0000000000..ab8a5ea992 --- /dev/null +++ b/scripts/render-release-installers.mjs @@ -0,0 +1,19 @@ +#!/usr/bin/env node + +import { readFileSync, writeFileSync } from "node:fs"; + +const [baseUrlArgument, stablePath, betaPath] = process.argv.slice(2); +const baseUrl = baseUrlArgument?.replace(/\/+$/, ""); +if (!baseUrl || !stablePath || !betaPath) { + console.error("Usage: node scripts/render-release-installers.mjs "); + process.exit(1); +} + +const installer = readFileSync("install.sh", "utf8"); +const renderInstaller = (channel) => + installer + .replaceAll("__PRIME_AGENT_DOWNLOAD_BASE_URL__", baseUrl) + .replaceAll("__PRIME_AGENT_DEFAULT_RELEASE_CHANNEL__", channel); + +writeFileSync(stablePath, renderInstaller("stable")); +writeFileSync(betaPath, renderInstaller("beta")); diff --git a/scripts/resolve-release-context.mjs b/scripts/resolve-release-context.mjs new file mode 100755 index 0000000000..c424aaad78 --- /dev/null +++ b/scripts/resolve-release-context.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node + +import { appendFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +import { createReleasePlan, validateReleaseRepository } from "./lib/release-lifecycle.mjs"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +function git(args, options = {}) { + const result = spawnSync("git", args, { cwd: root, encoding: "utf8", stdio: "pipe" }); + if (result.status !== 0) { + if (options.allowFailure) return undefined; + throw new Error(result.stderr.trim() || `git ${args.join(" ")} failed`); + } + return result.stdout.trim(); +} + +function versionAt(ref) { + const packageJson = git(["show", `${ref}:package.json`]); + return JSON.parse(packageJson).version; +} + +function resolveTagTarget(tag) { + return git(["rev-parse", `refs/tags/${tag}^{commit}`], { allowFailure: true }); +} + +function planPush() { + const repository = validateReleaseRepository(root); + const beforeSha = process.env.BEFORE_SHA; + const previousVersion = + beforeSha && !/^0+$/.test(beforeSha) && git(["cat-file", "-e", `${beforeSha}:package.json`], { allowFailure: true }) !== undefined + ? versionAt(beforeSha) + : undefined; + const tagTarget = resolveTagTarget(`v${repository.version}`); + const plan = createReleasePlan({ + eventName: "push", + previousVersion, + runAttempt: process.env.RUN_ATTEMPT, + runNumber: process.env.RUN_NUMBER, + sha: process.env.GITHUB_SHA_VALUE, + tagTarget, + version: repository.version, + }); + if (plan.publishProduction) { + validateReleaseRepository(root, { requireChangelogs: true, version: plan.productionVersion }); + } + return plan; +} + +function planRetry() { + const defaultBranch = process.env.DEFAULT_BRANCH; + if (process.env.REF_NAME !== defaultBranch) { + throw new Error(`Manual retries must run from the default branch (${defaultBranch})`); + } + const releaseTag = process.env.INPUT_RELEASE_TAG; + if (!/^v0\.\d+\.\d+$/.test(releaseTag ?? "")) { + throw new Error(`Retry target must be an existing plain release tag like v0.7.1: ${releaseTag ?? ""}`); + } + const tagTarget = resolveTagTarget(releaseTag); + const tagOnDefaultBranch = + tagTarget !== undefined && + git(["merge-base", "--is-ancestor", tagTarget, `origin/${defaultBranch}`], { allowFailure: true }) !== undefined; + const version = tagTarget ? versionAt(tagTarget) : releaseTag.slice(1); + return createReleasePlan({ + eventName: "workflow_dispatch", + operation: process.env.INPUT_OPERATION, + releaseTag, + tagOnDefaultBranch, + tagTarget, + version, + }); +} + +function writeOutputs(plan) { + const outputPath = process.env.GITHUB_OUTPUT; + if (!outputPath) throw new Error("GITHUB_OUTPUT is required"); + const outputs = { + beta_version: plan.betaVersion, + build_ref: plan.buildRef, + production_version: plan.productionVersion, + publish_beta: String(plan.publishBeta), + publish_production: String(plan.publishProduction), + }; + appendFileSync(outputPath, Object.entries(outputs).map(([name, value]) => `${name}=${value}`).join("\n") + "\n"); + console.log(`Build ref: ${plan.buildRef}`); + console.log(`Production: ${plan.publishProduction} ${plan.productionVersion ? `v${plan.productionVersion}` : ""}`); + console.log(`Beta: ${plan.publishBeta} ${plan.betaVersion ? `v${plan.betaVersion}` : ""}`); +} + +try { + const eventName = process.env.EVENT_NAME; + const plan = eventName === "push" ? planPush() : eventName === "workflow_dispatch" ? planRetry() : undefined; + if (!plan) throw new Error(`Unsupported release event: ${eventName ?? ""}`); + writeOutputs(plan); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/scripts/sync-versions.js b/scripts/sync-versions.js index 136ed088ad..d8bfc3ca0e 100644 --- a/scripts/sync-versions.js +++ b/scripts/sync-versions.js @@ -1,96 +1,5 @@ #!/usr/bin/env node -/** - * Syncs all workspace package dependency versions to match their current versions. - * This ensures lockstep versioning across the monorepo. - */ - -import { readFileSync, writeFileSync, readdirSync } from 'fs'; -import { join } from 'path'; - -const packagesDir = join(process.cwd(), 'packages'); -const packageDirs = readdirSync(packagesDir, { withFileTypes: true }) - .filter(dirent => dirent.isDirectory()) - .map(dirent => dirent.name); - -// Read all package.json files and build version map -const packages = {}; -const versionMap = {}; - -for (const dir of packageDirs) { - const pkgPath = join(packagesDir, dir, 'package.json'); - try { - const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); - packages[dir] = { path: pkgPath, data: pkg }; - versionMap[pkg.name] = pkg.version; - } catch (e) { - console.error(`Failed to read ${pkgPath}:`, e.message); - } -} - -console.log('Current versions:'); -for (const [name, version] of Object.entries(versionMap).sort()) { - console.log(` ${name}: ${version}`); -} - -// Verify all versions are the same (lockstep) -const versions = new Set(Object.values(versionMap)); -if (versions.size > 1) { - console.error('\n❌ ERROR: Not all packages have the same version!'); - console.error('Expected lockstep versioning. Run one of:'); - console.error(' npm run version:patch'); - console.error(' npm run version:minor'); - console.error(' npm run version:major'); - process.exit(1); -} - -console.log('\n✅ All packages at same version (lockstep)'); - -// Update all inter-package dependencies -let totalUpdates = 0; -for (const [dir, pkg] of Object.entries(packages)) { - let updated = false; - - // Check dependencies - if (pkg.data.dependencies) { - for (const [depName, currentVersion] of Object.entries(pkg.data.dependencies)) { - if (versionMap[depName]) { - const newVersion = `^${versionMap[depName]}`; - if (currentVersion !== newVersion) { - console.log(`\n${pkg.data.name}:`); - console.log(` ${depName}: ${currentVersion} → ${newVersion}`); - pkg.data.dependencies[depName] = newVersion; - updated = true; - totalUpdates++; - } - } - } - } - - // Check devDependencies - if (pkg.data.devDependencies) { - for (const [depName, currentVersion] of Object.entries(pkg.data.devDependencies)) { - if (versionMap[depName]) { - const newVersion = `^${versionMap[depName]}`; - if (currentVersion !== newVersion) { - console.log(`\n${pkg.data.name}:`); - console.log(` ${depName}: ${currentVersion} → ${newVersion} (devDependencies)`); - pkg.data.devDependencies[depName] = newVersion; - updated = true; - totalUpdates++; - } - } - } - } - - // Write if updated - if (updated) { - writeFileSync(pkg.path, JSON.stringify(pkg.data, null, '\t') + '\n'); - } -} - -if (totalUpdates === 0) { - console.log('\nAll inter-package dependencies already in sync.'); -} else { - console.log(`\n✅ Updated ${totalUpdates} dependency version(s)`); -} +console.error("scripts/sync-versions.js is retired because it included private and example workspaces in release lockstep."); +console.error("Use `npm run release:prepare -- patch|minor|0.x.y` for the four R2-packaged workspaces."); +process.exit(1); From 913931bb1237e702aa67edd97be07b75ee7a8693 Mon Sep 17 00:00:00 2001 From: fettpl <38704082+fettpl@users.noreply.github.com> Date: Sat, 8 Aug 2026 20:34:09 +0200 Subject: [PATCH 07/13] fix(release): harden retry and recovery paths (fixes #934) --- .github/workflows/build-binaries.yml | 58 ++++++++++++----- RELEASING.md | 8 +-- .../2026-08-08-release-authority-design.md | 8 +-- scripts/lib/release-lifecycle.mjs | 45 +++++++++++-- scripts/lib/release-publication.mjs | 31 ++++++--- scripts/pack-prime-agent-release.mjs | 3 +- scripts/publish-release.mjs | 8 ++- scripts/release-dry-run.mjs | 12 +++- scripts/release-lifecycle.test.mjs | 63 +++++++++++++++++-- scripts/release-publication.test.mjs | 27 ++++++++ scripts/release-workflow-contract.test.mjs | 11 +++- 11 files changed, 222 insertions(+), 52 deletions(-) diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index fd9721c98f..a08a49f331 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -71,16 +71,25 @@ jobs: env: BETA_VERSION: ${{ needs.release-context.outputs.beta_version }} BUILD_REF: ${{ needs.release-context.outputs.build_ref }} + PRIME_AGENT_RELEASE_SOURCE_ROOT: ${{ github.workspace }}/release-source PRODUCTION_VERSION: ${{ needs.release-context.outputs.production_version }} PUBLISH_BETA: ${{ needs.release-context.outputs.publish_beta }} PUBLISH_PRODUCTION: ${{ needs.release-context.outputs.publish_production }} steps: - - name: Checkout validated release commit + - name: Checkout protected release tooling + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.workflow_sha }} + path: release-tooling + persist-credentials: false + + - name: Checkout validated release source uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 fetch-tags: true ref: ${{ env.BUILD_REF }} + path: release-source persist-credentials: false - name: Setup Node.js @@ -90,24 +99,29 @@ jobs: registry-url: 'https://registry.npmjs.org' - name: Install dependencies + working-directory: release-source run: npm ci - name: Build + working-directory: release-source run: npm run build - name: Check + working-directory: release-source run: npm run check - name: Test release lifecycle - run: npm run release:test + working-directory: release-source + run: node --test ../release-tooling/scripts/release-lifecycle.test.mjs ../release-tooling/scripts/release-publication.test.mjs ../release-tooling/scripts/release-workflow-contract.test.mjs - name: Pack production release if: env.PUBLISH_PRODUCTION == 'true' env: PRIME_AGENT_DOWNLOAD_BASE_URL: ${{ vars.R2_PUBLIC_BASE_URL }} + working-directory: release-source run: | test -n "$PRIME_AGENT_DOWNLOAD_BASE_URL" - npm run release:pack -- \ + node ../release-tooling/scripts/pack-prime-agent-release.mjs \ --channel stable \ --version "$PRODUCTION_VERSION" \ --base-url "$PRIME_AGENT_DOWNLOAD_BASE_URL" \ @@ -117,8 +131,9 @@ jobs: if: env.PUBLISH_PRODUCTION == 'true' env: PRIME_AGENT_DOWNLOAD_BASE_URL: ${{ vars.R2_PUBLIC_BASE_URL }} + working-directory: release-source run: | - npm run release:dry-run -- \ + node ../release-tooling/scripts/release-dry-run.mjs \ --channel stable \ --version "$PRODUCTION_VERSION" \ --base-url "$PRIME_AGENT_DOWNLOAD_BASE_URL" \ @@ -128,9 +143,10 @@ jobs: if: env.PUBLISH_BETA == 'true' env: PRIME_AGENT_DOWNLOAD_BASE_URL: ${{ vars.R2_PUBLIC_BASE_URL }} + working-directory: release-source run: | test -n "$PRIME_AGENT_DOWNLOAD_BASE_URL" - npm run release:pack -- \ + node ../release-tooling/scripts/pack-prime-agent-release.mjs \ --channel beta \ --version "$BETA_VERSION" \ --base-url "$PRIME_AGENT_DOWNLOAD_BASE_URL" \ @@ -140,8 +156,9 @@ jobs: if: env.PUBLISH_BETA == 'true' env: PRIME_AGENT_DOWNLOAD_BASE_URL: ${{ vars.R2_PUBLIC_BASE_URL }} + working-directory: release-source run: | - npm run release:dry-run -- \ + node ../release-tooling/scripts/release-dry-run.mjs \ --channel beta \ --version "$BETA_VERSION" \ --base-url "$PRIME_AGENT_DOWNLOAD_BASE_URL" \ @@ -152,7 +169,7 @@ jobs: uses: actions/upload-artifact@v7 with: name: prime-agent-production - path: packages/coding-agent/release/production/artifacts/* + path: release-source/packages/coding-agent/release/production/artifacts/* if-no-files-found: error - name: Upload beta artifacts @@ -160,7 +177,7 @@ jobs: uses: actions/upload-artifact@v7 with: name: prime-agent-beta - path: packages/coding-agent/release/beta/artifacts/* + path: release-source/packages/coding-agent/release/beta/artifacts/* if-no-files-found: error publish: @@ -175,6 +192,7 @@ jobs: BETA_VERSION: ${{ needs.release-context.outputs.beta_version }} BUILD_REF: ${{ needs.release-context.outputs.build_ref }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + PRIME_AGENT_RELEASE_SOURCE_ROOT: ${{ github.workspace }}/release-source PRODUCTION_VERSION: ${{ needs.release-context.outputs.production_version }} PUBLISH_BETA: ${{ needs.release-context.outputs.publish_beta }} PUBLISH_PRODUCTION: ${{ needs.release-context.outputs.publish_production }} @@ -182,12 +200,20 @@ jobs: R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} R2_PUBLIC_BASE_URL: ${{ vars.R2_PUBLIC_BASE_URL }} steps: - - name: Checkout validated release commit + - name: Checkout protected release tooling + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.workflow_sha }} + path: release-tooling + persist-credentials: false + + - name: Checkout validated release source uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 fetch-tags: true ref: ${{ env.BUILD_REF }} + path: release-source persist-credentials: false - name: Download production artifacts @@ -205,16 +231,18 @@ jobs: path: release-artifacts/beta - name: Prepare installers + working-directory: release-source run: | INSTALL_BASE_URL="${R2_PUBLIC_BASE_URL%/}" test -n "$INSTALL_BASE_URL" - node scripts/render-release-installers.mjs \ + node ../release-tooling/scripts/render-release-installers.mjs \ "$INSTALL_BASE_URL" \ /tmp/prime-agent-install.sh \ /tmp/prime-agent-install-beta.sh - name: Extract production release notes if: env.PUBLISH_PRODUCTION == 'true' + working-directory: release-source run: | awk "/^## \[${PRODUCTION_VERSION}\]/{flag=1; next} /^## \[/{flag=0} flag" packages/coding-agent/CHANGELOG.md > /tmp/release-notes.md if [ ! -s /tmp/release-notes.md ]; then @@ -225,9 +253,10 @@ jobs: if: env.PUBLISH_PRODUCTION == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + working-directory: release-source run: | - node scripts/publish-release.mjs production \ - --artifacts-dir release-artifacts/production \ + node ../release-tooling/scripts/publish-release.mjs production \ + --artifacts-dir ../release-artifacts/production \ --version "$PRODUCTION_VERSION" \ --build-ref "$BUILD_REF" \ --notes-file /tmp/release-notes.md \ @@ -238,9 +267,10 @@ jobs: if: env.PUBLISH_BETA == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + working-directory: release-source run: | - node scripts/publish-release.mjs beta \ - --artifacts-dir release-artifacts/beta \ + node ../release-tooling/scripts/publish-release.mjs beta \ + --artifacts-dir ../release-artifacts/beta \ --version "$BETA_VERSION" \ --build-ref "$BUILD_REF" \ --default-branch "$DEFAULT_BRANCH" \ diff --git a/RELEASING.md b/RELEASING.md index e0c42ec61c..009d4cca85 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -34,7 +34,7 @@ npm run release:prepare -- minor npm run release:prepare -- 0.8.1 ``` -The command updates only the root release-control manifest, the four packaged manifests, internal dependency ranges, corresponding lockfile metadata, and those four changelogs. It does not stage or commit the result. +The command updates only the root release-control manifest, the four packaged manifests, internal dependency ranges, corresponding lockfile metadata, and those four changelogs. It restores every original file if any replacement fails, and it does not stage or commit the result. Review the complete diff. Confirm that each changelog release section is accurate, private/example manifests did not change, and the lockfile contains only expected version and internal-range changes. @@ -56,7 +56,7 @@ For each default-branch push, the Release Prime Agent workflow builds a beta. It The production transaction is ordered as follows: -1. Resolve and validate the exact version, commit, lockfile, package manifests, changelogs, and existing tag state. +1. Resolve and validate the exact version, commit, lockfile, package manifests, changelogs, and existing tag state using the protected workflow commit's release tooling. 2. Build, check, run the release lifecycle tests, pack once, and validate the workflow artifacts without publication credentials. 3. Create or verify the immutable version tag and GitHub Release target. 4. Create missing versioned R2 objects using conditional writes. Existing objects must be byte-identical. @@ -66,11 +66,11 @@ The production transaction is ordered as follows: 8. Write and verify `/stable`. 9. Write and verify `/latest.json` last. -The workflow serializes publication and refuses to move stable pointers backward. Issue #927 separately owns the full exact-commit Node and Python suite gate; do not treat the focused release tests as that broader gate. +The workflow serializes publication and refuses to move either stable surface backward: the effective monotonic floor is the higher version named by `/stable` or `/latest.json`. Only the protected rollback workflow may lower both surfaces. Issue #927 separately owns the full exact-commit Node and Python suite gate; do not treat the focused release tests as that broader gate. ## Retry -Use the Release Prime Agent workflow's `retry-production` dispatch only after the immutable `vX.Y.Z` tag exists. The workflow derives both the version and commit from that tag, verifies that it belongs to the default branch, and rebuilds the exact tagged commit. A free-form version paired with current `main` is not supported. +Use the Release Prime Agent workflow's `retry-production` dispatch only after the immutable `vX.Y.Z` tag exists. The workflow derives both the version and commit from that tag, verifies that it belongs to the default branch, and rebuilds the exact tagged source. Release policy and publication scripts come from the protected workflow commit in a separate checkout, so tags created before this lifecycle was introduced remain retryable. A free-form version paired with current `main` is not supported. If the original production run failed before creating the version tag, rerun that exact failed workflow run instead. Do not use a later default-branch run or create the tag locally. Once the immutable tag exists, use `retry-production` for subsequent recovery attempts. diff --git a/docs/superpowers/specs/2026-08-08-release-authority-design.md b/docs/superpowers/specs/2026-08-08-release-authority-design.md index 2c2bb5b1c5..3e9e5f443b 100644 --- a/docs/superpowers/specs/2026-08-08-release-authority-design.md +++ b/docs/superpowers/specs/2026-08-08-release-authority-design.md @@ -32,11 +32,11 @@ A merged release-preparation commit on the protected default branch is the only Every default-branch commit may produce a beta. A production release is additionally planned only when the root release-control version changes to a valid higher version. Tag pushes do not independently publish. -A manual production retry accepts only an existing immutable `vX.Y.Z` tag. CI derives both the version and commit from that tag, verifies that the tag is on the default branch and that repository metadata at the tagged commit agrees, and then reruns the same publication transaction. +A manual production retry accepts only an existing immutable `vX.Y.Z` tag. CI derives both the version and commit from that tag, verifies that the tag is on the default branch and that repository metadata at the tagged commit agrees, and then reruns the same publication transaction. The protected workflow commit supplies release tooling from a separate checkout while the tagged checkout supplies source and lockfiles, preserving retries for tags created before this lifecycle existed. ## Local Commands -`release:prepare` is the only supported version mutation command. It updates the root release-control manifest, the four R2-packaged workspaces, their internal dependency ranges, the matching lockfile entries, and their changelog headings. It never stages, commits, tags, pushes, publishes, or invokes a release workflow. Private and example workspaces remain untouched. +`release:prepare` is the only supported version mutation command. It updates the root release-control manifest, the four R2-packaged workspaces, their internal dependency ranges, the matching lockfile entries, and their changelog headings. All replacement files are staged before the first rename, and a failed replacement restores every original file. It never stages, commits, tags, pushes, publishes, or invokes a release workflow. Private and example workspaces remain untouched. `release:dry-run` performs repository and artifact validation without publication credentials or remote mutation. It writes packaging output only to a temporary release directory and removes that directory on success or failure. The dry run verifies: @@ -75,7 +75,7 @@ The production transaction is: 3. For each versioned R2 object and GitHub Release asset, create it if absent; if present, compare its hash and fail on any mismatch. Never overwrite a different object. 4. Verify all versioned objects and GitHub Release assets. 5. Upload and verify the stable and beta installer scripts. -6. Write and verify the legacy `/stable` text pointer. +6. Confirm the candidate does not regress either existing stable surface, then write and verify the legacy `/stable` text pointer. 7. Write and verify `/latest.json` last. This JSON manifest is the stable commit marker. If a failure occurs before step 6, stable clients do not observe the candidate. If step 6 succeeds but step 7 fails, fresh installs may resolve the new complete release while update checks still see the previous release; rerunning the same tagged release converges safely. Repeating any completed step with identical content is a no-op. @@ -84,7 +84,7 @@ Beta publication keeps its stale-default-branch guard. It applies the same compa ## Rollback -Rollback is a separate manually dispatched workflow using the protected production environment. It requires an existing stable `vX.Y.Z` tag and exact confirmation text. +Rollback is a separate manually dispatched workflow using the protected production environment. It requires an existing stable `vX.Y.Z` tag and exact confirmation text. Normal publication treats the higher version from `/stable` and `/latest.json` as the monotonic floor; only this rollback path may lower both. The rollback workflow verifies the tag target, GitHub Release, saved release manifest, checksums, and every referenced R2 artifact before changing channel state. It changes only `/stable` and `/latest.json`, in that order. It never creates or moves an immutable version tag and never deletes or overwrites a versioned object. diff --git a/scripts/lib/release-lifecycle.mjs b/scripts/lib/release-lifecycle.mjs index 47dd28afe0..5e0cef93e0 100644 --- a/scripts/lib/release-lifecycle.mjs +++ b/scripts/lib/release-lifecycle.mjs @@ -1,6 +1,15 @@ import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { existsSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { + constants as fsConstants, + copyFileSync, + existsSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; import { basename, dirname, join, relative } from "node:path"; import { isDeepStrictEqual } from "node:util"; @@ -201,21 +210,43 @@ function releaseChangelog(content, version, date, path) { return content.replace(/^## \[Unreleased\]\n/m, `## [Unreleased]\n\n## [${version}] - ${date}\n`); } -function writePreparedFiles(root, files) { +function writePreparedFiles(root, files, options = {}) { const staged = []; + const replaced = []; + const replaceFile = options.replaceFile ?? renameSync; + let preserveBackups = false; try { for (const [index, [relativePath, content]] of files.entries()) { const path = join(root, relativePath); const temporaryPath = join(dirname(path), `.${basename(path)}.release-${process.pid}-${index}.tmp`); + const backupPath = join(dirname(path), `.${basename(path)}.release-${process.pid}-${index}.backup`); writeFileSync(temporaryPath, content, { flag: "wx" }); - staged.push({ path, temporaryPath }); + staged.push({ backupPath, path, temporaryPath }); + copyFileSync(path, backupPath, fsConstants.COPYFILE_EXCL); } for (const file of staged) { - renameSync(file.temporaryPath, file.path); + replaceFile(file.temporaryPath, file.path); + replaced.push(file); } + options.validate?.(); + } catch (error) { + const rollbackErrors = []; + for (const file of replaced.reverse()) { + try { + renameSync(file.backupPath, file.path); + } catch (rollbackError) { + rollbackErrors.push(rollbackError); + } + } + if (rollbackErrors.length > 0) { + preserveBackups = true; + throw new AggregateError([error, ...rollbackErrors], "Release preparation failed and could not be fully restored"); + } + throw error; } finally { for (const file of staged) { rmSync(file.temporaryPath, { force: true }); + if (!preserveBackups) rmSync(file.backupPath, { force: true }); } } } @@ -256,8 +287,10 @@ export function prepareRelease(root, target, options = {}) { } files.set("package-lock.json", formatJson(lock)); - writePreparedFiles(root, [...files.entries()]); - validateReleaseRepository(root, { version, requireChangelogs: true }); + writePreparedFiles(root, [...files.entries()], { + replaceFile: options.replaceFile, + validate: () => validateReleaseRepository(root, { version, requireChangelogs: true }), + }); return [...files.keys()].map((path) => relative(root, join(root, path))); } diff --git a/scripts/lib/release-publication.mjs b/scripts/lib/release-publication.mjs index d74a14b1e8..4f875a03d0 100644 --- a/scripts/lib/release-publication.mjs +++ b/scripts/lib/release-publication.mjs @@ -56,15 +56,30 @@ export function validatePromotion(artifactsDir, channel, store, options = {}) { const manifestPath = join(artifactsDir, manifestKey); const manifest = readFileSync(manifestPath); const candidateVersion = JSON.parse(manifest).version.replace(/^v/, ""); - const currentManifest = store.read(manifestKey); - if (channel === "stable" && currentManifest) { - const currentVersion = JSON.parse(currentManifest).version?.replace(/^v/, ""); - const comparison = compareVersions(candidateVersion, currentVersion); - if (comparison < 0 && !options.allowRegression) { - throw new Error(`Stable promotion to ${candidateVersion} would regress the current ${currentVersion} release`); + if (channel === "stable") { + const currentPointer = store.read(pointerKey); + const currentManifest = store.read(manifestKey); + const currentSurfaces = []; + if (currentPointer) { + const pointerMatch = currentPointer.toString().match(/^v(0\.\d+\.\d+)\n$/); + if (!pointerMatch) throw new Error(`${pointerKey} contains an invalid stable release pointer`); + currentSurfaces.push({ key: pointerKey, version: pointerMatch[1] }); } - if (comparison === 0 && sha256(currentManifest) !== sha256(manifest)) { - throw new Error(`Stable manifest for ${candidateVersion} differs from the current manifest`); + if (currentManifest) { + const currentVersion = JSON.parse(currentManifest).version?.replace(/^v/, ""); + currentSurfaces.push({ key: manifestKey, version: currentVersion }); + if (compareVersions(candidateVersion, currentVersion) === 0 && sha256(currentManifest) !== sha256(manifest)) { + throw new Error(`Stable manifest for ${candidateVersion} differs from the current manifest`); + } + } + if (!options.allowRegression) { + for (const current of currentSurfaces) { + if (compareVersions(candidateVersion, current.version) < 0) { + throw new Error( + `Stable promotion to ${candidateVersion} would regress ${current.key} from ${current.version}`, + ); + } + } } } return { manifestKey, pointerKey }; diff --git a/scripts/pack-prime-agent-release.mjs b/scripts/pack-prime-agent-release.mjs index 9babc389f6..9e513fbc24 100644 --- a/scripts/pack-prime-agent-release.mjs +++ b/scripts/pack-prime-agent-release.mjs @@ -17,7 +17,8 @@ import { import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const toolingRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const root = resolve(process.env.PRIME_AGENT_RELEASE_SOURCE_ROOT || toolingRoot); const defaultOutputDir = join(root, "packages", "coding-agent", "release"); const defaultBaseUrl = process.env.PRIME_AGENT_DOWNLOAD_BASE_URL; const publicPackageName = process.env.PRIME_AGENT_PACKAGE_NAME || "prime-agent"; diff --git a/scripts/publish-release.mjs b/scripts/publish-release.mjs index f013efd160..69c9dd335f 100755 --- a/scripts/publish-release.mjs +++ b/scripts/publish-release.mjs @@ -13,6 +13,8 @@ import { verifyRemoteRelease, } from "./lib/release-publication.mjs"; +const sourceRoot = resolve(process.env.PRIME_AGENT_RELEASE_SOURCE_ROOT || process.cwd()); + function parseArgs(args) { const operation = args[0]; if (!["beta", "production", "rollback"].includes(operation)) { @@ -362,7 +364,7 @@ function publishProduction(options, store, github, baseUrl) { const version = requireOption(options, "version"); const buildRef = requireOption(options, "buildRef"); const artifactsDir = resolve(requireOption(options, "artifactsDir")); - validateReleaseRepository(process.cwd(), { requireChangelogs: true, version }); + validateReleaseRepository(sourceRoot, { requireChangelogs: true, version }); verifyReleaseArtifacts(artifactsDir, { baseUrl, channel: "stable", version }); validatePromotion(artifactsDir, "stable", store); const tag = `v${version}`; @@ -387,7 +389,7 @@ function publishBeta(options, store, github, baseUrl) { console.log("A newer default-branch commit exists; leaving beta release state unchanged."); return; } - validateReleaseRepository(process.cwd(), { requireChangelogs: false, version: version.split("-", 1)[0] }); + validateReleaseRepository(sourceRoot, { requireChangelogs: false, version: version.split("-", 1)[0] }); verifyReleaseArtifacts(artifactsDir, { baseUrl, channel: "beta", version }); const result = publishChannel({ artifactsDir, @@ -410,7 +412,7 @@ function rollbackProduction(options, store, github, baseUrl) { if ( run("git", ["merge-base", "--is-ancestor", tagTarget, `origin/${defaultBranch}`], { allowFailure: true, - cwd: process.cwd(), + cwd: sourceRoot, }) === undefined ) { throw new Error(`Rollback tag ${releaseTag} is not on the default branch`); diff --git a/scripts/release-dry-run.mjs b/scripts/release-dry-run.mjs index b8ce974e7d..23273b78c2 100755 --- a/scripts/release-dry-run.mjs +++ b/scripts/release-dry-run.mjs @@ -7,7 +7,8 @@ import { fileURLToPath } from "node:url"; import { validateReleaseRepository, verifyReleaseArtifacts } from "./lib/release-lifecycle.mjs"; -const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const toolingRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const root = resolve(process.env.PRIME_AGENT_RELEASE_SOURCE_ROOT || toolingRoot); const defaultBaseUrl = "https://release.invalid"; function parseArgs(args) { @@ -49,7 +50,7 @@ function runPacker(options, outDir) { const result = spawnSync( process.execPath, [ - join(root, "scripts/pack-prime-agent-release.mjs"), + join(toolingRoot, "scripts/pack-prime-agent-release.mjs"), "--channel", options.channel, "--version", @@ -59,7 +60,12 @@ function runPacker(options, outDir) { "--out-dir", outDir, ], - { cwd: root, encoding: "utf8", stdio: "pipe" }, + { + cwd: root, + encoding: "utf8", + env: { ...process.env, PRIME_AGENT_RELEASE_SOURCE_ROOT: root }, + stdio: "pipe", + }, ); if (result.status !== 0) { throw new Error(result.stderr.trim() || result.stdout.trim() || "Release packer failed"); diff --git a/scripts/release-lifecycle.test.mjs b/scripts/release-lifecycle.test.mjs index 2040bb7d4f..2dd2f72469 100644 --- a/scripts/release-lifecycle.test.mjs +++ b/scripts/release-lifecycle.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import test from "node:test"; @@ -185,6 +185,43 @@ test("release preparation updates only release metadata and preserves private wo } }); +test("release preparation restores every file when a replacement fails mid-transaction", () => { + const root = createRepositoryFixture(); + const releaseFiles = [ + "package.json", + "package-lock.json", + ...RELEASE_PACKAGE_DIRS.flatMap((packageDir) => [ + `${packageDir}/package.json`, + `${packageDir}/CHANGELOG.md`, + ]), + ]; + const before = new Map(releaseFiles.map((path) => [path, readFileSync(join(root, path))])); + let replacements = 0; + try { + assert.throws( + () => + prepareRelease(root, "patch", { + date: "2026-08-08", + replaceFile(source, destination) { + replacements += 1; + if (replacements === 4) throw new Error("injected replacement failure"); + renameSync(source, destination); + }, + }), + /injected replacement failure/, + ); + for (const [path, content] of before) { + assert.deepEqual(readFileSync(join(root, path)), content, path); + } + for (const directory of [root, ...RELEASE_PACKAGE_DIRS.map((packageDir) => join(root, packageDir))]) { + assert.equal(readdirSync(directory).some((name) => name.includes(".release-")), false, directory); + } + validateReleaseRepository(root, { version: "0.7.1", requireChangelogs: true }); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("main pushes publish beta and only a strictly newer version publishes production", () => { const changed = createReleasePlan({ eventName: "push", @@ -361,10 +398,11 @@ test("artifact verification rejects a packed CLI missing required internal R2 de } }); -test("dry-run CLI validates supplied artifacts without invoking publication tooling", () => { - const { artifactsDir, root } = createArtifactFixture("0.7.1", "stable"); - try { - const result = spawnSync( +test("current dry-run tooling validates a pre-migration source tree without release scripts", () => { + const sourceRoot = createRepositoryFixture("0.7.1"); + const { artifactsDir, root: artifactRoot } = createArtifactFixture("0.7.1", "stable"); + const runDryRun = () => + spawnSync( process.execPath, [ fileURLToPath(new URL("release-dry-run.mjs", import.meta.url)), @@ -384,13 +422,26 @@ test("dry-run CLI validates supplied artifacts without invoking publication tool ...process.env, AWS_ACCESS_KEY_ID: "must-not-be-used", GH_TOKEN: "must-not-be-used", + PRIME_AGENT_RELEASE_SOURCE_ROOT: sourceRoot, R2_BUCKET: "must-not-be-used", }, }, ); + try { + assert.equal(JSON.parse(readFileSync(join(sourceRoot, "package.json"), "utf8")).scripts, undefined); + const result = runDryRun(); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /Validated v0\.7\.1 stable artifacts without publishing/); + + const agentPath = join(sourceRoot, "packages/agent/package.json"); + const agent = JSON.parse(readFileSync(agentPath, "utf8")); + agent.version = "0.7.0"; + writeJson(agentPath, agent); + const drift = runDryRun(); + assert.equal(drift.status, 1); + assert.match(drift.stderr, /packages\/agent version is 0\.7\.0; expected 0\.7\.1/); } finally { - rmSync(root, { recursive: true, force: true }); + rmSync(artifactRoot, { recursive: true, force: true }); + rmSync(sourceRoot, { recursive: true, force: true }); } }); diff --git a/scripts/release-publication.test.mjs b/scripts/release-publication.test.mjs index 87af2bfb95..fc0250f113 100644 --- a/scripts/release-publication.test.mjs +++ b/scripts/release-publication.test.mjs @@ -122,6 +122,33 @@ test("stable promotion is monotonic unless an explicit rollback allows regressio } }); +test("stable promotion uses the highest version across split pointer surfaces", () => { + const { artifactsDir, root } = createPublicationFixture(); + try { + const pointerAhead = new MemoryStore({ + "latest.json": readFileSync(join(artifactsDir, "latest.json")), + stable: "v0.7.3\n", + }); + assert.throws(() => promoteChannel(artifactsDir, "stable", pointerAhead), /stable.*0\.7\.3/i); + + const manifestAhead = new MemoryStore({ + "latest.json": `${JSON.stringify({ version: "v0.7.3" })}\n`, + stable: "v0.7.1\n", + }); + assert.throws(() => promoteChannel(artifactsDir, "stable", manifestAhead), /latest\.json.*0\.7\.3/i); + + const rollback = new MemoryStore({ + "latest.json": `${JSON.stringify({ version: "v0.7.3" })}\n`, + stable: "v0.7.4\n", + }); + promoteChannel(artifactsDir, "stable", rollback, { allowRegression: true }); + assert.equal(rollback.objects.get("stable").toString(), "v0.7.2\n"); + assert.match(rollback.objects.get("latest.json").toString(), /v0\.7\.2/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("remote verification proves every saved release object before rollback", () => { const { artifactsDir, root } = createPublicationFixture(); const store = new MemoryStore(); diff --git a/scripts/release-workflow-contract.test.mjs b/scripts/release-workflow-contract.test.mjs index 7391cfe1c9..6746d1105c 100644 --- a/scripts/release-workflow-contract.test.mjs +++ b/scripts/release-workflow-contract.test.mjs @@ -36,13 +36,18 @@ test("release workflow publishes from main or an immutable retry tag, never a ta assert.match(releaseWorkflow, /retry-production/); assert.match(releaseWorkflow, /node scripts\/resolve-release-context\.mjs/); assert.doesNotMatch(releaseWorkflow, /Production release tag to create or update/); + assert.match(releaseWorkflow, /ref: \$\{\{ github\.workflow_sha \}\}/); + assert.match(releaseWorkflow, /path: release-tooling/); + assert.match(releaseWorkflow, /path: release-source/); + assert.match(releaseWorkflow, /PRIME_AGENT_RELEASE_SOURCE_ROOT:/); + assert.match(releaseWorkflow, /node \.\.\/release-tooling\/scripts\/publish-release\.mjs production/); }); test("production publication compares immutable assets and commits latest.json last", () => { - assert.match(releaseWorkflow, /node scripts\/publish-release\.mjs production/); + assert.match(releaseWorkflow, /node \.\.\/release-tooling\/scripts\/publish-release\.mjs production/); assert.doesNotMatch(releaseWorkflow, /gh release upload[^\n]*--clobber/); - const productionStep = releaseWorkflow.indexOf("node scripts/publish-release.mjs production"); - const betaStep = releaseWorkflow.indexOf("node scripts/publish-release.mjs beta"); + const productionStep = releaseWorkflow.indexOf("node ../release-tooling/scripts/publish-release.mjs production"); + const betaStep = releaseWorkflow.indexOf("node ../release-tooling/scripts/publish-release.mjs beta"); assert.ok(productionStep > -1); assert.ok(betaStep > productionStep); }); From f30329faedb883d2a9ad4ae2121c16ddc22cc293 Mon Sep 17 00:00:00 2001 From: fettpl <38704082+fettpl@users.noreply.github.com> Date: Sat, 8 Aug 2026 20:50:23 +0200 Subject: [PATCH 08/13] fix(release): bind manual operations to trusted workflows (fixes #934) --- .github/workflows/build-binaries.yml | 36 +++++++----- .github/workflows/rollback-release.yml | 40 +++++++++---- AGENTS.md | 2 +- RELEASING.md | 19 +++++-- .../2026-08-08-release-authority-design.md | 8 +-- scripts/lib/release-lifecycle.mjs | 30 +++++++++- scripts/lib/release-publication.mjs | 3 + scripts/publish-release.mjs | 6 ++ scripts/release-lifecycle.test.mjs | 54 ++++++++++++++++-- scripts/release-publication.test.mjs | 56 +++++++++++++++++++ scripts/release-workflow-contract.test.mjs | 16 +++++- scripts/resolve-release-context.mjs | 23 ++++---- scripts/resolve-rollback-context.mjs | 25 +++++++++ 13 files changed, 263 insertions(+), 55 deletions(-) create mode 100755 scripts/resolve-rollback-context.mjs diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index a08a49f331..6657c5b08b 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -4,18 +4,8 @@ on: push: branches: - main - workflow_dispatch: - inputs: - operation: - description: 'Release operation' - required: true - type: choice - options: - - retry-production - release_tag: - description: 'Existing immutable production tag to retry (for example v0.7.1)' - required: true - type: string + issue_comment: + types: [created] concurrency: group: release-prime-agent @@ -27,6 +17,7 @@ permissions: jobs: release-context: + if: github.event_name == 'push' || startsWith(github.event.comment.body, '/prime-agent release retry ') runs-on: ubuntu-latest permissions: contents: read @@ -42,8 +33,23 @@ jobs: with: fetch-depth: 0 fetch-tags: true + ref: ${{ github.workflow_sha }} persist-credentials: false + - name: Authorize release comment + id: authorization + if: github.event_name == 'issue_comment' + env: + ACTOR: ${{ github.actor }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + permission=$(gh api "repos/${GITHUB_REPOSITORY}/collaborators/${ACTOR}/permission" --jq .permission) + case "$permission" in + admin|maintain) ;; + *) echo "Release commands require admin or maintain permission." >&2; exit 1 ;; + esac + echo "permission=$permission" >> "$GITHUB_OUTPUT" + - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -52,15 +58,15 @@ jobs: - name: Resolve release context id: context env: + ACTOR_PERMISSION: ${{ steps.authorization.outputs.permission || '' }} BEFORE_SHA: ${{ github.event.before || '' }} + COMMENT_BODY: ${{ github.event.comment.body || '' }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} EVENT_NAME: ${{ github.event_name }} GITHUB_SHA_VALUE: ${{ github.sha }} - INPUT_OPERATION: ${{ inputs.operation || '' }} - INPUT_RELEASE_TAG: ${{ inputs.release_tag || '' }} - REF_NAME: ${{ github.ref_name }} RUN_ATTEMPT: ${{ github.run_attempt }} RUN_NUMBER: ${{ github.run_number }} + WORKFLOW_REF: ${{ github.workflow_ref }} run: node scripts/resolve-release-context.mjs build: diff --git a/.github/workflows/rollback-release.yml b/.github/workflows/rollback-release.yml index 49e802c590..cbb349f285 100644 --- a/.github/workflows/rollback-release.yml +++ b/.github/workflows/rollback-release.yml @@ -1,16 +1,8 @@ name: Rollback Prime Agent stable channel on: - workflow_dispatch: - inputs: - release_tag: - description: 'Existing stable release tag to restore (for example v0.7.1)' - required: true - type: string - confirmation: - description: 'Type ROLLBACK vX.Y.Z using the exact target tag' - required: true - type: string + issue_comment: + types: [created] concurrency: group: release-prime-agent @@ -22,6 +14,7 @@ permissions: jobs: rollback: + if: startsWith(github.event.comment.body, '/prime-agent release rollback ') runs-on: ubuntu-latest environment: production permissions: @@ -32,25 +25,48 @@ jobs: with: fetch-depth: 0 fetch-tags: true + ref: ${{ github.workflow_sha }} persist-credentials: false + - name: Authorize rollback comment + id: authorization + env: + ACTOR: ${{ github.actor }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + permission=$(gh api "repos/${GITHUB_REPOSITORY}/collaborators/${ACTOR}/permission" --jq .permission) + case "$permission" in + admin|maintain) ;; + *) echo "Release commands require admin or maintain permission." >&2; exit 1 ;; + esac + echo "permission=$permission" >> "$GITHUB_OUTPUT" + - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '22' + - name: Parse /prime-agent release rollback vX.Y.Z command + id: context + env: + ACTOR_PERMISSION: ${{ steps.authorization.outputs.permission }} + COMMENT_BODY: ${{ github.event.comment.body }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + WORKFLOW_REF: ${{ github.workflow_ref }} + run: node scripts/resolve-rollback-context.mjs + - name: Verify target and roll back stable pointers env: AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} AWS_DEFAULT_REGION: auto AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - CONFIRMATION: ${{ inputs.confirmation }} + CONFIRMATION: ${{ steps.context.outputs.confirmation }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} R2_BUCKET: ${{ secrets.R2_BUCKET }} R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} R2_PUBLIC_BASE_URL: ${{ vars.R2_PUBLIC_BASE_URL }} - RELEASE_TAG: ${{ inputs.release_tag }} + RELEASE_TAG: ${{ steps.context.outputs.release_tag }} run: | node scripts/publish-release.mjs rollback \ --release-tag "$RELEASE_TAG" \ diff --git a/AGENTS.md b/AGENTS.md index 15219c7907..33d7a4decf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -204,7 +204,7 @@ The protected GitHub Actions workflows are the only release authority. Local rel 4. Build through the normal repository process, then run `npm run release:dry-run`, `npm run release:test`, and `npm run check`. 5. Open a release-preparation pull request. CI publishes only after that exact version commit merges to the protected default branch. -The inherited `release:patch`, `release:minor`, `release:major`, `version:*`, root `publish*`, and `scripts/sync-versions.js` paths intentionally hard-fail. Never bypass them. Never dispatch a publishing or rollback workflow unless the user explicitly authorizes that external state change. +The inherited `release:patch`, `release:minor`, `release:major`, `version:*`, root `publish*`, and `scripts/sync-versions.js` paths intentionally hard-fail. Never bypass them. Never post a release retry or rollback control comment unless the user explicitly authorizes that external state change. ## **CRITICAL** Git Rules for Parallel Agents **CRITICAL** diff --git a/RELEASING.md b/RELEASING.md index 009d4cca85..f3c3af9b73 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -70,9 +70,15 @@ The workflow serializes publication and refuses to move either stable surface ba ## Retry -Use the Release Prime Agent workflow's `retry-production` dispatch only after the immutable `vX.Y.Z` tag exists. The workflow derives both the version and commit from that tag, verifies that it belongs to the default branch, and rebuilds the exact tagged source. Release policy and publication scripts come from the protected workflow commit in a separate checkout, so tags created before this lifecycle was introduced remain retryable. A free-form version paired with current `main` is not supported. +After the immutable `vX.Y.Z` tag exists, a maintainer with repository `admin` or `maintain` permission can post this exact issue comment: -If the original production run failed before creating the version tag, rerun that exact failed workflow run instead. Do not use a later default-branch run or create the tag locally. Once the immutable tag exists, use `retry-production` for subsequent recovery attempts. +```text +/prime-agent release retry vX.Y.Z +``` + +The `issue_comment` event always loads workflow code from the protected default branch; branch-selectable release dispatches are not supported. The workflow derives both the version and commit from the tag, verifies that it belongs to the default branch, and rebuilds the exact tagged source. Release policy and publication scripts come from the protected workflow commit in a separate checkout, so tags created before this lifecycle was introduced remain retryable. A free-form version paired with current `main` is not supported. + +If the original production run failed before creating the version tag, rerun that exact failed workflow run instead. Do not use a later default-branch run or create the tag locally. Once the immutable tag exists, use the issue-comment retry command for subsequent recovery attempts. Retries are idempotent: @@ -86,9 +92,14 @@ Never delete or replace an immutable version tag or artifact to make a retry pas ## Rollback -Prefer a forward fix. If stable must be restored immediately, dispatch the Rollback Prime Agent stable channel workflow. Select an existing stable `vX.Y.Z` tag and enter the exact confirmation `ROLLBACK vX.Y.Z`. +Prefer a forward fix. If stable must be restored immediately, a maintainer with repository `admin` or `maintain` permission can post this exact two-line issue comment: + +```text +/prime-agent release rollback vX.Y.Z +ROLLBACK vX.Y.Z +``` -The rollback job uses the protected `production` environment. Configure that environment with required reviewers before enabling rollback credentials. The job verifies the immutable tag, GitHub Release assets, manifest, checksums, and every referenced R2 tarball before writing `/stable` and then `/latest.json`. It does not create or move tags, rewrite immutable objects, or change GitHub Releases. +The default-branch workflow and an API-derived maintainer permission are the repository-enforced authorization boundary. The job also targets the `production` environment, so configured environment reviewers provide an additional gate but are not assumed to exist. The job verifies the immutable tag, GitHub Release assets, manifest, checksums, and every referenced R2 tarball before writing `/stable` and then `/latest.json`. It does not create or move tags, rewrite immutable objects, or change GitHub Releases. Rollback changes what fresh installations and future update checks select. It does not force already-installed newer clients to downgrade. diff --git a/docs/superpowers/specs/2026-08-08-release-authority-design.md b/docs/superpowers/specs/2026-08-08-release-authority-design.md index 3e9e5f443b..55e6d0b110 100644 --- a/docs/superpowers/specs/2026-08-08-release-authority-design.md +++ b/docs/superpowers/specs/2026-08-08-release-authority-design.md @@ -32,7 +32,7 @@ A merged release-preparation commit on the protected default branch is the only Every default-branch commit may produce a beta. A production release is additionally planned only when the root release-control version changes to a valid higher version. Tag pushes do not independently publish. -A manual production retry accepts only an existing immutable `vX.Y.Z` tag. CI derives both the version and commit from that tag, verifies that the tag is on the default branch and that repository metadata at the tagged commit agrees, and then reruns the same publication transaction. The protected workflow commit supplies release tooling from a separate checkout while the tagged checkout supplies source and lockfiles, preserving retries for tags created before this lifecycle existed. +A manual production retry is an exact issue-comment command from an actor whose repository permission resolves to `admin` or `maintain`. GitHub loads `issue_comment` workflow code from the default branch, so the privileged path has no branch-selectable dispatch. CI accepts only an existing immutable `vX.Y.Z` tag, derives both the version and commit from that tag, verifies that the tag is on the default branch and that repository metadata at the tagged commit agrees, and then reruns the same publication transaction. The protected workflow commit supplies release tooling from a separate checkout while the tagged checkout supplies source and lockfiles, preserving retries for tags created before this lifecycle existed. ## Local Commands @@ -80,11 +80,11 @@ The production transaction is: If a failure occurs before step 6, stable clients do not observe the candidate. If step 6 succeeds but step 7 fails, fresh installs may resolve the new complete release while update checks still see the previous release; rerunning the same tagged release converges safely. Repeating any completed step with identical content is a no-op. -Beta publication keeps its stale-default-branch guard. It applies the same compare-before-write rule to versioned objects, updates `/beta` before `/beta.json`, and treats `/beta.json` as the beta commit marker. The mutable `beta` Git tag and prerelease remain beta-only compatibility surfaces. +Beta publication keeps its stale-default-branch guard. After immutable uploads it rechecks default-branch freshness immediately before the GitHub mirror, installer updates, and channel-pointer promotion. It applies the same compare-before-write rule to versioned objects, updates `/beta` before `/beta.json`, and treats `/beta.json` as the beta commit marker. The mutable `beta` Git tag and prerelease remain beta-only compatibility surfaces. ## Rollback -Rollback is a separate manually dispatched workflow using the protected production environment. It requires an existing stable `vX.Y.Z` tag and exact confirmation text. Normal publication treats the higher version from `/stable` and `/latest.json` as the monotonic floor; only this rollback path may lower both. +Rollback is a separate exact two-line issue-comment command authorized by protected default-branch workflow code and an API-derived `admin` or `maintain` permission. The workflow also names the `production` environment as an optional additional gate. It requires an existing stable `vX.Y.Z` tag and matching confirmation text. Normal publication treats the higher version from `/stable` and `/latest.json` as the monotonic floor; only this rollback path may lower both. The rollback workflow verifies the tag target, GitHub Release, saved release manifest, checksums, and every referenced R2 artifact before changing channel state. It changes only `/stable` and `/latest.json`, in that order. It never creates or moves an immutable version tag and never deletes or overwrites a versioned object. @@ -106,7 +106,7 @@ Existing npm package identities are neither unpublished nor redefined. Direct SD Deterministic tests cover: - default-branch beta-only and version-bump production plans; -- manual retry from an existing tag and rejection of free-form or conflicting targets; +- maintainer-authorized default-branch retry from an existing tag and rejection of free-form or conflicting targets; - public-package version, dependency, lockfile, changelog, and tag drift; - rejection of major, equal, and lower versions; - private/example workspace preservation during preparation; diff --git a/scripts/lib/release-lifecycle.mjs b/scripts/lib/release-lifecycle.mjs index 5e0cef93e0..7d7dc1344f 100644 --- a/scripts/lib/release-lifecycle.mjs +++ b/scripts/lib/release-lifecycle.mjs @@ -316,7 +316,7 @@ export function createReleasePlan(input) { }; } - if (input.eventName === "workflow_dispatch" && input.operation === "retry-production") { + if (input.eventName === "issue_comment" && input.operation === "retry-production") { const expectedTag = `v${version}`; if (input.releaseTag !== expectedTag) { throw new Error(`Retry tag ${input.releaseTag ?? ""} does not match package version ${expectedTag}`); @@ -366,6 +366,34 @@ export function validateRollbackRequest(releaseTag, confirmation) { }; } +export function parseReleaseComment(input) { + if (input.actorPermission !== "admin" && input.actorPermission !== "maintain") { + throw new Error("Release commands require repository admin or maintain permission"); + } + const trustedRefSuffix = `@refs/heads/${input.defaultBranch}`; + if (!input.workflowRef?.endsWith(trustedRefSuffix)) { + throw new Error(`Release commands must use workflow code from the protected default branch (${input.defaultBranch})`); + } + const body = input.body?.trim() ?? ""; + if (input.expectedOperation === "retry-production") { + const match = body.match(/^\/prime-agent release retry (v0\.\d+\.\d+)$/); + if (!match) throw new Error("Retry command must be exactly: /prime-agent release retry v0.x.y"); + return { operation: "retry-production", releaseTag: match[1] }; + } + if (input.expectedOperation === "rollback-production") { + const match = body.match(/^\/prime-agent release rollback (v0\.\d+\.\d+)\nROLLBACK (v0\.\d+\.\d+)$/); + if (!match || match[1] !== match[2]) { + throw new Error("Rollback command must use matching tags on both lines"); + } + return { + confirmation: `ROLLBACK ${match[1]}`, + operation: "rollback-production", + releaseTag: match[1], + }; + } + throw new Error(`Unsupported release command operation: ${input.expectedOperation ?? ""}`); +} + function sha256File(path) { const hash = createHash("sha256"); hash.update(readFileSync(path)); diff --git a/scripts/lib/release-publication.mjs b/scripts/lib/release-publication.mjs index 4f875a03d0..b2e1572ac4 100644 --- a/scripts/lib/release-publication.mjs +++ b/scripts/lib/release-publication.mjs @@ -101,8 +101,10 @@ export function promoteChannel(artifactsDir, channel, store, options = {}) { export function publishChannel(options) { const immutable = publishImmutableArtifacts(options.artifactsDir, options.version, options.store); + options.beforeMutable?.("mirror"); options.mirror?.(); verifyRemoteRelease(options.artifactsDir, options.version, options.store); + options.beforeMutable?.("installers"); for (const installer of options.installers ?? []) { const local = readFileSync(installer.path); options.store.putMutable(installer.key, installer.path, { @@ -111,6 +113,7 @@ export function publishChannel(options) { }); requireRemoteMatch(options.store, installer.key, local); } + options.beforeMutable?.("promotion"); const promotion = promoteChannel(options.artifactsDir, options.channel, options.store, { allowRegression: options.allowRegression, }); diff --git a/scripts/publish-release.mjs b/scripts/publish-release.mjs index 69c9dd335f..00d4589dfd 100755 --- a/scripts/publish-release.mjs +++ b/scripts/publish-release.mjs @@ -389,10 +389,16 @@ function publishBeta(options, store, github, baseUrl) { console.log("A newer default-branch commit exists; leaving beta release state unchanged."); return; } + const requireCurrentBuild = () => { + if (github.latestDefaultBranchSha(defaultBranch) !== buildRef) { + throw new Error("A newer default-branch commit exists; refusing stale mutable beta updates"); + } + }; validateReleaseRepository(sourceRoot, { requireChangelogs: false, version: version.split("-", 1)[0] }); verifyReleaseArtifacts(artifactsDir, { baseUrl, channel: "beta", version }); const result = publishChannel({ artifactsDir, + beforeMutable: requireCurrentBuild, channel: "beta", installers: installers(options), mirror: () => github.replaceBetaRelease(buildRef, version, artifactsDir, defaultBranch), diff --git a/scripts/release-lifecycle.test.mjs b/scripts/release-lifecycle.test.mjs index 2dd2f72469..658ec06532 100644 --- a/scripts/release-lifecycle.test.mjs +++ b/scripts/release-lifecycle.test.mjs @@ -12,6 +12,7 @@ import { bumpVersion, createReleasePlan, decideImmutableWrite, + parseReleaseComment, prepareRelease, promotionKeys, validateReleaseRepository, @@ -267,10 +268,55 @@ test("main pushes publish beta and only a strictly newer version publishes produ ); }); -test("manual production retry is bound to an existing tag and its immutable commit", () => { +test("trusted maintainer release comments are bound to default-branch workflow code", () => { + assert.deepEqual( + parseReleaseComment({ + actorPermission: "maintain", + body: "/prime-agent release retry v0.7.2", + defaultBranch: "main", + expectedOperation: "retry-production", + workflowRef: "PrimeIntellect-ai/prime-agent/.github/workflows/build-binaries.yml@refs/heads/main", + }), + { operation: "retry-production", releaseTag: "v0.7.2" }, + ); + assert.deepEqual( + parseReleaseComment({ + actorPermission: "admin", + body: "/prime-agent release rollback v0.7.1\nROLLBACK v0.7.1", + defaultBranch: "main", + expectedOperation: "rollback-production", + workflowRef: "PrimeIntellect-ai/prime-agent/.github/workflows/rollback-release.yml@refs/heads/main", + }), + { confirmation: "ROLLBACK v0.7.1", operation: "rollback-production", releaseTag: "v0.7.1" }, + ); + assert.throws( + () => + parseReleaseComment({ + actorPermission: "write", + body: "/prime-agent release retry v0.7.2", + defaultBranch: "main", + expectedOperation: "retry-production", + workflowRef: "PrimeIntellect-ai/prime-agent/.github/workflows/build-binaries.yml@refs/heads/main", + }), + /admin or maintain/i, + ); + assert.throws( + () => + parseReleaseComment({ + actorPermission: "admin", + body: "/prime-agent release retry v0.7.2", + defaultBranch: "main", + expectedOperation: "retry-production", + workflowRef: "PrimeIntellect-ai/prime-agent/.github/workflows/build-binaries.yml@refs/heads/feature", + }), + /protected default branch/i, + ); +}); + +test("comment-requested production retry is bound to an existing tag and its immutable commit", () => { assert.deepEqual( createReleasePlan({ - eventName: "workflow_dispatch", + eventName: "issue_comment", operation: "retry-production", releaseTag: "v0.7.2", version: "0.7.2", @@ -288,7 +334,7 @@ test("manual production retry is bound to an existing tag and its immutable comm assert.throws( () => createReleasePlan({ - eventName: "workflow_dispatch", + eventName: "issue_comment", operation: "retry-production", releaseTag: "v0.7.2", version: "0.7.2", @@ -298,7 +344,7 @@ test("manual production retry is bound to an existing tag and its immutable comm assert.throws( () => createReleasePlan({ - eventName: "workflow_dispatch", + eventName: "issue_comment", operation: "retry-production", releaseTag: "v0.7.2", version: "0.7.2", diff --git a/scripts/release-publication.test.mjs b/scripts/release-publication.test.mjs index fc0250f113..34af257c21 100644 --- a/scripts/release-publication.test.mjs +++ b/scripts/release-publication.test.mjs @@ -106,6 +106,62 @@ test("channel transaction completes the mirror before pointer and manifest promo } }); +test("beta publication rechecks freshness before every mutable phase", () => { + const { artifactsDir, root } = createPublicationFixture("0.7.2-beta.42.1.0123456", "beta"); + const installerPath = join(root, "install-beta.sh"); + writeFileSync(installerPath, "installer"); + const store = new MemoryStore(); + try { + publishChannel({ + artifactsDir, + beforeMutable: (phase) => store.events.push(`guard:${phase}`), + channel: "beta", + installers: [{ key: "install-beta.sh", path: installerPath }], + mirror: () => store.events.push("mirror"), + store, + version: "0.7.2-beta.42.1.0123456", + }); + assert.notEqual(store.events.indexOf("guard:mirror"), -1); + assert.notEqual(store.events.indexOf("guard:installers"), -1); + assert.notEqual(store.events.indexOf("guard:promotion"), -1); + assert.ok(store.events.indexOf("guard:mirror") < store.events.indexOf("mirror")); + assert.ok(store.events.indexOf("guard:installers") < store.events.indexOf("mutable:install-beta.sh")); + assert.ok(store.events.indexOf("guard:promotion") < store.events.indexOf("mutable:beta")); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("a beta that becomes stale during mutable work cannot advance channel pointers", () => { + const { artifactsDir, root } = createPublicationFixture("0.7.2-beta.42.1.0123456", "beta"); + const installerPath = join(root, "install-beta.sh"); + writeFileSync(installerPath, "installer"); + const store = new MemoryStore(); + try { + assert.throws( + () => + publishChannel({ + artifactsDir, + beforeMutable(phase) { + store.events.push(`guard:${phase}`); + if (phase === "installers") throw new Error("newer main commit"); + }, + channel: "beta", + installers: [{ key: "install-beta.sh", path: installerPath }], + mirror: () => store.events.push("mirror"), + store, + version: "0.7.2-beta.42.1.0123456", + }), + /newer main commit/, + ); + assert.equal(store.objects.has("install-beta.sh"), false); + assert.equal(store.objects.has("beta"), false); + assert.equal(store.objects.has("beta.json"), false); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("stable promotion is monotonic unless an explicit rollback allows regression", () => { const { artifactsDir, root } = createPublicationFixture(); const store = new MemoryStore({ diff --git a/scripts/release-workflow-contract.test.mjs b/scripts/release-workflow-contract.test.mjs index 6746d1105c..dc2daa9ac3 100644 --- a/scripts/release-workflow-contract.test.mjs +++ b/scripts/release-workflow-contract.test.mjs @@ -8,6 +8,8 @@ const legacyRelease = readFileSync(new URL("release.mjs", import.meta.url), "utf const legacySync = readFileSync(new URL("sync-versions.js", import.meta.url), "utf8"); const releaseWorkflow = readFileSync(new URL("../.github/workflows/build-binaries.yml", import.meta.url), "utf8"); const rollbackWorkflow = readFileSync(new URL("../.github/workflows/rollback-release.yml", import.meta.url), "utf8"); +const rollbackResolver = readFileSync(new URL("resolve-rollback-context.mjs", import.meta.url), "utf8"); +const publisher = readFileSync(new URL("publish-release.mjs", import.meta.url), "utf8"); test("legacy local release and publish commands are non-mutating tombstones", () => { for (const scriptName of [ @@ -32,8 +34,10 @@ test("legacy local release and publish commands are non-mutating tombstones", () test("release workflow publishes from main or an immutable retry tag, never a tag push", () => { assert.doesNotMatch(releaseWorkflow, /^\s+tags:/m); - assert.match(releaseWorkflow, /operation:/); - assert.match(releaseWorkflow, /retry-production/); + assert.doesNotMatch(releaseWorkflow, /workflow_dispatch/); + assert.match(releaseWorkflow, /issue_comment:/); + assert.match(releaseWorkflow, /\/prime-agent release retry/); + assert.match(releaseWorkflow, /collaborators\/\$\{ACTOR\}\/permission/); assert.match(releaseWorkflow, /node scripts\/resolve-release-context\.mjs/); assert.doesNotMatch(releaseWorkflow, /Production release tag to create or update/); assert.match(releaseWorkflow, /ref: \$\{\{ github\.workflow_sha \}\}/); @@ -50,12 +54,18 @@ test("production publication compares immutable assets and commits latest.json l const betaStep = releaseWorkflow.indexOf("node ../release-tooling/scripts/publish-release.mjs beta"); assert.ok(productionStep > -1); assert.ok(betaStep > productionStep); + assert.match(publisher, /beforeMutable: requireCurrentBuild/); }); test("rollback is a separately protected pointer-only workflow", () => { assert.match(rollbackWorkflow, /^name: Rollback Prime Agent stable channel$/m); + assert.doesNotMatch(rollbackWorkflow, /workflow_dispatch/); + assert.match(rollbackWorkflow, /issue_comment:/); + assert.match(rollbackWorkflow, /\/prime-agent release rollback/); + assert.match(rollbackWorkflow, /collaborators\/\$\{ACTOR\}\/permission/); assert.match(rollbackWorkflow, /environment: production/); - assert.match(rollbackWorkflow, /ROLLBACK vX\.Y\.Z/); + assert.match(rollbackResolver, /confirmation/); + assert.match(rollbackWorkflow, /node scripts\/resolve-rollback-context\.mjs/); assert.match(rollbackWorkflow, /node scripts\/publish-release\.mjs rollback/); assert.doesNotMatch(rollbackWorkflow, /gh release (?:create|edit|upload)|git (?:tag|push)|npm publish/); }); diff --git a/scripts/resolve-release-context.mjs b/scripts/resolve-release-context.mjs index c424aaad78..8d5453e5ca 100755 --- a/scripts/resolve-release-context.mjs +++ b/scripts/resolve-release-context.mjs @@ -5,7 +5,7 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; -import { createReleasePlan, validateReleaseRepository } from "./lib/release-lifecycle.mjs"; +import { createReleasePlan, parseReleaseComment, validateReleaseRepository } from "./lib/release-lifecycle.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -52,21 +52,22 @@ function planPush() { function planRetry() { const defaultBranch = process.env.DEFAULT_BRANCH; - if (process.env.REF_NAME !== defaultBranch) { - throw new Error(`Manual retries must run from the default branch (${defaultBranch})`); - } - const releaseTag = process.env.INPUT_RELEASE_TAG; - if (!/^v0\.\d+\.\d+$/.test(releaseTag ?? "")) { - throw new Error(`Retry target must be an existing plain release tag like v0.7.1: ${releaseTag ?? ""}`); - } + const command = parseReleaseComment({ + actorPermission: process.env.ACTOR_PERMISSION, + body: process.env.COMMENT_BODY, + defaultBranch, + expectedOperation: "retry-production", + workflowRef: process.env.WORKFLOW_REF, + }); + const releaseTag = command.releaseTag; const tagTarget = resolveTagTarget(releaseTag); const tagOnDefaultBranch = tagTarget !== undefined && git(["merge-base", "--is-ancestor", tagTarget, `origin/${defaultBranch}`], { allowFailure: true }) !== undefined; const version = tagTarget ? versionAt(tagTarget) : releaseTag.slice(1); return createReleasePlan({ - eventName: "workflow_dispatch", - operation: process.env.INPUT_OPERATION, + eventName: "issue_comment", + operation: command.operation, releaseTag, tagOnDefaultBranch, tagTarget, @@ -92,7 +93,7 @@ function writeOutputs(plan) { try { const eventName = process.env.EVENT_NAME; - const plan = eventName === "push" ? planPush() : eventName === "workflow_dispatch" ? planRetry() : undefined; + const plan = eventName === "push" ? planPush() : eventName === "issue_comment" ? planRetry() : undefined; if (!plan) throw new Error(`Unsupported release event: ${eventName ?? ""}`); writeOutputs(plan); } catch (error) { diff --git a/scripts/resolve-rollback-context.mjs b/scripts/resolve-rollback-context.mjs new file mode 100755 index 0000000000..930cb9d943 --- /dev/null +++ b/scripts/resolve-rollback-context.mjs @@ -0,0 +1,25 @@ +#!/usr/bin/env node + +import { appendFileSync } from "node:fs"; + +import { parseReleaseComment, validateRollbackRequest } from "./lib/release-lifecycle.mjs"; + +try { + const command = parseReleaseComment({ + actorPermission: process.env.ACTOR_PERMISSION, + body: process.env.COMMENT_BODY, + defaultBranch: process.env.DEFAULT_BRANCH, + expectedOperation: "rollback-production", + workflowRef: process.env.WORKFLOW_REF, + }); + validateRollbackRequest(command.releaseTag, command.confirmation); + if (!process.env.GITHUB_OUTPUT) throw new Error("GITHUB_OUTPUT is required"); + appendFileSync( + process.env.GITHUB_OUTPUT, + `release_tag=${command.releaseTag}\nconfirmation=${command.confirmation}\n`, + ); + console.log(`Authorized rollback request for ${command.releaseTag}.`); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} From c06e564726d744714ab7a2b2d6c7bd2c143bbefc Mon Sep 17 00:00:00 2001 From: fettpl <38704082+fettpl@users.noreply.github.com> Date: Sat, 8 Aug 2026 20:54:28 +0200 Subject: [PATCH 09/13] fix(release): authorize rollback before serialization (fixes #934) --- .github/workflows/rollback-release.yml | 43 +++++++++++++------ RELEASING.md | 2 +- .../2026-08-08-release-authority-design.md | 4 +- scripts/release-workflow-contract.test.mjs | 18 +++++++- 4 files changed, 51 insertions(+), 16 deletions(-) diff --git a/.github/workflows/rollback-release.yml b/.github/workflows/rollback-release.yml index cbb349f285..fbbfa45546 100644 --- a/.github/workflows/rollback-release.yml +++ b/.github/workflows/rollback-release.yml @@ -4,27 +4,22 @@ on: issue_comment: types: [created] -concurrency: - group: release-prime-agent - cancel-in-progress: false - queue: max - permissions: contents: read jobs: - rollback: + authorize: if: startsWith(github.event.comment.body, '/prime-agent release rollback ') runs-on: ubuntu-latest - environment: production permissions: contents: read + outputs: + confirmation: ${{ steps.context.outputs.confirmation }} + release_tag: ${{ steps.context.outputs.release_tag }} steps: - - name: Checkout release tooling and tags + - name: Checkout protected release tooling uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - fetch-depth: 0 - fetch-tags: true ref: ${{ github.workflow_sha }} persist-credentials: false @@ -55,18 +50,42 @@ jobs: WORKFLOW_REF: ${{ github.workflow_ref }} run: node scripts/resolve-rollback-context.mjs + rollback: + needs: authorize + runs-on: ubuntu-latest + environment: production + concurrency: + group: release-prime-agent + cancel-in-progress: false + queue: max + permissions: + contents: read + steps: + - name: Checkout release tooling and tags + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + fetch-tags: true + ref: ${{ github.workflow_sha }} + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + - name: Verify target and roll back stable pointers env: AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} AWS_DEFAULT_REGION: auto AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - CONFIRMATION: ${{ steps.context.outputs.confirmation }} + CONFIRMATION: ${{ needs.authorize.outputs.confirmation }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} R2_BUCKET: ${{ secrets.R2_BUCKET }} R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} R2_PUBLIC_BASE_URL: ${{ vars.R2_PUBLIC_BASE_URL }} - RELEASE_TAG: ${{ steps.context.outputs.release_tag }} + RELEASE_TAG: ${{ needs.authorize.outputs.release_tag }} run: | node scripts/publish-release.mjs rollback \ --release-tag "$RELEASE_TAG" \ diff --git a/RELEASING.md b/RELEASING.md index f3c3af9b73..e42747c640 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -99,7 +99,7 @@ Prefer a forward fix. If stable must be restored immediately, a maintainer with ROLLBACK vX.Y.Z ``` -The default-branch workflow and an API-derived maintainer permission are the repository-enforced authorization boundary. The job also targets the `production` environment, so configured environment reviewers provide an additional gate but are not assumed to exist. The job verifies the immutable tag, GitHub Release assets, manifest, checksums, and every referenced R2 tarball before writing `/stable` and then `/latest.json`. It does not create or move tags, rewrite immutable objects, or change GitHub Releases. +The default-branch workflow and an API-derived maintainer permission are the repository-enforced authorization boundary. Authorization and exact command parsing complete in an ungated preflight job before the workflow can acquire the shared release lock or enter the `production` environment, so an unauthorized comment cannot block publication. The mutation job targets the `production` environment, so configured environment reviewers provide an additional gate but are not assumed to exist. It verifies the immutable tag, GitHub Release assets, manifest, checksums, and every referenced R2 tarball before writing `/stable` and then `/latest.json`. It does not create or move tags, rewrite immutable objects, or change GitHub Releases. Rollback changes what fresh installations and future update checks select. It does not force already-installed newer clients to downgrade. diff --git a/docs/superpowers/specs/2026-08-08-release-authority-design.md b/docs/superpowers/specs/2026-08-08-release-authority-design.md index 55e6d0b110..ecae72a9d9 100644 --- a/docs/superpowers/specs/2026-08-08-release-authority-design.md +++ b/docs/superpowers/specs/2026-08-08-release-authority-design.md @@ -84,7 +84,7 @@ Beta publication keeps its stale-default-branch guard. After immutable uploads i ## Rollback -Rollback is a separate exact two-line issue-comment command authorized by protected default-branch workflow code and an API-derived `admin` or `maintain` permission. The workflow also names the `production` environment as an optional additional gate. It requires an existing stable `vX.Y.Z` tag and matching confirmation text. Normal publication treats the higher version from `/stable` and `/latest.json` as the monotonic floor; only this rollback path may lower both. +Rollback is a separate exact two-line issue-comment command authorized by protected default-branch workflow code and an API-derived `admin` or `maintain` permission. An ungated preflight job performs authorization and exact parsing before the downstream mutation job can acquire the shared release concurrency group or enter the `production` environment, so unauthorized comments cannot occupy either protected resource. The environment is an optional additional gate. Rollback requires an existing stable `vX.Y.Z` tag and matching confirmation text. Normal publication treats the higher version from `/stable` and `/latest.json` as the monotonic floor; only this rollback path may lower both. The rollback workflow verifies the tag target, GitHub Release, saved release manifest, checksums, and every referenced R2 artifact before changing channel state. It changes only `/stable` and `/latest.json`, in that order. It never creates or moves an immutable version tag and never deletes or overwrites a versioned object. @@ -112,7 +112,7 @@ Deterministic tests cover: - private/example workspace preservation during preparation; - immutable object create, identical retry, and mismatch failure decisions; - stable and beta pointer ordering; -- rollback confirmation, target verification, and pointer-only behavior; +- rollback preflight isolation, confirmation, target verification, and pointer-only behavior; - dry-run cleanup and absence of publish, commit, tag, push, GitHub Release, and R2 commands; - packed manifest, checksum, and internal URL correctness. diff --git a/scripts/release-workflow-contract.test.mjs b/scripts/release-workflow-contract.test.mjs index dc2daa9ac3..2e4fa1a838 100644 --- a/scripts/release-workflow-contract.test.mjs +++ b/scripts/release-workflow-contract.test.mjs @@ -63,9 +63,25 @@ test("rollback is a separately protected pointer-only workflow", () => { assert.match(rollbackWorkflow, /issue_comment:/); assert.match(rollbackWorkflow, /\/prime-agent release rollback/); assert.match(rollbackWorkflow, /collaborators\/\$\{ACTOR\}\/permission/); - assert.match(rollbackWorkflow, /environment: production/); assert.match(rollbackResolver, /confirmation/); assert.match(rollbackWorkflow, /node scripts\/resolve-rollback-context\.mjs/); assert.match(rollbackWorkflow, /node scripts\/publish-release\.mjs rollback/); assert.doesNotMatch(rollbackWorkflow, /gh release (?:create|edit|upload)|git (?:tag|push)|npm publish/); + + const authorizationJob = rollbackWorkflow.indexOf(" authorize:"); + const rollbackJob = rollbackWorkflow.indexOf(" rollback:"); + assert.ok(authorizationJob > -1); + assert.ok(rollbackJob > authorizationJob); + + const authorization = rollbackWorkflow.slice(authorizationJob, rollbackJob); + assert.doesNotMatch(authorization, /environment: production/); + assert.doesNotMatch(authorization, /group: release-prime-agent/); + assert.doesNotMatch(authorization, /R2_(?:ACCESS_KEY_ID|SECRET_ACCESS_KEY|BUCKET|ENDPOINT_URL)/); + + const mutation = rollbackWorkflow.slice(rollbackJob); + assert.match(mutation, /needs: authorize/); + assert.match(mutation, /environment: production/); + assert.match(mutation, /group: release-prime-agent/); + assert.match(mutation, /CONFIRMATION: \$\{\{ needs\.authorize\.outputs\.confirmation \}\}/); + assert.match(mutation, /RELEASE_TAG: \$\{\{ needs\.authorize\.outputs\.release_tag \}\}/); }); From b11d4cb7ab11aa08daa0453b9e9e27bb61262535 Mon Sep 17 00:00:00 2001 From: fettpl <38704082+fettpl@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:27:59 +0200 Subject: [PATCH 10/13] fix(ci): gate releases on exact tested SHA (fixes #927) --- .github/workflows/build-binaries.yml | 1 + .github/workflows/ci.yml | 80 +- .gitignore | 1 + package.json | 3 +- prime-agent-runtime/pyproject.toml | 8 +- prime-agent-runtime/uv.lock | 1480 ++++++++++++++++++++++++++ scripts/pack-prime-agent-release.mjs | 15 +- scripts/release-workflow.test.mjs | 324 ++++++ scripts/resolve-release-context.mjs | 1 + scripts/verify-ci-results.mjs | 37 + scripts/verify-release-artifacts.mjs | 154 +++ scripts/verify-release-gate.mjs | 46 + 12 files changed, 2139 insertions(+), 11 deletions(-) create mode 100644 prime-agent-runtime/uv.lock create mode 100644 scripts/release-workflow.test.mjs mode change 100755 => 100644 scripts/resolve-release-context.mjs create mode 100644 scripts/verify-ci-results.mjs create mode 100644 scripts/verify-release-artifacts.mjs create mode 100644 scripts/verify-release-gate.mjs diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 6657c5b08b..c0d34e42d0 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -282,3 +282,4 @@ jobs: --default-branch "$DEFAULT_BRANCH" \ --stable-installer /tmp/prime-agent-install.sh \ --beta-installer /tmp/prime-agent-install-beta.sh + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5debd95003..333f22ae90 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,9 +5,15 @@ on: branches: [main] pull_request: branches: [main] + workflow_call: + inputs: + source_sha: + description: Full commit SHA to verify + required: true + type: string concurrency: - group: ci-${{ github.ref }} + group: ci-${{ inputs.source_sha || github.ref }} cancel-in-progress: true permissions: @@ -21,6 +27,14 @@ jobs: steps: - name: Checkout uses: actions/checkout@v7 + with: + ref: ${{ inputs.source_sha || github.sha }} + persist-credentials: false + + - name: Verify source commit + env: + SOURCE_SHA: ${{ inputs.source_sha || github.sha }} + run: test "$(git rev-parse HEAD)" = "$SOURCE_SHA" - name: Setup Node.js uses: actions/setup-node@v7.0.0 @@ -89,6 +103,14 @@ jobs: steps: - name: Checkout uses: actions/checkout@v7 + with: + ref: ${{ inputs.source_sha || github.sha }} + persist-credentials: false + + - name: Verify source commit + env: + SOURCE_SHA: ${{ inputs.source_sha || github.sha }} + run: test "$(git rev-parse HEAD)" = "$SOURCE_SHA" - name: Setup Node.js uses: actions/setup-node@v7.0.0 @@ -118,16 +140,58 @@ jobs: working-directory: ${{ matrix.package }} run: ${{ matrix.command }} + python-runtime: + name: Python runtime tests + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + ref: ${{ inputs.source_sha || github.sha }} + persist-credentials: false + + - name: Verify source commit + env: + SOURCE_SHA: ${{ inputs.source_sha || github.sha }} + run: test "$(git rev-parse HEAD)" = "$SOURCE_SHA" + + - name: Setup Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: '3.11.15' + + - name: Install pinned uv + run: python -m pip install uv==0.11.33 + + - name: Install locked runtime test dependencies + working-directory: prime-agent-runtime + run: uv sync --frozen --group test + + - name: Test runtime + working-directory: prime-agent-runtime + run: uv run --frozen --group test python -m unittest discover -s test -p 'test_*.py' + build-check-test: name: build-check-test if: always() - needs: [build-check, test] + needs: [build-check, test, python-runtime] runs-on: ubuntu-latest steps: - - name: Verify CI results + - name: Checkout + uses: actions/checkout@v7 + with: + ref: ${{ inputs.source_sha || github.sha }} + persist-credentials: false + + - name: Verify source commit env: - BUILD_CHECK_RESULT: ${{ needs.build-check.result }} - TEST_RESULT: ${{ needs.test.result }} - run: | - test "$BUILD_CHECK_RESULT" = success - test "$TEST_RESULT" = success + SOURCE_SHA: ${{ inputs.source_sha || github.sha }} + run: test "$(git rev-parse HEAD)" = "$SOURCE_SHA" + + - name: Verify CI results + run: >- + node scripts/verify-ci-results.mjs + build-check=${{ needs.build-check.result }} + test=${{ needs.test.result }} + python-runtime=${{ needs.python-runtime.result }} diff --git a/.gitignore b/.gitignore index d8b22e91e6..322893d8f2 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ collect.sh __pycache__/ *.pyc +.venv/ diff --git a/package.json b/package.json index a5a0b8c5c5..493279f7ec 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,11 @@ "build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build", "dev": "concurrently --names \"ai,agent,coding-agent,tui\" --prefix-colors \"cyan,yellow,red,magenta\" \"cd packages/ai && npm run dev\" \"cd packages/agent && npm run dev\" \"cd packages/coding-agent && npm run dev\" \"cd packages/tui && npm run dev\"", "dev:tsc": "cd packages/ai && npm run dev:tsc", - "check": "biome check --write --error-on-warnings . && tsgo --noEmit && npm run check:installer && npm run check:browser-smoke", + "check": "biome check --write --error-on-warnings . && tsgo --noEmit && npm run check:installer && npm run check:browser-smoke && npm run check:release-workflow", "check:installer": "node scripts/check-installer-render.mjs", "check:browser-smoke": "node scripts/check-browser-smoke.mjs", "check:release-packages": "node scripts/check-release-package-contract.mjs", + "check:release-workflow": "node --test scripts/release-workflow.test.mjs", "profile:tui": "node scripts/profile-coding-agent-node.mjs --mode tui", "profile:rpc": "node scripts/profile-coding-agent-node.mjs --mode rpc", "test": "npm run test --workspaces --if-present", diff --git a/prime-agent-runtime/pyproject.toml b/prime-agent-runtime/pyproject.toml index 2cd0c0ccca..1a89a15072 100644 --- a/prime-agent-runtime/pyproject.toml +++ b/prime-agent-runtime/pyproject.toml @@ -9,8 +9,14 @@ dependencies = [ "tyro", ] +[dependency-groups] +test = [ + "httpx", + "mcp>=1.0", +] + [build-system] -requires = ["hatchling"] +requires = ["hatchling==1.27.0"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] diff --git a/prime-agent-runtime/uv.lock b/prime-agent-runtime/uv.lock new file mode 100644 index 0000000000..a64ac2b673 --- /dev/null +++ b/prime-agent-runtime/uv.lock @@ -0,0 +1,1480 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.11' and python_full_version < '3.14'", + "python_full_version < '3.11'", +] + +[options] +exclude-newer = "2026-08-01T14:06:46.468Z" + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "appnope" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/1e/faf0f247f6f881b98fc4d6d07e14085cb89d13665084e6d6ac1dc2c03d0b/asttokens-3.0.2.tar.gz", hash = "sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2", size = 63136, upload-time = "2026-07-12T03:31:49.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/2b/04b8a15f3a1c77bc79ddf5c73875327f34b4fa75982df2b76e45e402d364/asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933", size = 28702, upload-time = "2026-07-12T03:31:47.542Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/e9/6d7724983b3d5a0908dbf74f64038ade77c18646ff6636ec7894fd392ce1/cffi-2.1.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0", size = 183837, upload-time = "2026-07-06T21:32:09.655Z" }, + { url = "https://files.pythonhosted.org/packages/69/aa/24580a278de21fd7322635556334d9b535f1cbc00b0a3919447cdf464c65/cffi-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd", size = 184226, upload-time = "2026-07-06T21:32:11.196Z" }, + { url = "https://files.pythonhosted.org/packages/88/a9/02cae418ec4beb282ace11958d9d4737793439d561fadc7e6d56f2e2b354/cffi-2.1.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46", size = 211107, upload-time = "2026-07-06T21:32:12.328Z" }, + { url = "https://files.pythonhosted.org/packages/3b/30/c806937ed5e4c2c7ac30d9d6b76b5dc57ff8b75d83800d9bb11a8253cf2a/cffi-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2", size = 218733, upload-time = "2026-07-06T21:32:13.67Z" }, + { url = "https://files.pythonhosted.org/packages/f9/cf/398272b8bbfd58aa314fda5a7f1cdbb26d1d78ae324a11211521315dd1f0/cffi-2.1.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd", size = 205543, upload-time = "2026-07-06T21:32:15.148Z" }, + { url = "https://files.pythonhosted.org/packages/45/ca/f91641185cdd90c36d317a9dc7f85e88ef8682d8b300977baff5e23c35d8/cffi-2.1.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3", size = 205460, upload-time = "2026-07-06T21:32:16.479Z" }, + { url = "https://files.pythonhosted.org/packages/38/66/04781a77b411f0bb5b234d62c1814754ab75ebe455ccff1b08e8d7aae98f/cffi-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0", size = 218760, upload-time = "2026-07-06T21:32:17.98Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9a/bb1d5ed9c3fcae158e9f6391bf309c95d98c2ac37ed56573228471d0af5e/cffi-2.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43", size = 221230, upload-time = "2026-07-06T21:32:19.407Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/3c1409cdd26094efacd1c36c66e0a6eb9d4296e4fd4f9901b8b2042f4323/cffi-2.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c", size = 213524, upload-time = "2026-07-06T21:32:20.828Z" }, + { url = "https://files.pythonhosted.org/packages/fa/75/74dfb7c3fc6ebbd408038476bd4c1d7e925c62614e7b9c534ecc34218288/cffi-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd", size = 220341, upload-time = "2026-07-06T21:32:21.9Z" }, + { url = "https://files.pythonhosted.org/packages/70/b6/9003c33a3e7d2c1306f5962e646457dcfe5a8cd8fce6bbe02d7af25db783/cffi-2.1.0-cp310-cp310-win32.whl", hash = "sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f", size = 174578, upload-time = "2026-07-06T21:32:23.073Z" }, + { url = "https://files.pythonhosted.org/packages/8a/26/710688310447531c7a22f857c7f79d9855ec18b03e04494ced723fb37e2f/cffi-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da", size = 185071, upload-time = "2026-07-06T21:32:24.671Z" }, + { url = "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", size = 183845, upload-time = "2026-07-06T21:32:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", size = 184186, upload-time = "2026-07-06T21:32:28.025Z" }, + { url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892, upload-time = "2026-07-06T21:32:29.565Z" }, + { url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793, upload-time = "2026-07-06T21:32:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737, upload-time = "2026-07-06T21:32:32.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", size = 204909, upload-time = "2026-07-06T21:32:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", size = 217883, upload-time = "2026-07-06T21:32:35.173Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", size = 221251, upload-time = "2026-07-06T21:32:36.527Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", size = 214250, upload-time = "2026-07-06T21:32:37.852Z" }, + { url = "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", size = 219441, upload-time = "2026-07-06T21:32:39.146Z" }, + { url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496, upload-time = "2026-07-06T21:32:40.467Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113, upload-time = "2026-07-06T21:32:41.761Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927, upload-time = "2026-07-06T21:32:42.961Z" }, + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, + { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, + { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, + { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, + { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, + { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "comm" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" }, + { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" }, + { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" }, + { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" }, +] + +[[package]] +name = "debugpy" +version = "1.8.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/aa/12037145b7a56eaa5b29b41872f7a21b538e807e13f32c4d3c46e59be084/debugpy-1.8.21.tar.gz", hash = "sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6", size = 1697577, upload-time = "2026-06-01T19:30:35.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/f3/6b1d4c71f4cbb5360009f928934a03b42906f28fc7b3f7f35f04e58acead/debugpy-1.8.21-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:8eeab7b5462f683452c57c0126aaa5ec4e974ddb705f39ba87dff8818c8e08f9", size = 2113873, upload-time = "2026-06-01T19:30:37.148Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f2/17c3bf91cebc173bfbf5734cd2669723d0a35c0cf9d2fd2124546efeae83/debugpy-1.8.21-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:0fddfdc130ac6d8bfc0415b0409822fa901c8f310e5c945ac5653a0352532344", size = 3004715, upload-time = "2026-06-01T19:30:38.888Z" }, + { url = "https://files.pythonhosted.org/packages/5a/22/1f8efd80c7b5909e760f9cfd0c9e8681d2d35d532f7c0a40760cd4da4a19/debugpy-1.8.21-cp310-cp310-win32.whl", hash = "sha256:72b5d676c4cbfac3bac5bb01c138a4656e843f93f03ce2a5f4e394ad49fbee73", size = 5303455, upload-time = "2026-06-01T19:30:40.52Z" }, + { url = "https://files.pythonhosted.org/packages/da/ce/54c79abd6cccef92fa7b43d97e3acafedf4d645557267ece05e948b5e4b8/debugpy-1.8.21-cp310-cp310-win_amd64.whl", hash = "sha256:a7fe47fd23da57b9e0bec3f4a8ee65a2dc55782455ed7f2141d75ab5d2eaeef5", size = 5331751, upload-time = "2026-06-01T19:30:42.146Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/cbf306d6e07a313a91e7171a98669054502840931432c227cfd505ee367f/debugpy-1.8.21-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:da456226c7b4c69e35dbe35dcee6623d912000a77816db7856a41af1c72a0264", size = 2203120, upload-time = "2026-06-01T19:30:43.964Z" }, + { url = "https://files.pythonhosted.org/packages/aa/57/aa739bd4ad2cbf96aeb1b20b56918ddd5ae4c28b68709bfcd327f02123ee/debugpy-1.8.21-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:f68b891688e61bdc08b8d364d919ff0051e0b94657b39dcd027bc3173edb7cdc", size = 3059958, upload-time = "2026-06-01T19:30:45.622Z" }, + { url = "https://files.pythonhosted.org/packages/a8/31/453d2c9a23d133fe2c8ec7ca1d816ded52a913487fe3ffef7c01b4b706af/debugpy-1.8.21-cp311-cp311-win32.whl", hash = "sha256:f843a8b08c2edeaf9b1582eed4f25441af21a297c22ff16bf76a662557aa9c9e", size = 5236515, upload-time = "2026-06-01T19:30:47.461Z" }, + { url = "https://files.pythonhosted.org/packages/60/94/6660de2f2d7bf388f229335ba4637646eebabdbf38564cb439a95a9193c9/debugpy-1.8.21-cp311-cp311-win_amd64.whl", hash = "sha256:84c564d8cc701d41843b29a92814c1f1bef6798724ca9d675c284ad9f6a547d7", size = 5256138, upload-time = "2026-06-01T19:30:49.113Z" }, + { url = "https://files.pythonhosted.org/packages/a2/df/bf625547431a9cadc9f4cbfeda38866e2b17f6aed147b625377e87834449/debugpy-1.8.21-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:9f96713896f39c3dff0ee841f47320c3f2983d33c341e009361bb0ebc79adc4e", size = 2483609, upload-time = "2026-06-01T19:30:50.794Z" }, + { url = "https://files.pythonhosted.org/packages/bf/09/59324b903599031ff9faaec1758292409f6561a0ec2492fe4b703327705a/debugpy-1.8.21-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:c193d474f0a211191f2b4449d2d06157c689013035bd952f3b617e0ef422b176", size = 3968900, upload-time = "2026-06-01T19:30:52.341Z" }, + { url = "https://files.pythonhosted.org/packages/14/cd/27f65b805d7fe005c44e1a36b9183ecdfbcdbf9d3e721a5115d461ecc7ee/debugpy-1.8.21-cp312-cp312-win32.whl", hash = "sha256:4743373c1cac7f9e74a1b9915bf1dbe0e900eca657ffb170ae07ac8363205ae9", size = 5336340, upload-time = "2026-06-01T19:30:54.047Z" }, + { url = "https://files.pythonhosted.org/packages/77/1d/c84e30c0c674184948b66f076ab271c01d940618a2824c23cd035a27bc20/debugpy-1.8.21-cp312-cp312-win_amd64.whl", hash = "sha256:bd7ba9dd3daa7c2f942c6ca8d4695a16bf9ac16b63615261c7982bc74f7ed20c", size = 5374751, upload-time = "2026-06-01T19:30:55.891Z" }, + { url = "https://files.pythonhosted.org/packages/77/6b/d817e1f8cc77aa055d37fba092e0febfdff40fe652d8d53d4cd7a86ad98d/debugpy-1.8.21-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:13678151fc401e2d68c9880b91e28714f797d40422994572b24560ef80910a88", size = 2477398, upload-time = "2026-06-01T19:30:57.644Z" }, + { url = "https://files.pythonhosted.org/packages/48/57/412421516afc3055fa577516f00beec3d663f9b0ab330639547ae6c57720/debugpy-1.8.21-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:ecbd158386c31ffe71d46f72d44d56e66331ab9b16cad649156d514368f23ab2", size = 3962096, upload-time = "2026-06-01T19:30:59.235Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2c616337cf6ba7b07ebbc97f02c6c945a8e2f76b365e33ee809c32ee36d1/debugpy-1.8.21-cp313-cp313-win32.whl", hash = "sha256:2c2ae706dec41d99a9ca1f7ebc987a83e65578363be6f6b3ac9067504917fae1", size = 5336288, upload-time = "2026-06-01T19:31:00.79Z" }, + { url = "https://files.pythonhosted.org/packages/f8/99/9175103392f84c4b1bf7622888cdc68da07f0ff7d9e581266428f6776033/debugpy-1.8.21-cp313-cp313-win_amd64.whl", hash = "sha256:aa648733047443eb1d07682c4ef287d36a54507b643ffdf38b09a3ef002c72a0", size = 5376567, upload-time = "2026-06-01T19:31:02.56Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3d/f4bbb323a548bfab2af3d6b4ffd9bf22636e55956a1285d317a1de643aad/debugpy-1.8.21-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9bb2a685287a2ac9b181cde89edcec64845cb51de7faaa75badb9a698bc24782", size = 2477209, upload-time = "2026-06-01T19:31:04.157Z" }, + { url = "https://files.pythonhosted.org/packages/8c/2d/6e7ec524984a1702777868de49a4c53202bddac2a432a76a093469587750/debugpy-1.8.21-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:3d6922439bf33fd38a3e2c447869ebc7b97da5cd3d329ff1ef9bc06c4903437e", size = 3927115, upload-time = "2026-06-01T19:31:05.863Z" }, + { url = "https://files.pythonhosted.org/packages/97/47/d1aa6d64005a98a9144647d99306b419396f9ad7bf1d73c119e17a81fb4d/debugpy-1.8.21-cp314-cp314-win32.whl", hash = "sha256:15d4963bd5ffa48f0da0947fd06757fa7621945048a14ad7705431566d3c0e7c", size = 5336724, upload-time = "2026-06-01T19:31:07.711Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/b905b90d163af11878c1af8abafa4a25206335e112e284e413454543a6da/debugpy-1.8.21-cp314-cp314-win_amd64.whl", hash = "sha256:fe0744a12353406de0ae8ccff0d0a4a666f00801a3db8fd04e7a5f761cd520e8", size = 5373803, upload-time = "2026-06-01T19:31:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92", size = 5352888, upload-time = "2026-06-01T19:31:25.186Z" }, +] + +[[package]] +name = "decorator" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpcore2" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/a8/20ed1ed79cbc2ecdf5301c0968ab7c85547212e2a7bd126ddd2d986e206e/httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2", size = 67089, upload-time = "2026-07-24T09:21:03.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/fb/46c52b781975c335a2bcf1072c7bbc007cbdc8d674217f5ee1daba2c848b/httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26", size = 82809, upload-time = "2026-07-24T09:21:01.178Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx2" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/14/38128fbafd7e0ed41d874df6c9a653d47c2d111cfe59e2b4ac95161b4abd/httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a", size = 95458, upload-time = "2026-07-24T09:21:04.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/b8/cfd91c4ab9134d386d48f0b6ac662ff3d4be6efdee59ee1c67ebc3c0487c/httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a", size = 91191, upload-time = "2026-07-24T09:21:02.6Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "ipykernel" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appnope", marker = "sys_platform == 'darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio2" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/c4/e4a38f579de4225a561305666f7541cdabb30075def2aa1ac17bd73c1fb5/ipykernel-7.3.0.tar.gz", hash = "sha256:9acaaaf97d16355166e4085afe9d225bfbdf2b7ef520f9df3be8f2b248275e09", size = 184899, upload-time = "2026-06-10T08:41:25.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/02/77b271f5dc58bfbc0b577c877b2365d1ffea2afe66a80c13f2312820348c/ipykernel-7.3.0-py3-none-any.whl", hash = "sha256:897eb64da762549ef610698fca5e9675195ec6ac8ec7f19d81ce1ca20c876057", size = 120583, upload-time = "2026-06-10T08:41:23.648Z" }, +] + +[[package]] +name = "ipython" +version = "8.39.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "exceptiongroup" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/56/4cc7fc9e9e3f38fd324f24f8afe0ad8bb5fa41283f37f1aaf9de0612c968/ipython-8.39.0-py3-none-any.whl", hash = "sha256:bb3c51c4fa8148ab1dea07a79584d1c854e234ea44aa1283bcb37bc75054651f", size = 831849, upload-time = "2026-03-27T10:02:07.846Z" }, +] + +[[package]] +name = "ipython" +version = "9.16.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.11' and python_full_version < '3.14'", +] +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/49/04360f83b4d110195751b4171b75dc1cd7b97ba122b18da34b5828172d59/ipython-9.16.0.tar.gz", hash = "sha256:d2f92587b1ef51d84f934dffe05fabb9255f0038ed0a21426f2ea761e39ad09a", size = 4515375, upload-time = "2026-07-31T08:02:51.977Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/82/d30656b9eb33b8ed4e421ca55c13c7fff412086f0405bbe53c39a7ee4a3b/ipython-9.16.0-py3-none-any.whl", hash = "sha256:3d02b96de2a59074d153b1ac1c3865de738df114e430e879e6e5ef100a4d470c", size = 625973, upload-time = "2026-07-31T08:02:50.114Z" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, +] + +[[package]] +name = "jedi" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "jupyter-client" +version = "8.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/dc/5512503b088997c2250b8bf18258fba9d9ce5ead641183700960d3c9d342/jupyter_client-8.9.1.tar.gz", hash = "sha256:a58f730dd9e728ba16ba1d62ebccf7ffe1ebbdbce4e95cfae941b7321ae1f4fa", size = 359256, upload-time = "2026-06-09T13:15:01.033Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/6f/56d39bf385c5c27988aebaf0c18a2a17e960575740100973511018bd904e/jupyter_client-8.9.1-py3-none-any.whl", hash = "sha256:0b7a295bc46e8751e9adae84781f726c851c1d911bd793edc4a3bde942e3da81", size = 109828, upload-time = "2026-06-09T13:14:58.835Z" }, +] + +[[package]] +name = "jupyter-core" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, +] + +[[package]] +name = "mcp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2" }, + { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/33/32d4dff2c95bb5d897c3ef4c83649a08996b17b58f0a326d2495d4c81179/mcp-2.0.0.tar.gz", hash = "sha256:0f440e735c13ece8bb19bc62cf0b86f4313448432fbb77d35e14034f4e050728", size = 1662284, upload-time = "2026-07-28T13:45:32.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/72/7d7897418912c1d12e87556630dfb7bf0eac71160e9bef8b447960804ee3/mcp-2.0.0-py3-none-any.whl", hash = "sha256:1cb4c75d2d2c7b8c1d756355e5d82a39f2822cc7f13e22a2051d7ca3592349d6", size = 349980, upload-time = "2026-07-28T13:45:28.853Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/56/9b8e1c152f61f6c6b07c4b5896c88c7d0ae90bac6ee6306f852fcc5c1eb0/mcp_types-2.0.0.tar.gz", hash = "sha256:d7d939b9285c9961ae8866ba75ef85da34d12bafe276efbf4eb6a131786d8379", size = 66632, upload-time = "2026-07-28T13:45:33.804Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/4c/c78d78c3d52b0ac594ad7cc8ef5972adfe070e3597a8a4c6ce0cd39196ea/mcp_types-2.0.0-py3-none-any.whl", hash = "sha256:6b2de797ca2797f568b79529e1b25948e34de511bcc0bd82fef1039a6d1b8eb0", size = 69649, upload-time = "2026-07-28T13:45:30.713Z" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + +[[package]] +name = "nest-asyncio2" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/73/731debf26e27e0a0323d7bda270dc2f634b398e38f040a09da1f4351d0aa/nest_asyncio2-1.7.2.tar.gz", hash = "sha256:1921d70b92cc4612c374928d081552efb59b83d91b2b789d935c665fa01729a8", size = 14743, upload-time = "2026-02-13T00:34:04.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/3c/3179b85b0e1c3659f0369940200cd6d0fa900e6cefcc7ea0bc6dd0e29ffb/nest_asyncio2-1.7.2-py3-none-any.whl", hash = "sha256:f5dfa702f3f81f6a03857e9a19e2ba578c0946a4ad417b4c50a24d7ba641fe01", size = 7843, upload-time = "2026-02-13T00:34:02.691Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "parso" +version = "0.8.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" }, +] + +[[package]] +name = "prime-agent-runtime" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "ipykernel" }, + { name = "nest-asyncio" }, + { name = "tyro" }, +] + +[package.dev-dependencies] +test = [ + { name = "httpx" }, + { name = "mcp" }, +] + +[package.metadata] +requires-dist = [ + { name = "ipykernel" }, + { name = "nest-asyncio" }, + { name = "tyro" }, +] + +[package.metadata.requires-dev] +test = [ + { name = "httpx" }, + { name = "mcp", specifier = ">=1.0" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.53" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/1b/9cfdeac80ee45bebbbcb31f1b7b99a0d81a1c72de48d837be984e0e88b1d/pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e", size = 6361387, upload-time = "2026-06-04T07:49:14.329Z" }, + { url = "https://files.pythonhosted.org/packages/33/b1/7afc96d041d982c27bc2df6f853d43f01fd273e3d39d04be3647ddeb533d/pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db", size = 6926780, upload-time = "2026-06-04T07:49:16.881Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/4140da9ad54108e517f4a16b2d83da3033e08662144623e1239587cb7db6/pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd", size = 4307203, upload-time = "2026-06-04T07:49:18.993Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "pyzmq" +version = "27.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/b9/52aa9ec2867528b54f1e60846728d8b4d84726630874fee3a91e66c7df81/pyzmq-27.1.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:508e23ec9bc44c0005c4946ea013d9317ae00ac67778bd47519fdf5a0e930ff4", size = 1329850, upload-time = "2025-09-08T23:07:26.274Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/5653e7b7425b169f994835a2b2abf9486264401fdef18df91ddae47ce2cc/pyzmq-27.1.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:507b6f430bdcf0ee48c0d30e734ea89ce5567fd7b8a0f0044a369c176aa44556", size = 906380, upload-time = "2025-09-08T23:07:29.78Z" }, + { url = "https://files.pythonhosted.org/packages/73/78/7d713284dbe022f6440e391bd1f3c48d9185673878034cfb3939cdf333b2/pyzmq-27.1.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf7b38f9fd7b81cb6d9391b2946382c8237fd814075c6aa9c3b746d53076023b", size = 666421, upload-time = "2025-09-08T23:07:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/30/76/8f099f9d6482450428b17c4d6b241281af7ce6a9de8149ca8c1c649f6792/pyzmq-27.1.0-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03ff0b279b40d687691a6217c12242ee71f0fba28bf8626ff50e3ef0f4410e1e", size = 854149, upload-time = "2025-09-08T23:07:33.17Z" }, + { url = "https://files.pythonhosted.org/packages/59/f0/37fbfff06c68016019043897e4c969ceab18bde46cd2aca89821fcf4fb2e/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:677e744fee605753eac48198b15a2124016c009a11056f93807000ab11ce6526", size = 1655070, upload-time = "2025-09-08T23:07:35.205Z" }, + { url = "https://files.pythonhosted.org/packages/47/14/7254be73f7a8edc3587609554fcaa7bfd30649bf89cd260e4487ca70fdaa/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd2fec2b13137416a1c5648b7009499bcc8fea78154cd888855fa32514f3dad1", size = 2033441, upload-time = "2025-09-08T23:07:37.432Z" }, + { url = "https://files.pythonhosted.org/packages/22/dc/49f2be26c6f86f347e796a4d99b19167fc94503f0af3fd010ad262158822/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:08e90bb4b57603b84eab1d0ca05b3bbb10f60c1839dc471fc1c9e1507bef3386", size = 1891529, upload-time = "2025-09-08T23:07:39.047Z" }, + { url = "https://files.pythonhosted.org/packages/a3/3e/154fb963ae25be70c0064ce97776c937ecc7d8b0259f22858154a9999769/pyzmq-27.1.0-cp310-cp310-win32.whl", hash = "sha256:a5b42d7a0658b515319148875fcb782bbf118dd41c671b62dae33666c2213bda", size = 567276, upload-time = "2025-09-08T23:07:40.695Z" }, + { url = "https://files.pythonhosted.org/packages/62/b2/f4ab56c8c595abcb26b2be5fd9fa9e6899c1e5ad54964e93ae8bb35482be/pyzmq-27.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:c0bb87227430ee3aefcc0ade2088100e528d5d3298a0a715a64f3d04c60ba02f", size = 632208, upload-time = "2025-09-08T23:07:42.298Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e3/be2cc7ab8332bdac0522fdb64c17b1b6241a795bee02e0196636ec5beb79/pyzmq-27.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:9a916f76c2ab8d045b19f2286851a38e9ac94ea91faf65bd64735924522a8b32", size = 559766, upload-time = "2025-09-08T23:07:43.869Z" }, + { url = "https://files.pythonhosted.org/packages/06/5d/305323ba86b284e6fcb0d842d6adaa2999035f70f8c38a9b6d21ad28c3d4/pyzmq-27.1.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:226b091818d461a3bef763805e75685e478ac17e9008f49fce2d3e52b3d58b86", size = 1333328, upload-time = "2025-09-08T23:07:45.946Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a0/fc7e78a23748ad5443ac3275943457e8452da67fda347e05260261108cbc/pyzmq-27.1.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0790a0161c281ca9723f804871b4027f2e8b5a528d357c8952d08cd1a9c15581", size = 908803, upload-time = "2025-09-08T23:07:47.551Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/37d15eb05f3bdfa4abea6f6d96eb3bb58585fbd3e4e0ded4e743bc650c97/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c895a6f35476b0c3a54e3eb6ccf41bf3018de937016e6e18748317f25d4e925f", size = 668836, upload-time = "2025-09-08T23:07:49.436Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c4/2a6fe5111a01005fc7af3878259ce17684fabb8852815eda6225620f3c59/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bbf8d3630bf96550b3be8e1fc0fea5cbdc8d5466c1192887bd94869da17a63e", size = 857038, upload-time = "2025-09-08T23:07:51.234Z" }, + { url = "https://files.pythonhosted.org/packages/cb/eb/bfdcb41d0db9cd233d6fb22dc131583774135505ada800ebf14dfb0a7c40/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:15c8bd0fe0dabf808e2d7a681398c4e5ded70a551ab47482067a572c054c8e2e", size = 1657531, upload-time = "2025-09-08T23:07:52.795Z" }, + { url = "https://files.pythonhosted.org/packages/ab/21/e3180ca269ed4a0de5c34417dfe71a8ae80421198be83ee619a8a485b0c7/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bafcb3dd171b4ae9f19ee6380dfc71ce0390fefaf26b504c0e5f628d7c8c54f2", size = 2034786, upload-time = "2025-09-08T23:07:55.047Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b1/5e21d0b517434b7f33588ff76c177c5a167858cc38ef740608898cd329f2/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e829529fcaa09937189178115c49c504e69289abd39967cd8a4c215761373394", size = 1894220, upload-time = "2025-09-08T23:07:57.172Z" }, + { url = "https://files.pythonhosted.org/packages/03/f2/44913a6ff6941905efc24a1acf3d3cb6146b636c546c7406c38c49c403d4/pyzmq-27.1.0-cp311-cp311-win32.whl", hash = "sha256:6df079c47d5902af6db298ec92151db82ecb557af663098b92f2508c398bb54f", size = 567155, upload-time = "2025-09-08T23:07:59.05Z" }, + { url = "https://files.pythonhosted.org/packages/23/6d/d8d92a0eb270a925c9b4dd039c0b4dc10abc2fcbc48331788824ef113935/pyzmq-27.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:190cbf120fbc0fc4957b56866830def56628934a9d112aec0e2507aa6a032b97", size = 633428, upload-time = "2025-09-08T23:08:00.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/14/01afebc96c5abbbd713ecfc7469cfb1bc801c819a74ed5c9fad9a48801cb/pyzmq-27.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:eca6b47df11a132d1745eb3b5b5e557a7dae2c303277aa0e69c6ba91b8736e07", size = 559497, upload-time = "2025-09-08T23:08:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, + { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" }, + { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" }, + { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" }, + { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" }, + { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" }, + { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" }, + { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" }, + { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" }, + { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, + { url = "https://files.pythonhosted.org/packages/f3/81/a65e71c1552f74dec9dff91d95bafb6e0d33338a8dfefbc88aa562a20c92/pyzmq-27.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c17e03cbc9312bee223864f1a2b13a99522e0dc9f7c5df0177cd45210ac286e6", size = 836266, upload-time = "2025-09-08T23:09:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/58/ed/0202ca350f4f2b69faa95c6d931e3c05c3a397c184cacb84cb4f8f42f287/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f328d01128373cb6763823b2b4e7f73bdf767834268c565151eacb3b7a392f90", size = 800206, upload-time = "2025-09-08T23:09:41.902Z" }, + { url = "https://files.pythonhosted.org/packages/47/42/1ff831fa87fe8f0a840ddb399054ca0009605d820e2b44ea43114f5459f4/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1790386614232e1b3a40a958454bdd42c6d1811837b15ddbb052a032a43f62", size = 567747, upload-time = "2025-09-08T23:09:43.741Z" }, + { url = "https://files.pythonhosted.org/packages/d1/db/5c4d6807434751e3f21231bee98109aa57b9b9b55e058e450d0aef59b70f/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:448f9cb54eb0cee4732b46584f2710c8bc178b0e5371d9e4fc8125201e413a74", size = 747371, upload-time = "2025-09-08T23:09:45.575Z" }, + { url = "https://files.pythonhosted.org/packages/26/af/78ce193dbf03567eb8c0dc30e3df2b9e56f12a670bf7eb20f9fb532c7e8a/pyzmq-27.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:05b12f2d32112bf8c95ef2e74ec4f1d4beb01f8b5e703b38537f8849f92cb9ba", size = 544862, upload-time = "2025-09-08T23:09:47.448Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c6/c4dcdecdbaa70969ee1fdced6d7b8f60cfabe64d25361f27ac4665a70620/pyzmq-27.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:18770c8d3563715387139060d37859c02ce40718d1faf299abddcdcc6a649066", size = 836265, upload-time = "2025-09-08T23:09:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/3e/79/f38c92eeaeb03a2ccc2ba9866f0439593bb08c5e3b714ac1d553e5c96e25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ac25465d42f92e990f8d8b0546b01c391ad431c3bf447683fdc40565941d0604", size = 800208, upload-time = "2025-09-08T23:09:51.073Z" }, + { url = "https://files.pythonhosted.org/packages/49/0e/3f0d0d335c6b3abb9b7b723776d0b21fa7f3a6c819a0db6097059aada160/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53b40f8ae006f2734ee7608d59ed661419f087521edbfc2149c3932e9c14808c", size = 567747, upload-time = "2025-09-08T23:09:52.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cf/f2b3784d536250ffd4be70e049f3b60981235d70c6e8ce7e3ef21e1adb25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f605d884e7c8be8fe1aa94e0a783bf3f591b84c24e4bc4f3e7564c82ac25e271", size = 747371, upload-time = "2025-09-08T23:09:54.563Z" }, + { url = "https://files.pythonhosted.org/packages/01/1b/5dbe84eefc86f48473947e2f41711aded97eecef1231f4558f1f02713c12/pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355", size = 544862, upload-time = "2025-09-08T23:09:56.509Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.11' and python_full_version < '3.14'", +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.4.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/10/a34c656829ffc1c4b22ef36d70d9ebb6b99c020e2aeb17cee5485099f028/sse_starlette-3.4.6.tar.gz", hash = "sha256:725f8a1bd6d26ae1b2c9610c0ef5065dfdd496f3988d28adcf8c4b49dc25c627", size = 32542, upload-time = "2026-07-20T14:16:32.201Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/36/e10c1d1b7ca881d2625db2ec28508578499187bb1c389952c398474e1834/sse_starlette-3.4.6-py3-none-any.whl", hash = "sha256:56217ab4c9a9f9c5db7b21e08732d3e7c2b807f45231ad23de0551a24c4a41f6", size = 16516, upload-time = "2026-07-20T14:16:30.978Z" }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "tornado" +version = "6.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" }, + { url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" }, + { url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" }, + { url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" }, + { url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" }, + { url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" }, + { url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" }, +] + +[[package]] +name = "traitlets" +version = "5.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/a1/d7e7d9f461575d8bb77e3c3bd78a6cdfdd2bb4a06bfbbb8a0e1f51ab7bc2/traitlets-5.16.0.tar.gz", hash = "sha256:7de0a3fabaf5971ff15c8905545f9febfa850309fb8e86e1b42bdb5b46b293ed", size = 165946, upload-time = "2026-07-31T12:23:49.785Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/bd/f8607e908605262e4926cbfd2560094bc5d04ef7f8aff1340e7fff503016/traitlets-5.16.0-py3-none-any.whl", hash = "sha256:94a9967ba45e89e837cf9934029c8d019bea9149cfffa115ed8c1900f679beba", size = 86093, upload-time = "2026-07-31T12:23:47.533Z" }, +] + +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + +[[package]] +name = "typeguard" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/de/4420db493fa8fc0856d5e5c1b159c63a323d2de2317babe36b01568928e8/typeguard-4.6.0.tar.gz", hash = "sha256:e7414f09111317de3e335de92cd397c5c0ca00b1cc1676de12e1d444a79b3f21", size = 82330, upload-time = "2026-07-26T08:40:23.207Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/eb/461d5f167b6f5c7d97696f397c82f82e3480e003fce3f0a1cd1dd26e2eb2/typeguard-4.6.0-py3-none-any.whl", hash = "sha256:79878165bb86f2cf5d41d159a0ff1792a796cf496882d2fe1b1c6c7049b9cdd7", size = 36884, upload-time = "2026-07-26T08:40:21.868Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tyro" +version = "1.0.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docstring-parser" }, + { name = "typeguard" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/78/a5749a6c1ee9abc2999e294f339f8f72476d1a60bb95fc0e86156aafed3b/tyro-1.0.15.tar.gz", hash = "sha256:3f1d60887723eecb9c489f195d11f079c4a1f33df74b723552ad31ec57c667bb", size = 593822, upload-time = "2026-06-20T08:48:28.364Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/28/d607636187cf6c18eb72efb5d65c1d1b9e451db03d84676f835dd488fdbd/tyro-1.0.15-py3-none-any.whl", hash = "sha256:982da1d566005f1b2a6b56f6be6c6929c96f0e5fab9d61bc6097573ddfaf8d13", size = 215045, upload-time = "2026-06-20T08:48:27.104Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.52.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/c8/2d307868453a4bca6e64fa3581d122ae0748a0869c53f159339def179c7c/uvicorn-0.52.0.tar.gz", hash = "sha256:ca8876ad6c1983f394157c168b39d52f6dd56dabf5602fa0982751cffc2293ae", size = 97504, upload-time = "2026-07-29T08:45:34.065Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/e6/b5c0630ace9757232aec07112be8146b812787db52141ff9d50674aa7634/uvicorn-0.52.0-py3-none-any.whl", hash = "sha256:3d887809810b89ed33501bcf0a9aba469b06ecd608158efce04bd6b48d8c9b08", size = 79058, upload-time = "2026-07-29T08:45:32.492Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, +] diff --git a/scripts/pack-prime-agent-release.mjs b/scripts/pack-prime-agent-release.mjs index 9e513fbc24..6cca5667fd 100644 --- a/scripts/pack-prime-agent-release.mjs +++ b/scripts/pack-prime-agent-release.mjs @@ -23,6 +23,7 @@ const defaultOutputDir = join(root, "packages", "coding-agent", "release"); const defaultBaseUrl = process.env.PRIME_AGENT_DOWNLOAD_BASE_URL; const publicPackageName = process.env.PRIME_AGENT_PACKAGE_NAME || "prime-agent"; const publicCommandName = process.env.PRIME_AGENT_CMD || "prime-agent"; +const defaultSourceSha = process.env.PRIME_AGENT_SOURCE_SHA; const releaseChannels = new Set(["stable", "beta"]); const releasePackages = [ @@ -37,6 +38,7 @@ function parseArgs(args) { baseUrl: defaultBaseUrl, channel: "stable", outDir: defaultOutputDir, + sourceSha: defaultSourceSha, version: undefined, }; @@ -66,6 +68,13 @@ function parseArgs(args) { i += 1; break; } + case "--source-sha": { + const value = args[i + 1]; + if (!value) throw new Error("--source-sha requires a value"); + parsed.sourceSha = value; + i += 1; + break; + } case "--version": { const value = args[i + 1]; if (!value) throw new Error("--version requires a value"); @@ -86,13 +95,16 @@ function parseArgs(args) { if (!parsed.baseUrl) { throw new Error("--base-url or PRIME_AGENT_DOWNLOAD_BASE_URL is required"); } + if (!parsed.sourceSha || !/^[0-9a-f]{40}$/.test(parsed.sourceSha)) { + throw new Error("--source-sha or PRIME_AGENT_SOURCE_SHA must be a full 40-character commit SHA"); + } parsed.baseUrl = parsed.baseUrl.replace(/\/+$/, ""); return parsed; } function printHelp() { - console.log(`Usage: node scripts/pack-prime-agent-release.mjs --base-url url [--channel stable|beta] [--version x.y.z] [--out-dir path] + console.log(`Usage: node scripts/pack-prime-agent-release.mjs --base-url url --source-sha sha [--channel stable|beta] [--version x.y.z] [--out-dir path] Creates private npm tarballs for R2 distribution: @@ -330,6 +342,7 @@ function main() { const manifestName = args.channel === "stable" ? "latest.json" : "beta.json"; writeJson(join(artifactsDir, manifestName), { version: `v${releaseVersion}`, + sourceSha: args.sourceSha, package: publicPackageName, tarball: `releases/v${releaseVersion}/${artifactFiles.get("coding-agent")}`, tarballs: tarballs.map((tarball) => ({ diff --git a/scripts/release-workflow.test.mjs b/scripts/release-workflow.test.mjs new file mode 100644 index 0000000000..f29f8b7564 --- /dev/null +++ b/scripts/release-workflow.test.mjs @@ -0,0 +1,324 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, it } from "node:test"; + +import { assertLockstepVersions, resolveReleaseContext } from "./resolve-release-context.mjs"; +import { verifyCiResults } from "./verify-ci-results.mjs"; +import { verifyReleaseGate } from "./verify-release-gate.mjs"; +import { verifyReleaseArtifacts } from "./verify-release-artifacts.mjs"; + +const buildSha = "a".repeat(40); +const otherSha = "b".repeat(40); +const packageVersions = { + root: "0.7.1", + agent: "0.7.1", + ai: "0.7.1", + "coding-agent": "0.7.1", + tui: "0.7.1", +}; +const releaseWorkflow = readFileSync(new URL("../.github/workflows/build-binaries.yml", import.meta.url), "utf8"); +const ciWorkflow = readFileSync(new URL("../.github/workflows/ci.yml", import.meta.url), "utf8"); + +describe("workflow contract", () => { + it("publishes main only from completed CI and reuses full CI for tag/manual", () => { + assert.match(releaseWorkflow, /workflow_run:\n\s+workflows: \[CI\]\n\s+types: \[completed\]/); + assert.doesNotMatch(releaseWorkflow, /push:\n\s+branches:/); + assert.match(releaseWorkflow, /full-ci:[\s\S]*uses: \.\/\.github\/workflows\/ci\.yml/); + assert.match(releaseWorkflow, /source_sha: \$\{\{ needs\.release-context\.outputs\.build_sha \}\}/); + }); + + it("binds CI, build, artifacts, and publication to the resolved SHA", () => { + assert.match(ciWorkflow, /workflow_call:[\s\S]*source_sha:/); + assert.equal((ciWorkflow.match(/ref: \$\{\{ inputs\.source_sha \|\| github\.sha \}\}/g) || []).length, 4); + assert.match(readFileSync(new URL("./resolve-release-context.mjs", import.meta.url), "utf8"), /checkedOutSha !== buildSha/); + assert.match(releaseWorkflow, /Verify release source remains exact[\s\S]*git diff --exit-code/); + assert.match(releaseWorkflow, /name: prime-agent-production-\$\{\{ env\.BUILD_SHA \}\}/); + assert.match(releaseWorkflow, /name: prime-agent-beta-\$\{\{ env\.BUILD_SHA \}\}/); + assert.match(releaseWorkflow, /publish:\n\s+runs-on: ubuntu-latest\n\s+needs: release-gate/); + }); + + it("refuses local and immutable remote provenance drift before upload", () => { + assert.match(releaseWorkflow, /--source-sha "\$BUILD_SHA"/); + assert.match(releaseWorkflow, /--remote-checksums "\$REMOTE_SUMS"/); + assert.match(releaseWorkflow, /--remote-manifest "\$REMOTE_MANIFEST"/); + assert.match(releaseWorkflow, /Refusing to overwrite incomplete immutable release prefix/); + }); +}); + +function mainContext(overrides = {}) { + return { + buildSha, + defaultBranch: "main", + eventName: "workflow_run", + existingTagSha: null, + inputReleaseTag: "", + packageVersions, + refName: "", + refType: "", + repository: "PrimeIntellect-ai/prime-agent", + runAttempt: "2", + runNumber: "123", + workflowRun: { + conclusion: "success", + event: "push", + headBranch: "main", + headRepository: "PrimeIntellect-ai/prime-agent", + headSha: buildSha, + workflowPath: ".github/workflows/ci.yml", + }, + ...overrides, + }; +} + +describe("release context", () => { + it("binds a successful main CI run to its exact SHA", () => { + assert.deepEqual(resolveReleaseContext(mainContext()), { + betaVersion: `0.7.1-beta.123.2.${buildSha.slice(0, 7)}`, + buildSha, + productionVersion: "0.7.1", + publishBeta: true, + publishProduction: true, + trigger: "main", + }); + }); + + it("does not republish a production version tagged at another main SHA", () => { + const context = resolveReleaseContext(mainContext({ existingTagSha: otherSha })); + assert.equal(context.publishBeta, true); + assert.equal(context.publishProduction, false); + }); + + it("rejects failed, cancelled, and skipped CI", () => { + for (const conclusion of ["failure", "cancelled", "skipped"]) { + assert.throws( + () => + resolveReleaseContext( + mainContext({ workflowRun: { ...mainContext().workflowRun, conclusion } }), + ), + /CI conclusion must be success/, + ); + } + }); + + it("rejects PR, fork, branch, workflow-path, and SHA mismatches", () => { + const invalidRuns = [ + { event: "pull_request" }, + { headRepository: "attacker/prime-agent" }, + { headBranch: "feature" }, + { workflowPath: ".github/workflows/other.yml" }, + { headSha: otherSha }, + ]; + for (const invalid of invalidRuns) { + assert.throws(() => + resolveReleaseContext( + mainContext({ workflowRun: { ...mainContext().workflowRun, ...invalid } }), + ), + ); + } + }); + + it("accepts exact tag and default-branch manual releases", () => { + const tag = resolveReleaseContext({ + ...mainContext(), + eventName: "push", + existingTagSha: buildSha, + refName: "v0.7.1", + refType: "tag", + }); + assert.equal(tag.trigger, "tag"); + assert.equal(tag.publishProduction, true); + + const manual = resolveReleaseContext({ + ...mainContext(), + eventName: "workflow_dispatch", + existingTagSha: null, + inputReleaseTag: "v0.7.1", + refName: "main", + }); + assert.equal(manual.trigger, "manual"); + assert.equal(manual.publishProduction, true); + }); + + it("rejects tag, manual branch, and package-version mismatches", () => { + assert.throws( + () => + resolveReleaseContext({ + ...mainContext(), + eventName: "push", + refName: "v0.7.2", + refType: "tag", + }), + /tag v0.7.2 does not match/i, + ); + assert.throws( + () => + resolveReleaseContext({ + ...mainContext(), + eventName: "workflow_dispatch", + inputReleaseTag: "v0.7.1", + refName: "feature", + }), + /default branch/, + ); + assert.throws( + () => assertLockstepVersions({ ...packageVersions, tui: "0.7.0" }), + /versions must match/, + ); + }); + + it("rejects invalid rerun identifiers", () => { + assert.throws(() => resolveReleaseContext(mainContext({ runAttempt: "0" })), /Run attempt/); + assert.throws(() => resolveReleaseContext(mainContext({ runNumber: "latest" })), /Run number/); + }); +}); + +describe("aggregate CI gate", () => { + it("accepts only all-success results", () => { + assert.doesNotThrow(() => + verifyCiResults({ "build-check": "success", test: "success", "python-runtime": "success" }), + ); + }); + + it("rejects a failed shard or cancelled/skipped required job", () => { + for (const result of ["failure", "cancelled", "skipped"]) { + assert.throws( + () => verifyCiResults({ "build-check": "success", test: result, "python-runtime": "success" }), + /new Error|Required CI jobs did not succeed/, + ); + } + }); + + it("requires upstream CI for main and reusable CI for tag/manual releases", () => { + assert.doesNotThrow(() => + verifyReleaseGate({ build: "success", fullCi: "skipped", releaseContext: "success", trigger: "main" }), + ); + for (const trigger of ["tag", "manual"]) { + assert.doesNotThrow(() => + verifyReleaseGate({ build: "success", fullCi: "success", releaseContext: "success", trigger }), + ); + } + }); + + it("rejects bypassed or unsuccessful release gates", () => { + for (const result of ["failure", "cancelled", "skipped"]) { + assert.throws( + () => + verifyReleaseGate({ build: "success", fullCi: result, releaseContext: "success", trigger: "tag" }), + /Required CI jobs did not succeed/, + ); + } + assert.throws( + () => + verifyReleaseGate({ build: "failure", fullCi: "skipped", releaseContext: "success", trigger: "main" }), + /Required CI jobs did not succeed/, + ); + assert.throws( + () => + verifyReleaseGate({ build: "success", fullCi: "success", releaseContext: "success", trigger: "main" }), + /reuse completed upstream CI/, + ); + }); +}); + +const temporaryDirectories = []; + +function checksum(value) { + return createHash("sha256").update(value).digest("hex"); +} + +function createArtifactFixture(channel = "stable") { + const directory = mkdtempSync(join(tmpdir(), "prime-agent-release-")); + temporaryDirectories.push(directory); + const version = channel === "stable" ? "0.7.1" : "0.7.1-beta.123.2.aaaaaaa"; + const definitions = [ + ["prime-agent", "prime-agent"], + ["prime-agent-ai", "prime-agent-ai"], + ["prime-agent-core", "prime-agent-core"], + ["prime-agent-tui", "prime-agent-tui"], + ].map(([name, prefix]) => ({ file: `${prefix}-${version}.tgz`, name })); + const tarballs = definitions.map((entry) => { + const content = `content:${entry.file}`; + writeFileSync(join(directory, entry.file), content); + return { ...entry, sha256: checksum(content) }; + }); + writeFileSync( + join(directory, "SHA256SUMS"), + `${tarballs + .slice() + .sort((left, right) => left.file.localeCompare(right.file)) + .map((entry) => `${entry.sha256} ${entry.file}`) + .join("\n")}\n`, + ); + writeFileSync(join(directory, channel), `v${version}\n`); + const manifestName = channel === "stable" ? "latest.json" : "beta.json"; + writeFileSync( + join(directory, manifestName), + `${JSON.stringify( + { + version: `v${version}`, + sourceSha: buildSha, + package: "prime-agent", + tarball: `releases/v${version}/prime-agent-${version}.tgz`, + tarballs: tarballs.map(({ name, ...entry }) => ({ package: name, ...entry })), + }, + null, + 2, + )}\n`, + ); + return { channel, directory, manifestName, version }; +} + +afterEach(() => { + while (temporaryDirectories.length > 0) rmSync(temporaryDirectories.pop(), { force: true, recursive: true }); +}); + +describe("release artifact provenance", () => { + it("accepts an exact source SHA and package/hash set", () => { + for (const channel of ["stable", "beta"]) { + const fixture = createArtifactFixture(channel); + assert.doesNotThrow(() => verifyReleaseArtifacts({ ...fixture, sourceSha: buildSha })); + } + }); + + it("rejects local tampering and source-SHA drift", () => { + const fixture = createArtifactFixture(); + writeFileSync(join(fixture.directory, `prime-agent-${fixture.version}.tgz`), "tampered"); + assert.throws(() => verifyReleaseArtifacts({ ...fixture, sourceSha: buildSha }), /Checksum mismatch/); + + const clean = createArtifactFixture(); + assert.throws(() => verifyReleaseArtifacts({ ...clean, sourceSha: otherSha }), /source SHA/); + }); + + it("refuses immutable remote checksum drift", () => { + const fixture = createArtifactFixture(); + const remoteChecksums = join(fixture.directory, "REMOTE_SHA256SUMS"); + const local = readFileSync(join(fixture.directory, "SHA256SUMS"), "utf8"); + writeFileSync(remoteChecksums, `${local[0] === "f" ? "e" : "f"}${local.slice(1)}`); + assert.throws( + () => verifyReleaseArtifacts({ ...fixture, remoteChecksums, sourceSha: buildSha }), + /Immutable release hash drift/, + ); + }); + + it("refuses immutable remote source-manifest drift", () => { + const fixture = createArtifactFixture(); + const remoteManifest = join(fixture.directory, "REMOTE_MANIFEST.json"); + const manifest = JSON.parse(readFileSync(join(fixture.directory, fixture.manifestName), "utf8")); + writeFileSync(remoteManifest, `${JSON.stringify({ ...manifest, sourceSha: otherSha }, null, 2)}\n`); + assert.throws( + () => verifyReleaseArtifacts({ ...fixture, remoteManifest, sourceSha: buildSha }), + /Immutable release manifest drift/, + ); + }); + + it("rejects incomplete or unexpected artifact lists", () => { + const fixture = createArtifactFixture(); + const checksumPath = join(fixture.directory, "SHA256SUMS"); + const lines = readFileSync(checksumPath, "utf8").trim().split("\n"); + writeFileSync(checksumPath, `${lines.slice(1).join("\n")}\n`); + assert.throws(() => verifyReleaseArtifacts({ ...fixture, sourceSha: buildSha }), /file set mismatch/); + }); +}); diff --git a/scripts/resolve-release-context.mjs b/scripts/resolve-release-context.mjs old mode 100755 new mode 100644 index 8d5453e5ca..7a00d3c787 --- a/scripts/resolve-release-context.mjs +++ b/scripts/resolve-release-context.mjs @@ -100,3 +100,4 @@ try { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); } + diff --git a/scripts/verify-ci-results.mjs b/scripts/verify-ci-results.mjs new file mode 100644 index 0000000000..03e5d8c882 --- /dev/null +++ b/scripts/verify-ci-results.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node + +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const validResults = new Set(["success", "failure", "cancelled", "skipped"]); + +export function verifyCiResults(results) { + if (Object.keys(results).length === 0) throw new Error("At least one CI result is required"); + const unsuccessful = []; + for (const [name, result] of Object.entries(results)) { + if (!validResults.has(result)) throw new Error(`Invalid CI result for ${name}: ${result}`); + if (result !== "success") unsuccessful.push(`${name}=${result}`); + } + if (unsuccessful.length > 0) throw new Error(`Required CI jobs did not succeed: ${unsuccessful.join(", ")}`); +} + +function main() { + const results = {}; + for (const argument of process.argv.slice(2)) { + const separator = argument.indexOf("="); + if (separator < 1) throw new Error(`Expected name=result, received ${argument}`); + const name = argument.slice(0, separator); + if (Object.hasOwn(results, name)) throw new Error(`Duplicate CI result: ${name}`); + results[name] = argument.slice(separator + 1); + } + verifyCiResults(results); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/verify-release-artifacts.mjs b/scripts/verify-release-artifacts.mjs new file mode 100644 index 0000000000..36e950d15c --- /dev/null +++ b/scripts/verify-release-artifacts.mjs @@ -0,0 +1,154 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { basename, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const commitShaPattern = /^[0-9a-f]{40}$/; +const releaseChannels = new Set(["stable", "beta"]); +const releasePackages = [ + { filePrefix: "prime-agent", name: "prime-agent" }, + { filePrefix: "prime-agent-ai", name: "prime-agent-ai" }, + { filePrefix: "prime-agent-core", name: "prime-agent-core" }, + { filePrefix: "prime-agent-tui", name: "prime-agent-tui" }, +]; + +function sha256File(path) { + const hash = createHash("sha256"); + hash.update(readFileSync(path)); + return hash.digest("hex"); +} + +function expectedTarballs(version) { + return releasePackages + .map((entry) => ({ ...entry, file: `${entry.filePrefix}-${version}.tgz` })) + .sort((left, right) => left.file.localeCompare(right.file)); +} + +function readChecksums(path, expectedFiles) { + const entries = new Map(); + for (const line of readFileSync(path, "utf8").trim().split("\n")) { + const match = /^([0-9a-f]{64}) ([^/]+)$/.exec(line); + if (!match) throw new Error(`Invalid checksum line in ${path}: ${line}`); + const [, checksum, file] = match; + if (basename(file) !== file) throw new Error(`Unsafe checksum filename: ${file}`); + if (entries.has(file)) throw new Error(`Duplicate checksum entry: ${file}`); + entries.set(file, checksum); + } + const actualFiles = [...entries.keys()].sort(); + if (JSON.stringify(actualFiles) !== JSON.stringify([...expectedFiles].sort())) { + throw new Error(`Checksum file set mismatch: ${actualFiles.join(", ")}`); + } + return entries; +} + +function verifyManifest({ checksums, manifest, manifestName, sourceSha, tarballs, version }) { + if (manifest.version !== `v${version}`) throw new Error(`${manifestName} version does not match v${version}`); + if (manifest.sourceSha !== sourceSha) throw new Error(`${manifestName} source SHA does not match ${sourceSha}`); + if (manifest.package !== "prime-agent") throw new Error(`${manifestName} package must be prime-agent`); + if (manifest.tarball !== `releases/v${version}/prime-agent-${version}.tgz`) { + throw new Error(`${manifestName} CLI tarball is invalid`); + } + if (!Array.isArray(manifest.tarballs) || manifest.tarballs.length !== tarballs.length) { + throw new Error(`${manifestName} tarball list is incomplete`); + } + const actualManifestTarballs = [...manifest.tarballs].sort((left, right) => left.file.localeCompare(right.file)); + for (let index = 0; index < tarballs.length; index += 1) { + const expected = tarballs[index]; + const actual = actualManifestTarballs[index]; + if ( + actual?.package !== expected.name || + actual.file !== expected.file || + actual.sha256 !== checksums.get(expected.file) + ) { + throw new Error(`${manifestName} tarball metadata is invalid for ${expected.file}`); + } + } +} + +export function verifyReleaseArtifacts({ channel, directory, remoteChecksums, remoteManifest, sourceSha, version }) { + if (!releaseChannels.has(channel)) throw new Error(`Unsupported release channel: ${channel}`); + if (!commitShaPattern.test(sourceSha)) throw new Error(`Invalid source SHA: ${sourceSha}`); + if (!/^[0-9A-Za-z.-]+$/.test(version)) throw new Error(`Invalid release version: ${version}`); + + const tarballs = expectedTarballs(version); + const expectedFiles = tarballs.map((entry) => entry.file); + const checksumsPath = join(directory, "SHA256SUMS"); + const checksums = readChecksums(checksumsPath, expectedFiles); + for (const tarball of tarballs) { + const actual = sha256File(join(directory, tarball.file)); + if (checksums.get(tarball.file) !== actual) throw new Error(`Checksum mismatch: ${tarball.file}`); + } + + if (remoteChecksums) { + const remote = readChecksums(remoteChecksums, expectedFiles); + for (const file of expectedFiles) { + if (remote.get(file) !== checksums.get(file)) { + throw new Error(`Immutable release hash drift: ${file}`); + } + } + } + + const pointer = readFileSync(join(directory, channel), "utf8"); + if (pointer !== `v${version}\n`) throw new Error(`${channel} pointer does not match v${version}`); + const manifestName = channel === "stable" ? "latest.json" : "beta.json"; + const manifestContent = readFileSync(join(directory, manifestName), "utf8"); + const manifest = JSON.parse(manifestContent); + verifyManifest({ checksums, manifest, manifestName, sourceSha, tarballs, version }); + if (remoteManifest) { + try { + const remoteManifestContent = readFileSync(remoteManifest, "utf8"); + if (remoteManifestContent !== manifestContent) throw new Error("content does not match local manifest"); + verifyManifest({ + checksums, + manifest: JSON.parse(remoteManifestContent), + manifestName: "Remote immutable manifest", + sourceSha, + tarballs, + version, + }); + } catch (error) { + throw new Error(`Immutable release manifest drift: ${error instanceof Error ? error.message : String(error)}`); + } + } +} + +function parseArgs(args) { + const parsed = {}; + const allowed = new Set(["channel", "directory", "remote_checksums", "remote_manifest", "source_sha", "version"]); + for (let index = 0; index < args.length; index += 2) { + const name = args[index]; + const value = args[index + 1]; + if (!name?.startsWith("--") || !value) throw new Error(`Invalid argument: ${name || "missing"}`); + const key = name.slice(2).replaceAll("-", "_"); + if (!allowed.has(key)) throw new Error(`Unknown argument: ${name}`); + if (Object.hasOwn(parsed, key)) throw new Error(`Duplicate argument: ${name}`); + parsed[key] = value; + } + return parsed; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + for (const required of ["channel", "directory", "source_sha", "version"]) { + if (!args[required]) throw new Error(`--${required.replaceAll("_", "-")} is required`); + } + verifyReleaseArtifacts({ + channel: args.channel, + directory: resolve(args.directory), + remoteChecksums: args.remote_checksums ? resolve(args.remote_checksums) : undefined, + remoteManifest: args.remote_manifest ? resolve(args.remote_manifest) : undefined, + sourceSha: args.source_sha, + version: args.version, + }); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/verify-release-gate.mjs b/scripts/verify-release-gate.mjs new file mode 100644 index 0000000000..bc66332e89 --- /dev/null +++ b/scripts/verify-release-gate.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node + +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { verifyCiResults } from "./verify-ci-results.mjs"; + +const releaseTriggers = new Set(["main", "manual", "tag"]); + +export function verifyReleaseGate({ build, fullCi, releaseContext, trigger }) { + if (!releaseTriggers.has(trigger)) throw new Error(`Unsupported release trigger: ${trigger || "missing"}`); + verifyCiResults({ build, "release-context": releaseContext }); + if (trigger === "main") { + if (fullCi !== "skipped") { + throw new Error(`Main release must reuse completed upstream CI, received full-ci=${fullCi}`); + } + return; + } + verifyCiResults({ "full-ci": fullCi }); +} + +function main() { + const values = {}; + for (const argument of process.argv.slice(2)) { + const separator = argument.indexOf("="); + if (separator < 1) throw new Error(`Expected name=value, received ${argument}`); + const name = argument.slice(0, separator); + if (Object.hasOwn(values, name)) throw new Error(`Duplicate release gate value: ${name}`); + values[name] = argument.slice(separator + 1); + } + verifyReleaseGate({ + build: values.build, + fullCi: values["full-ci"], + releaseContext: values["release-context"], + trigger: values.trigger, + }); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} From 958174f058bf2483107105ac09c384d060fb5090 Mon Sep 17 00:00:00 2001 From: fettpl <38704082+fettpl@users.noreply.github.com> Date: Sat, 8 Aug 2026 20:36:37 +0200 Subject: [PATCH 11/13] fix(ci): harden workflow privileges (fixes #926) --- .github/workflows/ci.yml | 27 ++- .github/workflows/nightly-process-stress.yml | 9 +- .../scripts/check-workflow-security.mjs | 177 ++++++++++++++++++ 3 files changed, 201 insertions(+), 12 deletions(-) create mode 100644 packages/coding-agent/scripts/check-workflow-security.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 333f22ae90..ca63786707 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,17 +16,18 @@ concurrency: group: ci-${{ inputs.source_sha || github.ref }} cancel-in-progress: true -permissions: - contents: read +permissions: {} jobs: build-check: name: Build and check runs-on: ubuntu-latest timeout-minutes: 15 + permissions: + contents: read steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.source_sha || github.sha }} persist-credentials: false @@ -37,7 +38,7 @@ jobs: run: test "$(git rev-parse HEAD)" = "$SOURCE_SHA" - name: Setup Node.js - uses: actions/setup-node@v7.0.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: npm @@ -60,10 +61,16 @@ jobs: - name: Check release packages run: npm run check:release-packages + - name: Check workflow security contracts + working-directory: packages/coding-agent + run: node --test scripts/check-workflow-security.mjs + test: name: Test (${{ matrix.name }}) runs-on: ubuntu-latest timeout-minutes: 15 + permissions: + contents: read strategy: fail-fast: false matrix: @@ -102,7 +109,7 @@ jobs: install_uv: true steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.source_sha || github.sha }} persist-credentials: false @@ -113,7 +120,7 @@ jobs: run: test "$(git rev-parse HEAD)" = "$SOURCE_SHA" - name: Setup Node.js - uses: actions/setup-node@v7.0.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: npm @@ -144,9 +151,11 @@ jobs: name: Python runtime tests runs-on: ubuntu-latest timeout-minutes: 10 + permissions: + contents: read steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.source_sha || github.sha }} persist-credentials: false @@ -177,9 +186,11 @@ jobs: if: always() needs: [build-check, test, python-runtime] runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.source_sha || github.sha }} persist-credentials: false diff --git a/.github/workflows/nightly-process-stress.yml b/.github/workflows/nightly-process-stress.yml index 1afdb59300..c2690f8142 100644 --- a/.github/workflows/nightly-process-stress.yml +++ b/.github/workflows/nightly-process-stress.yml @@ -9,20 +9,21 @@ concurrency: group: nightly-process-stress cancel-in-progress: true -permissions: - contents: read +permissions: {} jobs: process-stress: name: Process stress runs-on: ubuntu-latest timeout-minutes: 15 + permissions: + contents: read steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node.js - uses: actions/setup-node@v7.0.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: npm diff --git a/packages/coding-agent/scripts/check-workflow-security.mjs b/packages/coding-agent/scripts/check-workflow-security.mjs new file mode 100644 index 0000000000..c321df1cd8 --- /dev/null +++ b/packages/coding-agent/scripts/check-workflow-security.mjs @@ -0,0 +1,177 @@ +import assert from "node:assert/strict"; +import { readdir, readFile } from "node:fs/promises"; +import test from "node:test"; +import { parse } from "yaml"; + +const workflowsDirectory = new URL("../../../.github/workflows/", import.meta.url); +const expectedPermissionsByWorkflow = { + "build-binaries.yml": { + build: { contents: "read" }, + publish: { contents: "write" }, + "release-context": { contents: "read" }, + }, + "ci.yml": { + "build-check": { contents: "read" }, + "build-check-test": {}, + test: { contents: "read" }, + }, + "nightly-process-stress.yml": { + "process-stress": { contents: "read" }, + }, +}; + +function isWorkflowFilename(name) { + return /\.ya?ml$/i.test(name); +} + +async function readWorkflows() { + const names = (await readdir(workflowsDirectory)).filter(isWorkflowFilename).sort(); + return Promise.all( + names.map(async (name) => ({ + name, + source: await readFile(new URL(name, workflowsDirectory), "utf8"), + })), + ); +} + +function getJobBlocks(source) { + const lines = source.split("\n"); + const jobsIndex = lines.findIndex((line) => line === "jobs:"); + assert.notEqual(jobsIndex, -1, "workflow must define jobs"); + + const starts = []; + for (let index = jobsIndex + 1; index < lines.length; index += 1) { + const match = /^ ([a-zA-Z0-9_-]+):\s*$/.exec(lines[index]); + if (match) starts.push({ index, name: match[1] }); + } + + return starts.map((start, index) => ({ + name: start.name, + source: lines.slice(start.index, starts[index + 1]?.index ?? lines.length).join("\n"), + })); +} + +function getNamedSteps(jobSource) { + const lines = jobSource.split("\n"); + const starts = []; + for (let index = 0; index < lines.length; index += 1) { + const match = /^ - name: (.+)$/.exec(lines[index]); + if (match) starts.push({ index, name: match[1] }); + } + + return new Map( + starts.map((start, index) => [ + start.name, + lines.slice(start.index, starts[index + 1]?.index ?? lines.length).join("\n"), + ]), + ); +} + +test("third-party actions use full commit SHAs with version comments", async () => { + const workflows = await readWorkflows(); + for (const workflow of workflows) { + for (const [index, line] of workflow.source.split("\n").entries()) { + const match = /^\s*uses:\s+([^\s@]+)@([^\s#]+)(?:\s+#\s+(.+))?$/.exec(line); + if (!match || match[1].startsWith("./") || match[1].startsWith("docker://")) continue; + + assert.match( + match[2], + /^[0-9a-f]{40}$/, + `${workflow.name}:${index + 1} must pin ${match[1]} to a full commit SHA`, + ); + assert.match( + match[3] ?? "", + /^v\d+(?:\.\d+){1,2}.*$/, + `${workflow.name}:${index + 1} must document the pinned action version`, + ); + } + } +}); + +test("workflow discovery covers both supported YAML extensions", () => { + const candidates = ["ignored.json", "nightly.yaml", "release.yml", "UPPER.YAML"]; + assert.deepEqual(candidates.filter(isWorkflowFilename), ["nightly.yaml", "release.yml", "UPPER.YAML"]); +}); + +test("workflows default to no token permissions and grant only allowlisted job permissions", async () => { + const workflows = await readWorkflows(); + assert.deepEqual( + workflows.map(({ name }) => name), + Object.keys(expectedPermissionsByWorkflow).sort(), + "every workflow must have an explicit permission allowlist", + ); + + for (const workflow of workflows) { + const document = parse(workflow.source); + assert.deepEqual(document.permissions, {}, `${workflow.name} must default to no token permissions`); + + const expectedJobs = expectedPermissionsByWorkflow[workflow.name]; + assert.deepEqual( + Object.keys(document.jobs ?? {}).sort(), + Object.keys(expectedJobs).sort(), + `${workflow.name} jobs must match the permission allowlist`, + ); + + for (const [jobName, expectedPermissions] of Object.entries(expectedJobs)) { + assert.deepEqual( + document.jobs[jobName].permissions, + expectedPermissions, + `${workflow.name} job ${jobName} must use only its allowlisted permissions`, + ); + } + } +}); + +test("R2 secrets are available only to R2 upload steps", async () => { + const releaseWorkflow = await readFile(new URL("build-binaries.yml", workflowsDirectory), "utf8"); + const publishJob = getJobBlocks(releaseWorkflow).find((job) => job.name === "publish"); + assert.ok(publishJob, "release workflow must define the publish job"); + + const steps = getNamedSteps(publishJob.source); + const allowedSecretSteps = new Set([ + "Publish production channel to R2", + "Publish immutable beta artifacts to R2", + "Advance beta channel in R2", + ]); + + for (const [name, source] of steps) { + if (!source.includes("secrets.R2_")) continue; + assert.ok(allowedSecretSteps.has(name), `${name} must not receive R2 secrets`); + assert.match(source, /\baws s3 cp\b/, `${name} must upload to R2`); + assert.doesNotMatch(source, /\bgh (?:api|release)\b/, `${name} must not mix R2 and GitHub publication`); + } + + for (const name of allowedSecretSteps) { + const source = steps.get(name); + assert.ok(source, `release workflow must define ${name}`); + for (const secret of ["R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY", "R2_BUCKET", "R2_ENDPOINT_URL"]) { + assert.match(source, new RegExp(`secrets\\.${secret}\\b`), `${name} must scope ${secret} locally`); + } + } + + const publishPrefix = publishJob.source.slice(0, publishJob.source.indexOf(" steps:")); + assert.doesNotMatch(publishPrefix, /secrets\.R2_/, "publish job must not expose R2 secrets at job scope"); +}); + +test("beta pointer updates preserve the latest-main guard without sharing R2 credentials", async () => { + const releaseWorkflow = await readFile(new URL("build-binaries.yml", workflowsDirectory), "utf8"); + const publishJob = getJobBlocks(releaseWorkflow).find((job) => job.name === "publish"); + assert.ok(publishJob, "release workflow must define the publish job"); + const steps = getNamedSteps(publishJob.source); + + const guard = steps.get("Check whether beta should advance"); + assert.ok(guard, "beta publication must check the latest main commit"); + assert.match(guard, /id: beta_context/); + assert.match(guard, /should_advance=(?:false|true)/); + assert.doesNotMatch(guard, /secrets\.R2_/); + + const condition = /steps\.beta_context\.outputs\.should_advance == 'true'/; + assert.match(steps.get("Advance beta channel in R2") ?? "", condition); + assert.match(steps.get("Advance beta GitHub release") ?? "", condition); + assert.doesNotMatch(steps.get("Advance beta GitHub release") ?? "", /secrets\.R2_/); +}); + +test("Dependabot continues updating pinned GitHub Actions", async () => { + const dependabot = await readFile(new URL("../../../.github/dependabot.yml", import.meta.url), "utf8"); + assert.match(dependabot, /package-ecosystem:\s+github-actions/); +}); From 88db535efc1aa3616cb2b73ef7f5954a9d6f2fb5 Mon Sep 17 00:00:00 2001 From: fettpl <38704082+fettpl@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:46:17 +0200 Subject: [PATCH 12/13] fix(release): integrate verified distribution lifecycle fixes #926 fixes #927 fixes #934 fixes #949 --- .github/workflows/build-binaries.yml | 374 ++++++++++++++---- .github/workflows/ci.yml | 49 ++- .github/workflows/rollback-release.yml | 42 +- RELEASING.md | 22 +- package.json | 2 +- .../scripts/check-workflow-security.mjs | 98 +++-- scripts/check-release-package-contract.mjs | 3 + scripts/lib/release-lifecycle.mjs | 24 +- scripts/lib/release-publication.mjs | 20 +- scripts/pack-prime-agent-release.mjs | 12 + scripts/publish-release.mjs | 210 ++++++---- scripts/release-dry-run.mjs | 16 +- scripts/release-lifecycle.test.mjs | 44 +++ scripts/release-workflow-contract.test.mjs | 80 ++-- scripts/release-workflow.test.mjs | 201 +++++----- scripts/resolve-release-context.mjs | 266 +++++++++---- scripts/resolve-rollback-context.mjs | 15 +- scripts/verify-release-artifacts.mjs | 51 ++- scripts/verify-release-gate.mjs | 2 +- 19 files changed, 1083 insertions(+), 448 deletions(-) diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index c0d34e42d0..e6cb57420e 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -1,42 +1,44 @@ name: Release Prime Agent on: - push: - branches: - - main + workflow_run: + workflows: [CI] + types: [completed] issue_comment: types: [created] -concurrency: - group: release-prime-agent - cancel-in-progress: false - queue: max - -permissions: - contents: read +permissions: {} jobs: release-context: - if: github.event_name == 'push' || startsWith(github.event.comment.body, '/prime-agent release retry ') + if: >- + (github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == github.event.repository.default_branch && + github.event.workflow_run.head_repository.full_name == github.repository) || + startsWith(github.event.comment.body, '/prime-agent release retry ') runs-on: ubuntu-latest permissions: contents: read outputs: beta_version: ${{ steps.context.outputs.beta_version }} build_ref: ${{ steps.context.outputs.build_ref }} + build_sha: ${{ steps.context.outputs.build_sha }} production_version: ${{ steps.context.outputs.production_version }} publish_beta: ${{ steps.context.outputs.publish_beta }} publish_production: ${{ steps.context.outputs.publish_production }} + trigger: ${{ steps.context.outputs.trigger }} steps: - - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Checkout protected release tooling + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 fetch-tags: true ref: ${{ github.workflow_sha }} persist-credentials: false - - name: Authorize release comment + - name: Authorize release retry id: authorization if: github.event_name == 'issue_comment' env: @@ -59,16 +61,32 @@ jobs: id: context env: ACTOR_PERMISSION: ${{ steps.authorization.outputs.permission || '' }} - BEFORE_SHA: ${{ github.event.before || '' }} COMMENT_BODY: ${{ github.event.comment.body || '' }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} EVENT_NAME: ${{ github.event_name }} - GITHUB_SHA_VALUE: ${{ github.sha }} + GITHUB_REPOSITORY: ${{ github.repository }} RUN_ATTEMPT: ${{ github.run_attempt }} RUN_NUMBER: ${{ github.run_number }} + UPSTREAM_CONCLUSION: ${{ github.event.workflow_run.conclusion || '' }} + UPSTREAM_EVENT: ${{ github.event.workflow_run.event || '' }} + UPSTREAM_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch || '' }} + UPSTREAM_HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name || '' }} + UPSTREAM_HEAD_SHA: ${{ github.event.workflow_run.head_sha || '' }} + UPSTREAM_WORKFLOW_PATH: ${{ github.event.workflow_run.path || '' }} WORKFLOW_REF: ${{ github.workflow_ref }} + WORKFLOW_SHA: ${{ github.workflow_sha }} run: node scripts/resolve-release-context.mjs + full-ci: + if: needs.release-context.outputs.trigger == 'retry' + needs: release-context + permissions: + contents: read + uses: ./.github/workflows/ci.yml + with: + source_sha: ${{ needs.release-context.outputs.build_sha }} + tooling_sha: ${{ github.workflow_sha }} + build: runs-on: ubuntu-latest needs: release-context @@ -76,28 +94,32 @@ jobs: contents: read env: BETA_VERSION: ${{ needs.release-context.outputs.beta_version }} - BUILD_REF: ${{ needs.release-context.outputs.build_ref }} + BUILD_SHA: ${{ needs.release-context.outputs.build_sha }} PRIME_AGENT_RELEASE_SOURCE_ROOT: ${{ github.workspace }}/release-source PRODUCTION_VERSION: ${{ needs.release-context.outputs.production_version }} PUBLISH_BETA: ${{ needs.release-context.outputs.publish_beta }} PUBLISH_PRODUCTION: ${{ needs.release-context.outputs.publish_production }} steps: - name: Checkout protected release tooling - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.workflow_sha }} path: release-tooling persist-credentials: false - name: Checkout validated release source - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 fetch-tags: true - ref: ${{ env.BUILD_REF }} + ref: ${{ env.BUILD_SHA }} path: release-source persist-credentials: false + - name: Verify release source commit + working-directory: release-source + run: test "$(git rev-parse HEAD)" = "$BUILD_SHA" + - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -112,13 +134,38 @@ jobs: working-directory: release-source run: npm run build + - name: Capture post-build source baseline + working-directory: release-source + run: | + git diff --binary --full-index > "$RUNNER_TEMP/release-build-worktree.diff" + git diff --cached --binary --full-index > "$RUNNER_TEMP/release-build-index.diff" + - name: Check working-directory: release-source run: npm run check - - name: Test release lifecycle + - name: Test protected release tooling working-directory: release-source - run: node --test ../release-tooling/scripts/release-lifecycle.test.mjs ../release-tooling/scripts/release-publication.test.mjs ../release-tooling/scripts/release-workflow-contract.test.mjs + run: >- + node --test + ../release-tooling/scripts/release-lifecycle.test.mjs + ../release-tooling/scripts/release-publication.test.mjs + ../release-tooling/scripts/release-workflow-contract.test.mjs + ../release-tooling/scripts/release-workflow.test.mjs + + - name: Verify validation did not mutate release source + working-directory: release-source + run: | + git diff --binary --full-index > "$RUNNER_TEMP/release-check-worktree.diff" + git diff --cached --binary --full-index > "$RUNNER_TEMP/release-check-index.diff" + if ! cmp -s "$RUNNER_TEMP/release-build-worktree.diff" "$RUNNER_TEMP/release-check-worktree.diff"; then + diff -u "$RUNNER_TEMP/release-build-worktree.diff" "$RUNNER_TEMP/release-check-worktree.diff" || true + exit 1 + fi + if ! cmp -s "$RUNNER_TEMP/release-build-index.diff" "$RUNNER_TEMP/release-check-index.diff"; then + diff -u "$RUNNER_TEMP/release-build-index.diff" "$RUNNER_TEMP/release-check-index.diff" || true + exit 1 + fi - name: Pack production release if: env.PUBLISH_PRODUCTION == 'true' @@ -130,20 +177,20 @@ jobs: node ../release-tooling/scripts/pack-prime-agent-release.mjs \ --channel stable \ --version "$PRODUCTION_VERSION" \ + --source-sha "$BUILD_SHA" \ --base-url "$PRIME_AGENT_DOWNLOAD_BASE_URL" \ --out-dir packages/coding-agent/release/production - - - name: Dry-run production release - if: env.PUBLISH_PRODUCTION == 'true' - env: - PRIME_AGENT_DOWNLOAD_BASE_URL: ${{ vars.R2_PUBLIC_BASE_URL }} - working-directory: release-source - run: | node ../release-tooling/scripts/release-dry-run.mjs \ --channel stable \ --version "$PRODUCTION_VERSION" \ + --source-sha "$BUILD_SHA" \ --base-url "$PRIME_AGENT_DOWNLOAD_BASE_URL" \ --artifacts-dir packages/coding-agent/release/production/artifacts + node ../release-tooling/scripts/verify-release-artifacts.mjs \ + --channel stable \ + --directory packages/coding-agent/release/production/artifacts \ + --source-sha "$BUILD_SHA" \ + --version "$PRODUCTION_VERSION" - name: Pack beta release if: env.PUBLISH_BETA == 'true' @@ -155,87 +202,142 @@ jobs: node ../release-tooling/scripts/pack-prime-agent-release.mjs \ --channel beta \ --version "$BETA_VERSION" \ + --source-sha "$BUILD_SHA" \ --base-url "$PRIME_AGENT_DOWNLOAD_BASE_URL" \ --out-dir packages/coding-agent/release/beta - - - name: Dry-run beta release - if: env.PUBLISH_BETA == 'true' - env: - PRIME_AGENT_DOWNLOAD_BASE_URL: ${{ vars.R2_PUBLIC_BASE_URL }} - working-directory: release-source - run: | node ../release-tooling/scripts/release-dry-run.mjs \ --channel beta \ --version "$BETA_VERSION" \ + --source-sha "$BUILD_SHA" \ --base-url "$PRIME_AGENT_DOWNLOAD_BASE_URL" \ --artifacts-dir packages/coding-agent/release/beta/artifacts + node ../release-tooling/scripts/verify-release-artifacts.mjs \ + --channel beta \ + --directory packages/coding-agent/release/beta/artifacts \ + --source-sha "$BUILD_SHA" \ + --version "$BETA_VERSION" - name: Upload production artifacts if: env.PUBLISH_PRODUCTION == 'true' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: prime-agent-production + name: prime-agent-production-${{ env.BUILD_SHA }} path: release-source/packages/coding-agent/release/production/artifacts/* if-no-files-found: error + overwrite: true - name: Upload beta artifacts if: env.PUBLISH_BETA == 'true' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: prime-agent-beta + name: prime-agent-beta-${{ env.BUILD_SHA }} path: release-source/packages/coding-agent/release/beta/artifacts/* if-no-files-found: error + overwrite: true + + release-gate: + if: always() && needs.release-context.result == 'success' + runs-on: ubuntu-latest + needs: [release-context, full-ci, build] + permissions: + contents: read + outputs: + beta_version: ${{ needs.release-context.outputs.beta_version }} + build_sha: ${{ needs.release-context.outputs.build_sha }} + production_version: ${{ needs.release-context.outputs.production_version }} + publish_beta: ${{ needs.release-context.outputs.publish_beta }} + publish_production: ${{ needs.release-context.outputs.publish_production }} + steps: + - name: Checkout protected release tooling + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + + - name: Verify release gate + env: + BUILD_RESULT: ${{ needs.build.result }} + FULL_CI_RESULT: ${{ needs.full-ci.result }} + RELEASE_CONTEXT_RESULT: ${{ needs.release-context.result }} + RELEASE_TRIGGER: ${{ needs.release-context.outputs.trigger }} + run: | + node scripts/verify-release-gate.mjs \ + "release-context=$RELEASE_CONTEXT_RESULT" \ + "full-ci=$FULL_CI_RESULT" \ + "build=$BUILD_RESULT" \ + "trigger=$RELEASE_TRIGGER" publish: runs-on: ubuntu-latest - needs: [release-context, build] + needs: release-gate + environment: production + concurrency: + group: release-prime-agent + cancel-in-progress: false permissions: contents: write env: - AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - AWS_DEFAULT_REGION: auto - AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - BETA_VERSION: ${{ needs.release-context.outputs.beta_version }} - BUILD_REF: ${{ needs.release-context.outputs.build_ref }} + BETA_VERSION: ${{ needs.release-gate.outputs.beta_version }} + BUILD_SHA: ${{ needs.release-gate.outputs.build_sha }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} PRIME_AGENT_RELEASE_SOURCE_ROOT: ${{ github.workspace }}/release-source - PRODUCTION_VERSION: ${{ needs.release-context.outputs.production_version }} - PUBLISH_BETA: ${{ needs.release-context.outputs.publish_beta }} - PUBLISH_PRODUCTION: ${{ needs.release-context.outputs.publish_production }} - R2_BUCKET: ${{ secrets.R2_BUCKET }} - R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} + PRODUCTION_VERSION: ${{ needs.release-gate.outputs.production_version }} + PUBLISH_BETA: ${{ needs.release-gate.outputs.publish_beta }} + PUBLISH_PRODUCTION: ${{ needs.release-gate.outputs.publish_production }} R2_PUBLIC_BASE_URL: ${{ vars.R2_PUBLIC_BASE_URL }} steps: - name: Checkout protected release tooling - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.workflow_sha }} path: release-tooling persist-credentials: false - name: Checkout validated release source - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 fetch-tags: true - ref: ${{ env.BUILD_REF }} + ref: ${{ env.BUILD_SHA }} path: release-source persist-credentials: false + - name: Verify release source commit + working-directory: release-source + run: test "$(git rev-parse HEAD)" = "$BUILD_SHA" + - name: Download production artifacts if: env.PUBLISH_PRODUCTION == 'true' - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b3455f8bb19e09b3fb854d966fb27682a2 # v8.0.1 with: - name: prime-agent-production + name: prime-agent-production-${{ env.BUILD_SHA }} path: release-artifacts/production - name: Download beta artifacts if: env.PUBLISH_BETA == 'true' - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b3455f8bb19e09b3fb854d966fb27682a2 # v8.0.1 with: - name: prime-agent-beta + name: prime-agent-beta-${{ env.BUILD_SHA }} path: release-artifacts/beta + - name: Verify production artifact provenance + if: env.PUBLISH_PRODUCTION == 'true' + run: >- + node release-tooling/scripts/verify-release-artifacts.mjs + --channel stable + --directory release-artifacts/production + --source-sha "$BUILD_SHA" + --version "$PRODUCTION_VERSION" + + - name: Verify beta artifact provenance + if: env.PUBLISH_BETA == 'true' + run: >- + node release-tooling/scripts/verify-release-artifacts.mjs + --channel beta + --directory release-artifacts/beta + --source-sha "$BUILD_SHA" + --version "$BETA_VERSION" + - name: Prepare installers working-directory: release-source run: | @@ -255,31 +357,171 @@ jobs: echo "Release v${PRODUCTION_VERSION}" > /tmp/release-notes.md fi - - name: Publish production transaction + - name: Prepare production GitHub release + if: env.PUBLISH_PRODUCTION == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + working-directory: release-source + run: | + node ../release-tooling/scripts/publish-release.mjs production-github-prepare \ + --artifacts-dir ../release-artifacts/production \ + --version "$PRODUCTION_VERSION" \ + --build-ref "$BUILD_SHA" \ + --source-sha "$BUILD_SHA" \ + --notes-file /tmp/release-notes.md + + - name: Publish production immutable objects to R2 + if: env.PUBLISH_PRODUCTION == 'true' + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_DEFAULT_REGION: auto + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + R2_BUCKET: ${{ secrets.R2_BUCKET }} + R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} + working-directory: release-source + run: | + node ../release-tooling/scripts/publish-release.mjs production-r2-immutable \ + --artifacts-dir ../release-artifacts/production \ + --version "$PRODUCTION_VERSION" \ + --build-ref "$BUILD_SHA" \ + --source-sha "$BUILD_SHA" + + - name: Publish production GitHub assets if: env.PUBLISH_PRODUCTION == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} working-directory: release-source run: | - node ../release-tooling/scripts/publish-release.mjs production \ + node ../release-tooling/scripts/publish-release.mjs production-github-assets \ --artifacts-dir ../release-artifacts/production \ --version "$PRODUCTION_VERSION" \ - --build-ref "$BUILD_REF" \ - --notes-file /tmp/release-notes.md \ + --build-ref "$BUILD_SHA" \ + --source-sha "$BUILD_SHA" \ + --notes-file /tmp/release-notes.md + + - name: Publish production installers to R2 + if: env.PUBLISH_PRODUCTION == 'true' + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_DEFAULT_REGION: auto + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + R2_BUCKET: ${{ secrets.R2_BUCKET }} + R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} + working-directory: release-source + run: | + node ../release-tooling/scripts/publish-release.mjs production-r2-installers \ + --artifacts-dir ../release-artifacts/production \ + --version "$PRODUCTION_VERSION" \ + --build-ref "$BUILD_SHA" \ + --source-sha "$BUILD_SHA" \ --stable-installer /tmp/prime-agent-install.sh \ --beta-installer /tmp/prime-agent-install-beta.sh - - name: Publish beta transaction + - name: Promote production channel in R2 + if: env.PUBLISH_PRODUCTION == 'true' + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_DEFAULT_REGION: auto + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + R2_BUCKET: ${{ secrets.R2_BUCKET }} + R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} + working-directory: release-source + run: | + node ../release-tooling/scripts/publish-release.mjs production-r2-promote \ + --artifacts-dir ../release-artifacts/production \ + --version "$PRODUCTION_VERSION" \ + --build-ref "$BUILD_SHA" \ + --source-sha "$BUILD_SHA" + + - name: Publish beta immutable objects to R2 if: env.PUBLISH_BETA == 'true' + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_DEFAULT_REGION: auto + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + R2_BUCKET: ${{ secrets.R2_BUCKET }} + R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} + working-directory: release-source + run: | + node ../release-tooling/scripts/publish-release.mjs beta-r2-immutable \ + --artifacts-dir ../release-artifacts/beta \ + --version "$BETA_VERSION" \ + --build-ref "$BUILD_SHA" \ + --source-sha "$BUILD_SHA" + + - name: Check beta freshness before GitHub mirror + if: env.PUBLISH_BETA == 'true' + id: beta_github + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + latest_main_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${DEFAULT_BRANCH}" --jq .sha) + test "$latest_main_sha" = "$BUILD_SHA" + echo "current=true" >> "$GITHUB_OUTPUT" + + - name: Advance beta GitHub release + if: env.PUBLISH_BETA == 'true' && steps.beta_github.outputs.current == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + working-directory: release-source + run: | + node ../release-tooling/scripts/publish-release.mjs beta-github \ + --artifacts-dir ../release-artifacts/beta \ + --version "$BETA_VERSION" \ + --build-ref "$BUILD_SHA" \ + --source-sha "$BUILD_SHA" \ + --default-branch "$DEFAULT_BRANCH" + + - name: Check beta freshness before installers + if: env.PUBLISH_BETA == 'true' + id: beta_installers env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + latest_main_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${DEFAULT_BRANCH}" --jq .sha) + test "$latest_main_sha" = "$BUILD_SHA" + echo "current=true" >> "$GITHUB_OUTPUT" + + - name: Advance beta installers in R2 + if: env.PUBLISH_BETA == 'true' && steps.beta_installers.outputs.current == 'true' + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_DEFAULT_REGION: auto + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + R2_BUCKET: ${{ secrets.R2_BUCKET }} + R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} working-directory: release-source run: | - node ../release-tooling/scripts/publish-release.mjs beta \ + node ../release-tooling/scripts/publish-release.mjs beta-r2-installers \ --artifacts-dir ../release-artifacts/beta \ --version "$BETA_VERSION" \ - --build-ref "$BUILD_REF" \ - --default-branch "$DEFAULT_BRANCH" \ + --build-ref "$BUILD_SHA" \ + --source-sha "$BUILD_SHA" \ --stable-installer /tmp/prime-agent-install.sh \ --beta-installer /tmp/prime-agent-install-beta.sh + - name: Check beta freshness before channel promotion + if: env.PUBLISH_BETA == 'true' + id: beta_promotion + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + latest_main_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${DEFAULT_BRANCH}" --jq .sha) + test "$latest_main_sha" = "$BUILD_SHA" + echo "current=true" >> "$GITHUB_OUTPUT" + + - name: Advance beta channel in R2 + if: env.PUBLISH_BETA == 'true' && steps.beta_promotion.outputs.current == 'true' + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_DEFAULT_REGION: auto + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + R2_BUCKET: ${{ secrets.R2_BUCKET }} + R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} + working-directory: release-source + run: | + node ../release-tooling/scripts/publish-release.mjs beta-r2-promote \ + --artifacts-dir ../release-artifacts/beta \ + --version "$BETA_VERSION" \ + --build-ref "$BUILD_SHA" \ + --source-sha "$BUILD_SHA" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca63786707..0515cba6a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,11 @@ on: workflow_call: inputs: source_sha: - description: Full commit SHA to verify + description: Full source commit SHA to verify + required: true + type: string + tooling_sha: + description: Protected commit SHA containing the reusable CI workflow required: true type: string @@ -26,7 +30,7 @@ jobs: permissions: contents: read steps: - - name: Checkout + - name: Checkout source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.source_sha || github.sha }} @@ -59,9 +63,11 @@ jobs: run: npm run check - name: Check release packages + if: inputs.source_sha == '' run: npm run check:release-packages - name: Check workflow security contracts + if: inputs.source_sha == '' working-directory: packages/coding-agent run: node --test scripts/check-workflow-security.mjs @@ -108,7 +114,7 @@ jobs: command: npm run test:kernel install_uv: true steps: - - name: Checkout + - name: Checkout source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.source_sha || github.sha }} @@ -137,10 +143,10 @@ jobs: - name: Build run: npm run build - - name: Install uv + - name: Install pinned uv if: matrix.install_uv run: | - python3 -m pip install --user uv + python3 -m pip install --user uv==0.11.33 echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Test @@ -154,12 +160,19 @@ jobs: permissions: contents: read steps: - - name: Checkout + - name: Checkout source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.source_sha || github.sha }} persist-credentials: false + - name: Checkout protected Python lock + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.tooling_sha || github.sha }} + path: release-tooling + persist-credentials: false + - name: Verify source commit env: SOURCE_SHA: ${{ inputs.source_sha || github.sha }} @@ -174,12 +187,17 @@ jobs: run: python -m pip install uv==0.11.33 - name: Install locked runtime test dependencies - working-directory: prime-agent-runtime - run: uv sync --frozen --group test + run: uv sync --project release-tooling/prime-agent-runtime --frozen --group test - - name: Test runtime - working-directory: prime-agent-runtime - run: uv run --frozen --group test python -m unittest discover -s test -p 'test_*.py' + - name: Test exact source runtime + env: + PYTHONPATH: ${{ github.workspace }}/prime-agent-runtime + run: >- + uv run + --project release-tooling/prime-agent-runtime + --frozen + --group test + python -m unittest discover -s prime-agent-runtime/test -p 'test_*.py' build-check-test: name: build-check-test @@ -189,17 +207,12 @@ jobs: permissions: contents: read steps: - - name: Checkout + - name: Checkout protected CI tooling uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ inputs.source_sha || github.sha }} + ref: ${{ inputs.tooling_sha || github.sha }} persist-credentials: false - - name: Verify source commit - env: - SOURCE_SHA: ${{ inputs.source_sha || github.sha }} - run: test "$(git rev-parse HEAD)" = "$SOURCE_SHA" - - name: Verify CI results run: >- node scripts/verify-ci-results.mjs diff --git a/.github/workflows/rollback-release.yml b/.github/workflows/rollback-release.yml index fbbfa45546..7529cdd268 100644 --- a/.github/workflows/rollback-release.yml +++ b/.github/workflows/rollback-release.yml @@ -4,8 +4,7 @@ on: issue_comment: types: [created] -permissions: - contents: read +permissions: {} jobs: authorize: @@ -16,10 +15,13 @@ jobs: outputs: confirmation: ${{ steps.context.outputs.confirmation }} release_tag: ${{ steps.context.outputs.release_tag }} + source_sha: ${{ steps.context.outputs.source_sha }} steps: - name: Checkout protected release tooling - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + fetch-depth: 0 + fetch-tags: true ref: ${{ github.workflow_sha }} persist-credentials: false @@ -41,7 +43,7 @@ jobs: with: node-version: '22' - - name: Parse /prime-agent release rollback vX.Y.Z command + - name: Parse and verify rollback request id: context env: ACTOR_PERMISSION: ${{ steps.authorization.outputs.permission }} @@ -57,12 +59,11 @@ jobs: concurrency: group: release-prime-agent cancel-in-progress: false - queue: max permissions: contents: read steps: - - name: Checkout release tooling and tags - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Checkout protected release tooling and tags + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 fetch-tags: true @@ -74,20 +75,29 @@ jobs: with: node-version: '22' - - name: Verify target and roll back stable pointers + - name: Verify rollback GitHub release + id: rollback_release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + R2_PUBLIC_BASE_URL: ${{ vars.R2_PUBLIC_BASE_URL }} + run: | + node scripts/publish-release.mjs rollback-github-verify \ + --release-tag "${{ needs.authorize.outputs.release_tag }}" \ + --confirmation "${{ needs.authorize.outputs.confirmation }}" \ + --default-branch "${{ github.event.repository.default_branch }}" \ + --artifacts-dir rollback-artifacts + + - name: Promote verified stable pointers in R2 env: AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} AWS_DEFAULT_REGION: auto AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - CONFIRMATION: ${{ needs.authorize.outputs.confirmation }} - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} R2_BUCKET: ${{ secrets.R2_BUCKET }} R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} R2_PUBLIC_BASE_URL: ${{ vars.R2_PUBLIC_BASE_URL }} - RELEASE_TAG: ${{ needs.authorize.outputs.release_tag }} run: | - node scripts/publish-release.mjs rollback \ - --release-tag "$RELEASE_TAG" \ - --confirmation "$CONFIRMATION" \ - --default-branch "$DEFAULT_BRANCH" + node scripts/publish-release.mjs rollback-r2-promote \ + --release-tag "${{ needs.authorize.outputs.release_tag }}" \ + --confirmation "${{ needs.authorize.outputs.confirmation }}" \ + --source-sha "${{ steps.rollback_release.outputs.source_sha }}" \ + --artifacts-dir rollback-artifacts diff --git a/RELEASING.md b/RELEASING.md index e42747c640..d216a7a25e 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -2,13 +2,14 @@ The protected GitHub Actions workflows are the only release authority. Local commands may prepare and validate release files, but they never publish packages, create or move tags, push branches, upload artifacts, or promote a release channel. -Prime Agent's supported distribution is the branded R2 tarball bundle installed by `install.sh`. The inherited npm workspace names are source-level implementation details. Direct SDK and library distribution is tracked separately in issue #949. +Prime Agent's supported distribution is the branded R2 tarball bundle installed by `install.sh`. Every release also provides immutable `prime-agent`, `prime-agent-ai`, `prime-agent-core`, and `prime-agent-tui` tarballs for direct SDK and library use. The inherited npm workspace names are source-level implementation details. ## Release Contract A stable version consists of: - immutable objects under `releases/vX.Y.Z/`; +- an immutable `release-provenance.json` binding the channel, source SHA, artifact names, and SHA-256 values; - an immutable `vX.Y.Z` Git tag and matching GitHub Release assets; - the `/stable` text pointer used by existing installers; - `/latest.json`, the stable commit marker used by update checks. @@ -46,27 +47,29 @@ npm run release:test npm run check ``` -`release:dry-run` packages into a temporary directory, validates tarball names and manifests, verifies every SHA-256 digest and internal R2 URL, and removes its output. It has no npm, GitHub Release, Git tag, Git push, or R2 publication path. +`release:dry-run` packages into a temporary directory, validates tarball names, compatibility manifests, and the source-SHA provenance manifest, verifies every SHA-256 digest and internal R2 URL, and removes its output. It has no npm, GitHub Release, Git tag, Git push, or R2 publication path. Open and review the release-preparation pull request. Merging the exact version-preparation commit to the protected default branch is the only way to introduce a new production candidate. ## CI Publication -For each default-branch push, the Release Prime Agent workflow builds a beta. It additionally publishes production only when the root version changed to a strictly newer validated version. +The Release Prime Agent workflow starts only after the canonical `CI` workflow completes successfully for a same-repository push to the protected default branch. It binds every checkout, workflow artifact, manifest, checksum, and publication phase to that CI run's complete source SHA. Each successful default-branch commit builds a beta; production is additionally published only when the root version changed to a strictly newer validated version. + +The upstream CI result already covers the complete Node and locked Python suites. A protected production retry invokes the same reusable CI workflow against the immutable tag SHA before rebuilding or publishing. Failed, cancelled, skipped, fork-originated, wrong-branch, wrong-workflow, and wrong-SHA results cannot reach publication. The production transaction is ordered as follows: 1. Resolve and validate the exact version, commit, lockfile, package manifests, changelogs, and existing tag state using the protected workflow commit's release tooling. 2. Build, check, run the release lifecycle tests, pack once, and validate the workflow artifacts without publication credentials. -3. Create or verify the immutable version tag and GitHub Release target. -4. Create missing versioned R2 objects using conditional writes. Existing objects must be byte-identical. -5. Upload missing GitHub Release assets. Existing assets must be byte-identical and are never clobbered. +3. Create or verify the immutable version tag and GitHub Release target using only GitHub credentials. +4. Create missing versioned R2 objects using conditional writes in a separate R2-only phase. Existing objects must be byte-identical. +5. Upload missing GitHub Release assets in a GitHub-only phase. Existing assets must be byte-identical and are never clobbered. 6. Verify all versioned R2 and GitHub Release objects. 7. Upload and verify the stable and beta installer scripts. 8. Write and verify `/stable`. 9. Write and verify `/latest.json` last. -The workflow serializes publication and refuses to move either stable surface backward: the effective monotonic floor is the higher version named by `/stable` or `/latest.json`. Only the protected rollback workflow may lower both surfaces. Issue #927 separately owns the full exact-commit Node and Python suite gate; do not treat the focused release tests as that broader gate. +The workflow serializes publication and refuses to move either stable surface backward: the effective monotonic floor is the higher version named by `/stable` or `/latest.json`. Only the protected rollback workflow may lower both surfaces. GitHub and R2 mutation steps never receive each other's credentials. Beta freshness is rechecked against the default-branch head before each mutable GitHub, installer, and pointer phase. ## Retry @@ -76,7 +79,7 @@ After the immutable `vX.Y.Z` tag exists, a maintainer with repository `admin` or /prime-agent release retry vX.Y.Z ``` -The `issue_comment` event always loads workflow code from the protected default branch; branch-selectable release dispatches are not supported. The workflow derives both the version and commit from the tag, verifies that it belongs to the default branch, and rebuilds the exact tagged source. Release policy and publication scripts come from the protected workflow commit in a separate checkout, so tags created before this lifecycle was introduced remain retryable. A free-form version paired with current `main` is not supported. +The `issue_comment` event always loads workflow code from the protected default branch; branch-selectable release dispatches and tag-push publication are not supported. The workflow derives both the version and commit from the tag, verifies that it belongs to the default branch, reruns the complete reusable CI suite at that SHA, and rebuilds the exact tagged source. Release policy, the locked Python test environment, and publication scripts come from the protected workflow commit in separate checkouts, so tags created before this lifecycle was introduced remain retryable. A free-form version paired with current `main` is not supported. If the original production run failed before creating the version tag, rerun that exact failed workflow run instead. Do not use a later default-branch run or create the tag locally. Once the immutable tag exists, use the issue-comment retry command for subsequent recovery attempts. @@ -85,6 +88,7 @@ Retries are idempotent: - a failure before tag creation has no release state; - an empty release or partially uploaded immutable set can be completed by retry; - identical existing R2 and GitHub Release objects are reused; +- a missing provenance manifest on a pre-migration release may be added without rewriting its existing compatibility manifest or artifacts; - any existing object with different bytes hard-fails; - if `/stable` advanced but `/latest.json` did not, retry completes the JSON commit marker. @@ -99,7 +103,7 @@ Prefer a forward fix. If stable must be restored immediately, a maintainer with ROLLBACK vX.Y.Z ``` -The default-branch workflow and an API-derived maintainer permission are the repository-enforced authorization boundary. Authorization and exact command parsing complete in an ungated preflight job before the workflow can acquire the shared release lock or enter the `production` environment, so an unauthorized comment cannot block publication. The mutation job targets the `production` environment, so configured environment reviewers provide an additional gate but are not assumed to exist. It verifies the immutable tag, GitHub Release assets, manifest, checksums, and every referenced R2 tarball before writing `/stable` and then `/latest.json`. It does not create or move tags, rewrite immutable objects, or change GitHub Releases. +The default-branch workflow and an API-derived maintainer permission are the repository-enforced authorization boundary. Authorization, exact command parsing, tag resolution, and default-branch ancestry checks complete in an ungated preflight job before the workflow can acquire the shared release lock or enter the `production` environment, so an unauthorized comment cannot block publication. The mutation job targets the `production` environment, so configured environment reviewers provide an additional gate but are not assumed to exist. A GitHub-read-only phase downloads and validates the immutable Release assets; a separate R2-only phase verifies the matching remote objects before writing `/stable` and then `/latest.json`. It does not create or move tags, rewrite immutable objects, or change GitHub Releases. Rollback changes what fresh installations and future update checks select. It does not force already-installed newer clients to downgrade. diff --git a/package.json b/package.json index 493279f7ec..b2f3465baf 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "release:pack": "node scripts/pack-prime-agent-release.mjs", "release:prepare": "node scripts/prepare-release.mjs", "release:dry-run": "node scripts/release-dry-run.mjs", - "release:test": "node --test scripts/release-lifecycle.test.mjs scripts/release-publication.test.mjs scripts/release-workflow-contract.test.mjs", + "release:test": "node --test scripts/release-lifecycle.test.mjs scripts/release-publication.test.mjs scripts/release-workflow-contract.test.mjs scripts/release-workflow.test.mjs", "release:patch": "node scripts/release.mjs", "release:minor": "node scripts/release.mjs", "release:major": "node scripts/release.mjs", diff --git a/packages/coding-agent/scripts/check-workflow-security.mjs b/packages/coding-agent/scripts/check-workflow-security.mjs index c321df1cd8..7cec51ee5d 100644 --- a/packages/coding-agent/scripts/check-workflow-security.mjs +++ b/packages/coding-agent/scripts/check-workflow-security.mjs @@ -7,17 +7,24 @@ const workflowsDirectory = new URL("../../../.github/workflows/", import.meta.ur const expectedPermissionsByWorkflow = { "build-binaries.yml": { build: { contents: "read" }, + "full-ci": { contents: "read" }, publish: { contents: "write" }, + "release-gate": { contents: "read" }, "release-context": { contents: "read" }, }, "ci.yml": { "build-check": { contents: "read" }, - "build-check-test": {}, + "build-check-test": { contents: "read" }, + "python-runtime": { contents: "read" }, test: { contents: "read" }, }, "nightly-process-stress.yml": { "process-stress": { contents: "read" }, }, + "rollback-release.yml": { + authorize: { contents: "read" }, + rollback: { contents: "read" }, + }, }; function isWorkflowFilename(name) { @@ -122,53 +129,64 @@ test("workflows default to no token permissions and grant only allowlisted job p } }); -test("R2 secrets are available only to R2 upload steps", async () => { - const releaseWorkflow = await readFile(new URL("build-binaries.yml", workflowsDirectory), "utf8"); - const publishJob = getJobBlocks(releaseWorkflow).find((job) => job.name === "publish"); - assert.ok(publishJob, "release workflow must define the publish job"); - - const steps = getNamedSteps(publishJob.source); - const allowedSecretSteps = new Set([ - "Publish production channel to R2", - "Publish immutable beta artifacts to R2", - "Advance beta channel in R2", - ]); - - for (const [name, source] of steps) { - if (!source.includes("secrets.R2_")) continue; - assert.ok(allowedSecretSteps.has(name), `${name} must not receive R2 secrets`); - assert.match(source, /\baws s3 cp\b/, `${name} must upload to R2`); - assert.doesNotMatch(source, /\bgh (?:api|release)\b/, `${name} must not mix R2 and GitHub publication`); - } - - for (const name of allowedSecretSteps) { - const source = steps.get(name); - assert.ok(source, `release workflow must define ${name}`); - for (const secret of ["R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY", "R2_BUCKET", "R2_ENDPOINT_URL"]) { - assert.match(source, new RegExp(`secrets\\.${secret}\\b`), `${name} must scope ${secret} locally`); +test("R2 credentials are scoped to R2-only mutation steps", async () => { + const allowedByWorkflow = { + "build-binaries.yml": new Set([ + "Publish production immutable objects to R2", + "Publish production installers to R2", + "Promote production channel in R2", + "Publish beta immutable objects to R2", + "Advance beta installers in R2", + "Advance beta channel in R2", + ]), + "rollback-release.yml": new Set(["Promote verified stable pointers in R2"]), + }; + for (const [workflowName, allowedSecretSteps] of Object.entries(allowedByWorkflow)) { + const source = await readFile(new URL(workflowName, workflowsDirectory), "utf8"); + const allSteps = new Map(); + for (const job of getJobBlocks(source)) { + for (const [name, step] of getNamedSteps(job.source)) allSteps.set(name, step); + const jobPrefix = job.source.slice(0, job.source.indexOf(" steps:")); + assert.doesNotMatch(jobPrefix, /secrets\.R2_/, `${workflowName}/${job.name} must not expose R2 secrets at job scope`); + } + for (const [name, step] of allSteps) { + if (!step.includes("secrets.R2_")) continue; + assert.ok(allowedSecretSteps.has(name), `${workflowName}/${name} must not receive R2 secrets`); + assert.match(step, /publish-release\.mjs [a-z0-9-]*r2[a-z0-9-]*/, `${name} must invoke an R2 phase`); + assert.doesNotMatch(step, /secrets\.GITHUB_TOKEN|\bGH_TOKEN\b|\bgh (?:api|release)\b/); + } + for (const name of allowedSecretSteps) { + const step = allSteps.get(name); + assert.ok(step, `${workflowName} must define ${name}`); + for (const secret of ["R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY", "R2_BUCKET", "R2_ENDPOINT_URL"]) { + assert.match(step, new RegExp(`secrets\\.${secret}\\b`), `${name} must scope ${secret} locally`); + } } } - - const publishPrefix = publishJob.source.slice(0, publishJob.source.indexOf(" steps:")); - assert.doesNotMatch(publishPrefix, /secrets\.R2_/, "publish job must not expose R2 secrets at job scope"); }); -test("beta pointer updates preserve the latest-main guard without sharing R2 credentials", async () => { +test("every mutable beta phase has a fresh main guard without R2 credentials", async () => { const releaseWorkflow = await readFile(new URL("build-binaries.yml", workflowsDirectory), "utf8"); const publishJob = getJobBlocks(releaseWorkflow).find((job) => job.name === "publish"); assert.ok(publishJob, "release workflow must define the publish job"); const steps = getNamedSteps(publishJob.source); - - const guard = steps.get("Check whether beta should advance"); - assert.ok(guard, "beta publication must check the latest main commit"); - assert.match(guard, /id: beta_context/); - assert.match(guard, /should_advance=(?:false|true)/); - assert.doesNotMatch(guard, /secrets\.R2_/); - - const condition = /steps\.beta_context\.outputs\.should_advance == 'true'/; - assert.match(steps.get("Advance beta channel in R2") ?? "", condition); - assert.match(steps.get("Advance beta GitHub release") ?? "", condition); - assert.doesNotMatch(steps.get("Advance beta GitHub release") ?? "", /secrets\.R2_/); + const phases = [ + ["Check beta freshness before GitHub mirror", "beta_github", "Advance beta GitHub release"], + ["Check beta freshness before installers", "beta_installers", "Advance beta installers in R2"], + ["Check beta freshness before channel promotion", "beta_promotion", "Advance beta channel in R2"], + ]; + for (const [guardName, outputId, mutationName] of phases) { + const guard = steps.get(guardName); + assert.ok(guard, `beta publication must define ${guardName}`); + assert.match(guard, new RegExp(`id: ${outputId}`)); + assert.match(guard, /commits\/\$\{DEFAULT_BRANCH\}/); + assert.match(guard, /test "\$latest_main_sha" = "\$BUILD_SHA"/); + assert.doesNotMatch(guard, /secrets\.R2_/); + assert.match( + steps.get(mutationName) ?? "", + new RegExp(`steps\\.${outputId}\\.outputs\\.current == 'true'`), + ); + } }); test("Dependabot continues updating pinned GitHub Actions", async () => { diff --git a/scripts/check-release-package-contract.mjs b/scripts/check-release-package-contract.mjs index bf7425bb72..ea042cda26 100644 --- a/scripts/check-release-package-contract.mjs +++ b/scripts/check-release-package-contract.mjs @@ -18,6 +18,7 @@ import { fileURLToPath } from "node:url"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const releaseRoot = join(repoRoot, "packages", "coding-agent", "release"); const contractVersion = "0.0.0-contract"; +const contractSourceSha = "0".repeat(40); const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; const typeScriptCli = join(repoRoot, "node_modules", "typescript", "bin", "tsc"); @@ -270,6 +271,8 @@ async function main() { "stable", "--version", contractVersion, + "--source-sha", + contractSourceSha, "--base-url", baseUrl, "--out-dir", diff --git a/scripts/lib/release-lifecycle.mjs b/scripts/lib/release-lifecycle.mjs index 7d7dc1344f..9f6f73a928 100644 --- a/scripts/lib/release-lifecycle.mjs +++ b/scripts/lib/release-lifecycle.mjs @@ -35,7 +35,7 @@ const EXPECTED_INTERNAL_DEPENDENCIES = new Map([ ]); const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; const BETA_VERSION_PATTERN = /^0\.\d+\.\d+-beta\.\d+\.\d+\.[0-9a-f]{7}$/; -const RELEASE_ARTIFACTS = [ +export const RELEASE_ARTIFACTS = [ { filePrefix: "prime-agent-ai", packageName: "prime-agent-ai", sourceName: "@earendil-works/pi-ai" }, { filePrefix: "prime-agent-core", packageName: "prime-agent-core", sourceName: "@earendil-works/pi-agent-core" }, { filePrefix: "prime-agent-tui", packageName: "prime-agent-tui", sourceName: "@earendil-works/pi-tui" }, @@ -473,6 +473,9 @@ function validatePackedPackage(packageJson, artifact, version, baseUrl, artifact export function verifyReleaseArtifacts(artifactsDir, options) { const version = options.version; + if (!/^[0-9a-f]{40}$/.test(options.sourceSha ?? "")) { + throw new Error(`Artifact source SHA must be a full 40-character commit SHA: ${options.sourceSha ?? ""}`); + } if (options.channel !== "stable" && options.channel !== "beta") { throw new Error(`Artifact channel must be stable or beta: ${options.channel}`); } @@ -492,7 +495,11 @@ export function verifyReleaseArtifacts(artifactsDir, options) { "SHA256SUMS", options.channel, manifestName, - ].sort(); + ]; + const provenancePath = join(artifactsDir, "release-provenance.json"); + const hasProvenance = existsSync(provenancePath); + if (hasProvenance || !options.allowMissingProvenance) expectedFiles.push("release-provenance.json"); + expectedFiles.sort(); assertSameFiles(readdirSync(artifactsDir).sort(), expectedFiles); const checksums = parseChecksums(readFileSync(join(artifactsDir, "SHA256SUMS"), "utf8")); @@ -527,7 +534,18 @@ export function verifyReleaseArtifacts(artifactsDir, options) { if (!isDeepStrictEqual(manifest, expectedManifest)) { throw new Error(`${manifestName} does not match the verified release artifacts`); } - return expectedManifest; + const expectedProvenance = { + version: `v${version}`, + channel: options.channel, + sourceSha: options.sourceSha, + package: "prime-agent", + tarball: `releases/v${version}/${artifactFiles.get("prime-agent")}`, + tarballs, + }; + if (hasProvenance && !isDeepStrictEqual(readJson(provenancePath), expectedProvenance)) { + throw new Error("release-provenance.json does not match the verified source and release artifacts"); + } + return expectedProvenance; } function assertSameFiles(actual, expected) { diff --git a/scripts/lib/release-publication.mjs b/scripts/lib/release-publication.mjs index b2e1572ac4..e0df12d98a 100644 --- a/scripts/lib/release-publication.mjs +++ b/scripts/lib/release-publication.mjs @@ -99,20 +99,24 @@ export function promoteChannel(artifactsDir, channel, store, options = {}) { return { manifestKey, pointerKey }; } +export function publishInstallers(installers, store) { + for (const installer of installers) { + const local = readFileSync(installer.path); + store.putMutable(installer.key, installer.path, { + cacheControl: "no-cache", + contentType: "text/x-shellscript", + }); + requireRemoteMatch(store, installer.key, local); + } +} + export function publishChannel(options) { const immutable = publishImmutableArtifacts(options.artifactsDir, options.version, options.store); options.beforeMutable?.("mirror"); options.mirror?.(); verifyRemoteRelease(options.artifactsDir, options.version, options.store); options.beforeMutable?.("installers"); - for (const installer of options.installers ?? []) { - const local = readFileSync(installer.path); - options.store.putMutable(installer.key, installer.path, { - cacheControl: "no-cache", - contentType: "text/x-shellscript", - }); - requireRemoteMatch(options.store, installer.key, local); - } + publishInstallers(options.installers ?? [], options.store); options.beforeMutable?.("promotion"); const promotion = promoteChannel(options.artifactsDir, options.channel, options.store, { allowRegression: options.allowRegression, diff --git a/scripts/pack-prime-agent-release.mjs b/scripts/pack-prime-agent-release.mjs index 6cca5667fd..e10079489c 100644 --- a/scripts/pack-prime-agent-release.mjs +++ b/scripts/pack-prime-agent-release.mjs @@ -113,6 +113,7 @@ Creates private npm tarballs for R2 distribution: /artifacts/prime-agent-core-.tgz /artifacts/prime-agent-tui-.tgz /artifacts/SHA256SUMS + /artifacts/release-provenance.json /artifacts/ /artifacts/latest.json (stable) or beta.json (beta) `); @@ -342,6 +343,17 @@ function main() { const manifestName = args.channel === "stable" ? "latest.json" : "beta.json"; writeJson(join(artifactsDir, manifestName), { version: `v${releaseVersion}`, + package: publicPackageName, + tarball: `releases/v${releaseVersion}/${artifactFiles.get("coding-agent")}`, + tarballs: tarballs.map((tarball) => ({ + package: tarball.name, + file: tarball.file, + sha256: tarball.sha256, + })), + }); + writeJson(join(artifactsDir, "release-provenance.json"), { + version: `v${releaseVersion}`, + channel: args.channel, sourceSha: args.sourceSha, package: publicPackageName, tarball: `releases/v${releaseVersion}/${artifactFiles.get("coding-agent")}`, diff --git a/scripts/publish-release.mjs b/scripts/publish-release.mjs index 00d4589dfd..8c9793208a 100755 --- a/scripts/publish-release.mjs +++ b/scripts/publish-release.mjs @@ -1,14 +1,15 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; -import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { appendFileSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { validateReleaseRepository, validateRollbackRequest, verifyReleaseArtifacts } from "./lib/release-lifecycle.mjs"; import { promoteChannel, - publishChannel, + publishImmutableArtifacts, + publishInstallers, validatePromotion, verifyRemoteRelease, } from "./lib/release-publication.mjs"; @@ -17,8 +18,21 @@ const sourceRoot = resolve(process.env.PRIME_AGENT_RELEASE_SOURCE_ROOT || proces function parseArgs(args) { const operation = args[0]; - if (!["beta", "production", "rollback"].includes(operation)) { - throw new Error("Usage: node scripts/publish-release.mjs [options]"); + const operations = new Set([ + "beta-github", + "beta-r2-installers", + "beta-r2-immutable", + "beta-r2-promote", + "production-github-assets", + "production-github-prepare", + "production-r2-installers", + "production-r2-immutable", + "production-r2-promote", + "rollback-github-verify", + "rollback-r2-promote", + ]); + if (!operations.has(operation)) { + throw new Error(`Unsupported release publication phase: ${operation ?? "missing"}`); } const options = { operation }; for (let index = 1; index < args.length; index += 1) { @@ -347,12 +361,6 @@ function requireOption(options, name) { return options[name]; } -function releaseFilesForRemoteVerification(artifactsDir) { - return readdirSync(artifactsDir) - .filter((file) => file === "SHA256SUMS" || file.endsWith(".tgz")) - .sort(); -} - function installers(options) { return [ { key: "install.sh", path: resolve(requireOption(options, "stableInstaller")) }, @@ -360,79 +368,125 @@ function installers(options) { ]; } -function publishProduction(options, store, github, baseUrl) { +function validatePhaseArtifacts(options, baseUrl, channel) { const version = requireOption(options, "version"); const buildRef = requireOption(options, "buildRef"); + const sourceSha = requireOption(options, "sourceSha"); + if (!/^[0-9a-f]{40}$/.test(buildRef) || sourceSha !== buildRef) { + throw new Error(`Release build ref and source SHA must be the same full commit SHA: ${buildRef}/${sourceSha}`); + } const artifactsDir = resolve(requireOption(options, "artifactsDir")); - validateReleaseRepository(sourceRoot, { requireChangelogs: true, version }); - verifyReleaseArtifacts(artifactsDir, { baseUrl, channel: "stable", version }); + validateReleaseRepository(sourceRoot, { + requireChangelogs: channel === "stable", + version: channel === "beta" ? version.split("-", 1)[0] : version, + }); + verifyReleaseArtifacts(artifactsDir, { baseUrl, channel, sourceSha, version }); + return { artifactsDir, buildRef, sourceSha, version }; +} + +function prepareProductionGitHub(options, github, baseUrl) { + const { buildRef, version } = validatePhaseArtifacts(options, baseUrl, "stable"); + github.ensureProductionRelease(`v${version}`, buildRef, resolve(requireOption(options, "notesFile"))); +} + +function publishProductionR2Immutable(options, store, baseUrl) { + const { artifactsDir, version } = validatePhaseArtifacts(options, baseUrl, "stable"); validatePromotion(artifactsDir, "stable", store); + const result = publishImmutableArtifacts(artifactsDir, version, store); + verifyRemoteRelease(artifactsDir, version, store); + console.log(`Published production immutable objects: ${result.created} created, ${result.reused} reused.`); +} + +function publishProductionGitHubAssets(options, github, baseUrl) { + const { artifactsDir, buildRef, version } = validatePhaseArtifacts(options, baseUrl, "stable"); const tag = `v${version}`; github.ensureProductionRelease(tag, buildRef, resolve(requireOption(options, "notesFile"))); - const result = publishChannel({ - artifactsDir, - channel: "stable", - installers: installers(options), - mirror: () => github.ensureProductionAssets(tag, artifactsDir), - store, - version, - }); - console.log(`Published production ${tag}: ${result.created} immutable objects created, ${result.reused} reused.`); + github.ensureProductionAssets(tag, artifactsDir); } -function publishBeta(options, store, github, baseUrl) { - const version = requireOption(options, "version"); - const buildRef = requireOption(options, "buildRef"); +function promoteProductionR2(options, store, baseUrl) { + const { artifactsDir, version } = validatePhaseArtifacts(options, baseUrl, "stable"); + validatePromotion(artifactsDir, "stable", store); + verifyRemoteRelease(artifactsDir, version, store); + promoteChannel(artifactsDir, "stable", store); + console.log(`Promoted production v${version}.`); +} + +function publishBetaR2Immutable(options, store, baseUrl) { + const { artifactsDir, version } = validatePhaseArtifacts(options, baseUrl, "beta"); + const result = publishImmutableArtifacts(artifactsDir, version, store); + verifyRemoteRelease(artifactsDir, version, store); + console.log(`Published beta immutable objects: ${result.created} created, ${result.reused} reused.`); +} + +function publishBetaGitHub(options, github, baseUrl) { + const { artifactsDir, buildRef, version } = validatePhaseArtifacts(options, baseUrl, "beta"); const defaultBranch = requireOption(options, "defaultBranch"); - const artifactsDir = resolve(requireOption(options, "artifactsDir")); if (github.latestDefaultBranchSha(defaultBranch) !== buildRef) { - console.log("A newer default-branch commit exists; leaving beta release state unchanged."); - return; + throw new Error("A newer default-branch commit exists; refusing stale GitHub beta update"); } - const requireCurrentBuild = () => { - if (github.latestDefaultBranchSha(defaultBranch) !== buildRef) { - throw new Error("A newer default-branch commit exists; refusing stale mutable beta updates"); - } - }; - validateReleaseRepository(sourceRoot, { requireChangelogs: false, version: version.split("-", 1)[0] }); - verifyReleaseArtifacts(artifactsDir, { baseUrl, channel: "beta", version }); - const result = publishChannel({ - artifactsDir, - beforeMutable: requireCurrentBuild, - channel: "beta", - installers: installers(options), - mirror: () => github.replaceBetaRelease(buildRef, version, artifactsDir, defaultBranch), - store, - version, - }); - console.log(`Published beta v${version}: ${result.created} immutable objects created, ${result.reused} reused.`); + github.replaceBetaRelease(buildRef, version, artifactsDir, defaultBranch); +} + +function publishR2Installers(options, store, baseUrl, channel) { + const { artifactsDir, version } = validatePhaseArtifacts(options, baseUrl, channel); + verifyRemoteRelease(artifactsDir, version, store); + publishInstallers(installers(options), store); + console.log(`Published ${channel} installers for v${version}.`); +} + +function promoteBetaR2(options, store, baseUrl) { + const { artifactsDir, version } = validatePhaseArtifacts(options, baseUrl, "beta"); + verifyRemoteRelease(artifactsDir, version, store); + promoteChannel(artifactsDir, "beta", store); + console.log(`Promoted beta v${version}.`); } -function rollbackProduction(options, store, github, baseUrl) { +function verifyRollbackGitHub(options, github, baseUrl) { const releaseTag = requireOption(options, "releaseTag"); validateRollbackRequest(releaseTag, requireOption(options, "confirmation")); - const artifactsDir = mkdtempSync(join(tmpdir(), "prime-agent-rollback-assets-")); - try { - const tagTarget = github.verifyExistingRelease(releaseTag, artifactsDir); - const defaultBranch = requireOption(options, "defaultBranch"); - if ( - run("git", ["merge-base", "--is-ancestor", tagTarget, `origin/${defaultBranch}`], { - allowFailure: true, - cwd: sourceRoot, - }) === undefined - ) { - throw new Error(`Rollback tag ${releaseTag} is not on the default branch`); - } - const version = releaseTag.slice(1); - verifyReleaseArtifacts(artifactsDir, { baseUrl, channel: "stable", version }); - verifyRemoteRelease(artifactsDir, version, store, { - files: releaseFilesForRemoteVerification(artifactsDir), - }); - promoteChannel(artifactsDir, "stable", store, { allowRegression: true }); - console.log(`Rolled back stable pointers to ${releaseTag}. Installed newer clients were not downgraded.`); - } finally { - rmSync(artifactsDir, { force: true, recursive: true }); + const artifactsDir = resolve(requireOption(options, "artifactsDir")); + mkdirSync(artifactsDir, { recursive: true }); + if (readdirSync(artifactsDir).length > 0) { + throw new Error(`Rollback artifact directory must be empty: ${artifactsDir}`); } + const tagTarget = github.verifyExistingRelease(releaseTag, artifactsDir); + const defaultBranch = requireOption(options, "defaultBranch"); + if ( + run("git", ["merge-base", "--is-ancestor", tagTarget, `origin/${defaultBranch}`], { + allowFailure: true, + cwd: sourceRoot, + }) === undefined + ) { + throw new Error(`Rollback tag ${releaseTag} is not on the default branch`); + } + verifyReleaseArtifacts(artifactsDir, { + allowMissingProvenance: true, + baseUrl, + channel: "stable", + sourceSha: tagTarget, + version: releaseTag.slice(1), + }); + if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `source_sha=${tagTarget}\n`); + console.log(`Verified rollback release ${releaseTag} at ${tagTarget}.`); +} + +function promoteRollbackR2(options, store, baseUrl) { + const releaseTag = requireOption(options, "releaseTag"); + validateRollbackRequest(releaseTag, requireOption(options, "confirmation")); + const artifactsDir = resolve(requireOption(options, "artifactsDir")); + const sourceSha = requireOption(options, "sourceSha"); + const version = releaseTag.slice(1); + verifyReleaseArtifacts(artifactsDir, { + allowMissingProvenance: true, + baseUrl, + channel: "stable", + sourceSha, + version, + }); + verifyRemoteRelease(artifactsDir, version, store); + promoteChannel(artifactsDir, "stable", store, { allowRegression: true }); + console.log(`Rolled back stable pointers to ${releaseTag}. Installed newer clients were not downgraded.`); } let store; @@ -440,11 +494,23 @@ try { const options = parseArgs(process.argv.slice(2)); const baseUrl = (process.env.R2_PUBLIC_BASE_URL || "").replace(/\/+$/, ""); if (!baseUrl) throw new Error("R2_PUBLIC_BASE_URL is required"); - store = new AwsR2Store(process.env.R2_BUCKET, process.env.R2_ENDPOINT_URL); - const github = new GitHubReleaseMirror(process.env.GITHUB_REPOSITORY); - if (options.operation === "production") publishProduction(options, store, github, baseUrl); - else if (options.operation === "beta") publishBeta(options, store, github, baseUrl); - else rollbackProduction(options, store, github, baseUrl); + if (options.operation.includes("-r2-")) { + store = new AwsR2Store(process.env.R2_BUCKET, process.env.R2_ENDPOINT_URL); + } + const github = options.operation.includes("github") + ? new GitHubReleaseMirror(process.env.GITHUB_REPOSITORY) + : undefined; + if (options.operation === "production-github-prepare") prepareProductionGitHub(options, github, baseUrl); + else if (options.operation === "production-r2-immutable") publishProductionR2Immutable(options, store, baseUrl); + else if (options.operation === "production-github-assets") publishProductionGitHubAssets(options, github, baseUrl); + else if (options.operation === "production-r2-installers") publishR2Installers(options, store, baseUrl, "stable"); + else if (options.operation === "production-r2-promote") promoteProductionR2(options, store, baseUrl); + else if (options.operation === "beta-r2-immutable") publishBetaR2Immutable(options, store, baseUrl); + else if (options.operation === "beta-github") publishBetaGitHub(options, github, baseUrl); + else if (options.operation === "beta-r2-installers") publishR2Installers(options, store, baseUrl, "beta"); + else if (options.operation === "beta-r2-promote") promoteBetaR2(options, store, baseUrl); + else if (options.operation === "rollback-github-verify") verifyRollbackGitHub(options, github, baseUrl); + else promoteRollbackR2(options, store, baseUrl); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; diff --git a/scripts/release-dry-run.mjs b/scripts/release-dry-run.mjs index 23273b78c2..49bf3547c5 100755 --- a/scripts/release-dry-run.mjs +++ b/scripts/release-dry-run.mjs @@ -16,23 +16,25 @@ function parseArgs(args) { artifactsDir: undefined, baseUrl: defaultBaseUrl, channel: "stable", + sourceSha: process.env.PRIME_AGENT_SOURCE_SHA, version: undefined, }; for (let index = 0; index < args.length; index += 1) { const argument = args[index]; if (argument === "--help" || argument === "-h") { console.log( - "Usage: npm run release:dry-run -- [--channel stable|beta] [--version version] [--base-url url] [--artifacts-dir path]", + "Usage: npm run release:dry-run -- [--channel stable|beta] [--version version] [--source-sha sha] [--base-url url] [--artifacts-dir path]", ); process.exit(0); } - if (!["--artifacts-dir", "--base-url", "--channel", "--version"].includes(argument)) { + if (!["--artifacts-dir", "--base-url", "--channel", "--source-sha", "--version"].includes(argument)) { throw new Error(`Unknown argument: ${argument}`); } const key = { "--artifacts-dir": "artifactsDir", "--base-url": "baseUrl", "--channel": "channel", + "--source-sha": "sourceSha", "--version": "version", }[argument]; const value = args[index + 1]; @@ -43,6 +45,14 @@ function parseArgs(args) { if (parsed.channel !== "stable" && parsed.channel !== "beta") { throw new Error("--channel must be stable or beta"); } + if (!parsed.sourceSha) { + const result = spawnSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8", stdio: "pipe" }); + if (result.status !== 0) throw new Error(result.stderr.trim() || "Unable to resolve release source SHA"); + parsed.sourceSha = result.stdout.trim(); + } + if (!/^[0-9a-f]{40}$/.test(parsed.sourceSha)) { + throw new Error(`--source-sha must be a full 40-character commit SHA: ${parsed.sourceSha}`); + } return parsed; } @@ -57,6 +67,8 @@ function runPacker(options, outDir) { options.version, "--base-url", options.baseUrl, + "--source-sha", + options.sourceSha, "--out-dir", outDir, ], diff --git a/scripts/release-lifecycle.test.mjs b/scripts/release-lifecycle.test.mjs index 658ec06532..8499b38fbe 100644 --- a/scripts/release-lifecycle.test.mjs +++ b/scripts/release-lifecycle.test.mjs @@ -26,6 +26,7 @@ const packageNames = { "coding-agent": "@earendil-works/pi-coding-agent", tui: "@earendil-works/pi-tui", }; +const sourceSha = "a".repeat(40); function writeJson(path, value) { writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); @@ -122,6 +123,14 @@ function createArtifactFixture(version = "0.7.2", channel = "stable", options = tarball: `releases/v${version}/prime-agent-${version}.tgz`, tarballs, }); + writeJson(join(artifactsDir, "release-provenance.json"), { + version: `v${version}`, + channel, + sourceSha, + package: "prime-agent", + tarball: `releases/v${version}/prime-agent-${version}.tgz`, + tarballs, + }); return { artifactsDir, root }; } @@ -382,6 +391,7 @@ test("artifact verification checks branded manifests, internal URLs, and every c const result = verifyReleaseArtifacts(artifactsDir, { baseUrl: "https://release.invalid", channel: "stable", + sourceSha, version: "0.7.2", }); assert.equal(result.tarballs.length, 4); @@ -391,6 +401,7 @@ test("artifact verification checks branded manifests, internal URLs, and every c verifyReleaseArtifacts(artifactsDir, { baseUrl: "https://release.invalid", channel: "stable", + sourceSha, version: "0.7.2", }), /checksum mismatch/i, @@ -407,6 +418,7 @@ test("artifact verification accepts only workflow-shaped beta versions", () => { verifyReleaseArtifacts(artifactsDir, { baseUrl: "https://release.invalid", channel: "beta", + sourceSha, version: "0.7.2-beta.42.1.0123456", }).version, "v0.7.2-beta.42.1.0123456", @@ -416,6 +428,7 @@ test("artifact verification accepts only workflow-shaped beta versions", () => { verifyReleaseArtifacts(artifactsDir, { baseUrl: "https://release.invalid", channel: "beta", + sourceSha, version: "0.7.2-custom", }), /workflow beta version/i, @@ -435,6 +448,7 @@ test("artifact verification rejects a packed CLI missing required internal R2 de verifyReleaseArtifacts(artifactsDir, { baseUrl: "https://release.invalid", channel: "stable", + sourceSha, version: "0.7.2", }), /missing the required internal dependency/i, @@ -444,6 +458,34 @@ test("artifact verification rejects a packed CLI missing required internal R2 de } }); +test("legacy release assets remain verifiable for retry and rollback without provenance", () => { + const { artifactsDir, root } = createArtifactFixture(); + try { + rmSync(join(artifactsDir, "release-provenance.json")); + assert.throws( + () => + verifyReleaseArtifacts(artifactsDir, { + baseUrl: "https://release.invalid", + channel: "stable", + sourceSha, + version: "0.7.2", + }), + /release-provenance\.json/, + ); + assert.doesNotThrow(() => + verifyReleaseArtifacts(artifactsDir, { + allowMissingProvenance: true, + baseUrl: "https://release.invalid", + channel: "stable", + sourceSha, + version: "0.7.2", + }), + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("current dry-run tooling validates a pre-migration source tree without release scripts", () => { const sourceRoot = createRepositoryFixture("0.7.1"); const { artifactsDir, root: artifactRoot } = createArtifactFixture("0.7.1", "stable"); @@ -458,6 +500,8 @@ test("current dry-run tooling validates a pre-migration source tree without rele "0.7.1", "--base-url", "https://release.invalid", + "--source-sha", + sourceSha, "--artifacts-dir", artifactsDir, ], diff --git a/scripts/release-workflow-contract.test.mjs b/scripts/release-workflow-contract.test.mjs index 2e4fa1a838..55eb64aade 100644 --- a/scripts/release-workflow-contract.test.mjs +++ b/scripts/release-workflow-contract.test.mjs @@ -10,6 +10,7 @@ const releaseWorkflow = readFileSync(new URL("../.github/workflows/build-binarie const rollbackWorkflow = readFileSync(new URL("../.github/workflows/rollback-release.yml", import.meta.url), "utf8"); const rollbackResolver = readFileSync(new URL("resolve-rollback-context.mjs", import.meta.url), "utf8"); const publisher = readFileSync(new URL("publish-release.mjs", import.meta.url), "utf8"); +const packageContract = readFileSync(new URL("check-release-package-contract.mjs", import.meta.url), "utf8"); test("legacy local release and publish commands are non-mutating tombstones", () => { for (const scriptName of [ @@ -32,56 +33,79 @@ test("legacy local release and publish commands are non-mutating tombstones", () assert.doesNotMatch(dryRun, /npm publish|git (?:commit|tag|push)|\bgh\b|\baws\b/); }); -test("release workflow publishes from main or an immutable retry tag, never a tag push", () => { - assert.doesNotMatch(releaseWorkflow, /^\s+tags:/m); - assert.doesNotMatch(releaseWorkflow, /workflow_dispatch/); +test("release authority is successful canonical CI or an authorized immutable retry", () => { + assert.match(releaseWorkflow, /workflow_run:\n\s+workflows: \[CI\]\n\s+types: \[completed\]/); assert.match(releaseWorkflow, /issue_comment:/); + assert.doesNotMatch(releaseWorkflow, /workflow_dispatch|^\s+push:|^\s+tags:/m); assert.match(releaseWorkflow, /\/prime-agent release retry/); assert.match(releaseWorkflow, /collaborators\/\$\{ACTOR\}\/permission/); - assert.match(releaseWorkflow, /node scripts\/resolve-release-context\.mjs/); - assert.doesNotMatch(releaseWorkflow, /Production release tag to create or update/); assert.match(releaseWorkflow, /ref: \$\{\{ github\.workflow_sha \}\}/); assert.match(releaseWorkflow, /path: release-tooling/); assert.match(releaseWorkflow, /path: release-source/); - assert.match(releaseWorkflow, /PRIME_AGENT_RELEASE_SOURCE_ROOT:/); - assert.match(releaseWorkflow, /node \.\.\/release-tooling\/scripts\/publish-release\.mjs production/); + assert.match(releaseWorkflow, /full-ci:[\s\S]*outputs\.trigger == 'retry'/); + assert.match(releaseWorkflow, /uses: \.\/\.github\/workflows\/ci\.yml/); }); -test("production publication compares immutable assets and commits latest.json last", () => { - assert.match(releaseWorkflow, /node \.\.\/release-tooling\/scripts\/publish-release\.mjs production/); +test("release artifacts, verification, and publication use one exact source SHA", () => { + assert.match(releaseWorkflow, /--source-sha "\$BUILD_SHA"/); + assert.match(packageContract, /--source-sha["'],\s*contractSourceSha/); + assert.match(releaseWorkflow, /prime-agent-production-\$\{\{ env\.BUILD_SHA \}\}/); + assert.match(releaseWorkflow, /prime-agent-beta-\$\{\{ env\.BUILD_SHA \}\}/); + assert.match(releaseWorkflow, /Verify release source commit[\s\S]*git rev-parse HEAD/); + assert.match(releaseWorkflow, /release-gate:[\s\S]*needs: \[release-context, full-ci, build\]/); + assert.match(releaseWorkflow, /publish:[\s\S]*needs: release-gate/); +}); + +test("production and beta mutation phases preserve ordering and credential separation", () => { + const orderedNames = [ + "Prepare production GitHub release", + "Publish production immutable objects to R2", + "Publish production GitHub assets", + "Publish production installers to R2", + "Promote production channel in R2", + "Publish beta immutable objects to R2", + "Check beta freshness before GitHub mirror", + "Advance beta GitHub release", + "Check beta freshness before installers", + "Advance beta installers in R2", + "Check beta freshness before channel promotion", + "Advance beta channel in R2", + ]; + let previous = -1; + for (const name of orderedNames) { + const index = releaseWorkflow.indexOf(`- name: ${name}`); + assert.ok(index > previous, `${name} must follow the preceding publication phase`); + previous = index; + } assert.doesNotMatch(releaseWorkflow, /gh release upload[^\n]*--clobber/); - const productionStep = releaseWorkflow.indexOf("node ../release-tooling/scripts/publish-release.mjs production"); - const betaStep = releaseWorkflow.indexOf("node ../release-tooling/scripts/publish-release.mjs beta"); - assert.ok(productionStep > -1); - assert.ok(betaStep > productionStep); - assert.match(publisher, /beforeMutable: requireCurrentBuild/); + assert.match(publisher, /putImmutable/); + assert.match(publisher, /ensureProductionAssets/); + assert.match(publisher, /promoteChannel\(artifactsDir, "stable"/); }); -test("rollback is a separately protected pointer-only workflow", () => { +test("rollback authorizes before protected resources and splits GitHub verification from R2 promotion", () => { assert.match(rollbackWorkflow, /^name: Rollback Prime Agent stable channel$/m); assert.doesNotMatch(rollbackWorkflow, /workflow_dispatch/); - assert.match(rollbackWorkflow, /issue_comment:/); assert.match(rollbackWorkflow, /\/prime-agent release rollback/); assert.match(rollbackWorkflow, /collaborators\/\$\{ACTOR\}\/permission/); - assert.match(rollbackResolver, /confirmation/); - assert.match(rollbackWorkflow, /node scripts\/resolve-rollback-context\.mjs/); - assert.match(rollbackWorkflow, /node scripts\/publish-release\.mjs rollback/); - assert.doesNotMatch(rollbackWorkflow, /gh release (?:create|edit|upload)|git (?:tag|push)|npm publish/); - + assert.match(rollbackResolver, /source_sha/); const authorizationJob = rollbackWorkflow.indexOf(" authorize:"); const rollbackJob = rollbackWorkflow.indexOf(" rollback:"); assert.ok(authorizationJob > -1); assert.ok(rollbackJob > authorizationJob); - const authorization = rollbackWorkflow.slice(authorizationJob, rollbackJob); - assert.doesNotMatch(authorization, /environment: production/); - assert.doesNotMatch(authorization, /group: release-prime-agent/); - assert.doesNotMatch(authorization, /R2_(?:ACCESS_KEY_ID|SECRET_ACCESS_KEY|BUCKET|ENDPOINT_URL)/); - + assert.doesNotMatch(authorization, /environment: production|group: release-prime-agent|secrets\.R2_/); const mutation = rollbackWorkflow.slice(rollbackJob); assert.match(mutation, /needs: authorize/); assert.match(mutation, /environment: production/); assert.match(mutation, /group: release-prime-agent/); - assert.match(mutation, /CONFIRMATION: \$\{\{ needs\.authorize\.outputs\.confirmation \}\}/); - assert.match(mutation, /RELEASE_TAG: \$\{\{ needs\.authorize\.outputs\.release_tag \}\}/); + assert.match(mutation, /rollback-github-verify/); + assert.match(mutation, /rollback-r2-promote/); + const githubStep = mutation.slice( + mutation.indexOf("- name: Verify rollback GitHub release"), + mutation.indexOf("- name: Promote verified stable pointers in R2"), + ); + assert.doesNotMatch(githubStep, /secrets\.R2_/); + const r2Step = mutation.slice(mutation.indexOf("- name: Promote verified stable pointers in R2")); + assert.doesNotMatch(r2Step, /GH_TOKEN|secrets\.GITHUB_TOKEN/); }); diff --git a/scripts/release-workflow.test.mjs b/scripts/release-workflow.test.mjs index f29f8b7564..817953304f 100644 --- a/scripts/release-workflow.test.mjs +++ b/scripts/release-workflow.test.mjs @@ -23,28 +23,40 @@ const releaseWorkflow = readFileSync(new URL("../.github/workflows/build-binarie const ciWorkflow = readFileSync(new URL("../.github/workflows/ci.yml", import.meta.url), "utf8"); describe("workflow contract", () => { - it("publishes main only from completed CI and reuses full CI for tag/manual", () => { + it("publishes main only from completed CI and reuses full CI only for protected retry", () => { assert.match(releaseWorkflow, /workflow_run:\n\s+workflows: \[CI\]\n\s+types: \[completed\]/); - assert.doesNotMatch(releaseWorkflow, /push:\n\s+branches:/); - assert.match(releaseWorkflow, /full-ci:[\s\S]*uses: \.\/\.github\/workflows\/ci\.yml/); + assert.match(releaseWorkflow, /issue_comment:/); + assert.doesNotMatch(releaseWorkflow, /workflow_dispatch|^\s+push:|^\s+tags:/m); + assert.match(releaseWorkflow, /full-ci:[\s\S]*outputs\.trigger == 'retry'/); + assert.match(releaseWorkflow, /uses: \.\/\.github\/workflows\/ci\.yml/); assert.match(releaseWorkflow, /source_sha: \$\{\{ needs\.release-context\.outputs\.build_sha \}\}/); + assert.match(releaseWorkflow, /tooling_sha: \$\{\{ github\.workflow_sha \}\}/); }); it("binds CI, build, artifacts, and publication to the resolved SHA", () => { - assert.match(ciWorkflow, /workflow_call:[\s\S]*source_sha:/); - assert.equal((ciWorkflow.match(/ref: \$\{\{ inputs\.source_sha \|\| github\.sha \}\}/g) || []).length, 4); - assert.match(readFileSync(new URL("./resolve-release-context.mjs", import.meta.url), "utf8"), /checkedOutSha !== buildSha/); - assert.match(releaseWorkflow, /Verify release source remains exact[\s\S]*git diff --exit-code/); + assert.match(ciWorkflow, /workflow_call:[\s\S]*source_sha:[\s\S]*tooling_sha:/); + assert.ok((ciWorkflow.match(/ref: \$\{\{ inputs\.source_sha \|\| github\.sha \}\}/g) || []).length >= 3); + assert.match(releaseWorkflow, /Verify release source commit[\s\S]*git rev-parse HEAD/); assert.match(releaseWorkflow, /name: prime-agent-production-\$\{\{ env\.BUILD_SHA \}\}/); assert.match(releaseWorkflow, /name: prime-agent-beta-\$\{\{ env\.BUILD_SHA \}\}/); - assert.match(releaseWorkflow, /publish:\n\s+runs-on: ubuntu-latest\n\s+needs: release-gate/); + assert.match(releaseWorkflow, /publish:[\s\S]*needs: release-gate/); }); - it("refuses local and immutable remote provenance drift before upload", () => { + it("validates source provenance before every split publication phase", () => { assert.match(releaseWorkflow, /--source-sha "\$BUILD_SHA"/); - assert.match(releaseWorkflow, /--remote-checksums "\$REMOTE_SUMS"/); - assert.match(releaseWorkflow, /--remote-manifest "\$REMOTE_MANIFEST"/); - assert.match(releaseWorkflow, /Refusing to overwrite incomplete immutable release prefix/); + for (const phase of [ + "production-github-prepare", + "production-r2-immutable", + "production-github-assets", + "production-r2-installers", + "production-r2-promote", + "beta-r2-immutable", + "beta-github", + "beta-r2-installers", + "beta-r2-promote", + ]) { + assert.match(releaseWorkflow, new RegExp(`publish-release\\.mjs ${phase}`)); + } }); }); @@ -53,14 +65,12 @@ function mainContext(overrides = {}) { buildSha, defaultBranch: "main", eventName: "workflow_run", - existingTagSha: null, - inputReleaseTag: "", packageVersions, - refName: "", - refType: "", + previousVersion: "0.7.0", repository: "PrimeIntellect-ai/prime-agent", runAttempt: "2", runNumber: "123", + tagTarget: undefined, workflowRun: { conclusion: "success", event: "push", @@ -73,10 +83,25 @@ function mainContext(overrides = {}) { }; } +function retryContext(overrides = {}) { + return { + actorPermission: "maintain", + buildSha, + commentBody: "/prime-agent release retry v0.7.1", + defaultBranch: "main", + eventName: "issue_comment", + packageVersions, + tagOnDefaultBranch: true, + workflowRef: "PrimeIntellect-ai/prime-agent/.github/workflows/build-binaries.yml@refs/heads/main", + ...overrides, + }; +} + describe("release context", () => { it("binds a successful main CI run to its exact SHA", () => { assert.deepEqual(resolveReleaseContext(mainContext()), { betaVersion: `0.7.1-beta.123.2.${buildSha.slice(0, 7)}`, + buildRef: buildSha, buildSha, productionVersion: "0.7.1", publishBeta: true, @@ -85,139 +110,97 @@ describe("release context", () => { }); }); - it("does not republish a production version tagged at another main SHA", () => { - const context = resolveReleaseContext(mainContext({ existingTagSha: otherSha })); + it("publishes only beta when the default-branch package version is unchanged", () => { + const context = resolveReleaseContext(mainContext({ previousVersion: "0.7.1", tagTarget: otherSha })); assert.equal(context.publishBeta, true); assert.equal(context.publishProduction, false); + assert.equal(context.productionVersion, ""); }); - it("rejects failed, cancelled, and skipped CI", () => { + it("rejects failed, cancelled, skipped, wrong-source, and wrong-SHA upstream CI", () => { for (const conclusion of ["failure", "cancelled", "skipped"]) { assert.throws( - () => - resolveReleaseContext( - mainContext({ workflowRun: { ...mainContext().workflowRun, conclusion } }), - ), + () => resolveReleaseContext(mainContext({ workflowRun: { ...mainContext().workflowRun, conclusion } })), /CI conclusion must be success/, ); } - }); - - it("rejects PR, fork, branch, workflow-path, and SHA mismatches", () => { - const invalidRuns = [ + for (const invalid of [ { event: "pull_request" }, { headRepository: "attacker/prime-agent" }, { headBranch: "feature" }, { workflowPath: ".github/workflows/other.yml" }, { headSha: otherSha }, - ]; - for (const invalid of invalidRuns) { + ]) { assert.throws(() => - resolveReleaseContext( - mainContext({ workflowRun: { ...mainContext().workflowRun, ...invalid } }), - ), + resolveReleaseContext(mainContext({ workflowRun: { ...mainContext().workflowRun, ...invalid } })), ); } }); - it("accepts exact tag and default-branch manual releases", () => { - const tag = resolveReleaseContext({ - ...mainContext(), - eventName: "push", - existingTagSha: buildSha, - refName: "v0.7.1", - refType: "tag", - }); - assert.equal(tag.trigger, "tag"); - assert.equal(tag.publishProduction, true); - - const manual = resolveReleaseContext({ - ...mainContext(), - eventName: "workflow_dispatch", - existingTagSha: null, - inputReleaseTag: "v0.7.1", - refName: "main", + it("accepts only an exact authorized retry bound to an existing default-branch tag", () => { + assert.deepEqual(resolveReleaseContext(retryContext()), { + betaVersion: "", + buildRef: buildSha, + buildSha, + productionVersion: "0.7.1", + publishBeta: false, + publishProduction: true, + trigger: "retry", }); - assert.equal(manual.trigger, "manual"); - assert.equal(manual.publishProduction, true); + for (const invalid of [ + { actorPermission: "write" }, + { commentBody: "/prime-agent release retry v0.7.1 extra" }, + { tagOnDefaultBranch: false }, + { workflowRef: "PrimeIntellect-ai/prime-agent/.github/workflows/build-binaries.yml@refs/heads/feature" }, + ]) { + assert.throws(() => resolveReleaseContext(retryContext(invalid))); + } }); - it("rejects tag, manual branch, and package-version mismatches", () => { + it("rejects tag pushes, manual dispatch, invalid rerun identifiers, and lockstep drift", () => { + assert.throws(() => resolveReleaseContext({ ...mainContext(), eventName: "push" }), /Unsupported release event/); assert.throws( - () => - resolveReleaseContext({ - ...mainContext(), - eventName: "push", - refName: "v0.7.2", - refType: "tag", - }), - /tag v0.7.2 does not match/i, + () => resolveReleaseContext({ ...mainContext(), eventName: "workflow_dispatch" }), + /Unsupported release event/, ); - assert.throws( - () => - resolveReleaseContext({ - ...mainContext(), - eventName: "workflow_dispatch", - inputReleaseTag: "v0.7.1", - refName: "feature", - }), - /default branch/, - ); - assert.throws( - () => assertLockstepVersions({ ...packageVersions, tui: "0.7.0" }), - /versions must match/, - ); - }); - - it("rejects invalid rerun identifiers", () => { assert.throws(() => resolveReleaseContext(mainContext({ runAttempt: "0" })), /Run attempt/); assert.throws(() => resolveReleaseContext(mainContext({ runNumber: "latest" })), /Run number/); + assert.throws(() => assertLockstepVersions({ ...packageVersions, tui: "0.7.0" }), /versions must match/); }); }); describe("aggregate CI gate", () => { - it("accepts only all-success results", () => { + it("accepts only all-success Node and Python results", () => { assert.doesNotThrow(() => verifyCiResults({ "build-check": "success", test: "success", "python-runtime": "success" }), ); - }); - - it("rejects a failed shard or cancelled/skipped required job", () => { for (const result of ["failure", "cancelled", "skipped"]) { assert.throws( () => verifyCiResults({ "build-check": "success", test: result, "python-runtime": "success" }), - /new Error|Required CI jobs did not succeed/, + /Required CI jobs did not succeed/, ); } }); - it("requires upstream CI for main and reusable CI for tag/manual releases", () => { + it("reuses successful upstream CI for main and requires reusable CI for retry", () => { assert.doesNotThrow(() => verifyReleaseGate({ build: "success", fullCi: "skipped", releaseContext: "success", trigger: "main" }), ); - for (const trigger of ["tag", "manual"]) { - assert.doesNotThrow(() => - verifyReleaseGate({ build: "success", fullCi: "success", releaseContext: "success", trigger }), - ); - } - }); - - it("rejects bypassed or unsuccessful release gates", () => { + assert.doesNotThrow(() => + verifyReleaseGate({ build: "success", fullCi: "success", releaseContext: "success", trigger: "retry" }), + ); for (const result of ["failure", "cancelled", "skipped"]) { assert.throws( - () => - verifyReleaseGate({ build: "success", fullCi: result, releaseContext: "success", trigger: "tag" }), + () => verifyReleaseGate({ build: "success", fullCi: result, releaseContext: "success", trigger: "retry" }), /Required CI jobs did not succeed/, ); } assert.throws( - () => - verifyReleaseGate({ build: "failure", fullCi: "skipped", releaseContext: "success", trigger: "main" }), + () => verifyReleaseGate({ build: "failure", fullCi: "skipped", releaseContext: "success", trigger: "main" }), /Required CI jobs did not succeed/, ); assert.throws( - () => - verifyReleaseGate({ build: "success", fullCi: "success", releaseContext: "success", trigger: "main" }), + () => verifyReleaseGate({ build: "success", fullCi: "success", releaseContext: "success", trigger: "main" }), /reuse completed upstream CI/, ); }); @@ -259,6 +242,20 @@ function createArtifactFixture(channel = "stable") { `${JSON.stringify( { version: `v${version}`, + package: "prime-agent", + tarball: `releases/v${version}/prime-agent-${version}.tgz`, + tarballs: tarballs.map(({ name, ...entry }) => ({ package: name, ...entry })), + }, + null, + 2, + )}\n`, + ); + writeFileSync( + join(directory, "release-provenance.json"), + `${JSON.stringify( + { + version: `v${version}`, + channel, sourceSha: buildSha, package: "prime-agent", tarball: `releases/v${version}/prime-agent-${version}.tgz`, @@ -294,7 +291,9 @@ describe("release artifact provenance", () => { it("refuses immutable remote checksum drift", () => { const fixture = createArtifactFixture(); - const remoteChecksums = join(fixture.directory, "REMOTE_SHA256SUMS"); + const remoteDirectory = mkdtempSync(join(tmpdir(), "prime-agent-remote-release-")); + temporaryDirectories.push(remoteDirectory); + const remoteChecksums = join(remoteDirectory, "SHA256SUMS"); const local = readFileSync(join(fixture.directory, "SHA256SUMS"), "utf8"); writeFileSync(remoteChecksums, `${local[0] === "f" ? "e" : "f"}${local.slice(1)}`); assert.throws( @@ -305,8 +304,10 @@ describe("release artifact provenance", () => { it("refuses immutable remote source-manifest drift", () => { const fixture = createArtifactFixture(); - const remoteManifest = join(fixture.directory, "REMOTE_MANIFEST.json"); - const manifest = JSON.parse(readFileSync(join(fixture.directory, fixture.manifestName), "utf8")); + const remoteDirectory = mkdtempSync(join(tmpdir(), "prime-agent-remote-release-")); + temporaryDirectories.push(remoteDirectory); + const remoteManifest = join(remoteDirectory, "release-provenance.json"); + const manifest = JSON.parse(readFileSync(join(fixture.directory, "release-provenance.json"), "utf8")); writeFileSync(remoteManifest, `${JSON.stringify({ ...manifest, sourceSha: otherSha }, null, 2)}\n`); assert.throws( () => verifyReleaseArtifacts({ ...fixture, remoteManifest, sourceSha: buildSha }), diff --git a/scripts/resolve-release-context.mjs b/scripts/resolve-release-context.mjs index 7a00d3c787..2f7f649dba 100644 --- a/scripts/resolve-release-context.mjs +++ b/scripts/resolve-release-context.mjs @@ -1,13 +1,21 @@ #!/usr/bin/env node +import { spawnSync } from "node:child_process"; import { appendFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { spawnSync } from "node:child_process"; -import { createReleasePlan, parseReleaseComment, validateReleaseRepository } from "./lib/release-lifecycle.mjs"; +import { createReleasePlan, parseReleaseComment } from "./lib/release-lifecycle.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const commitShaPattern = /^[0-9a-f]{40}$/; +const packageManifests = { + root: "package.json", + agent: "packages/agent/package.json", + ai: "packages/ai/package.json", + "coding-agent": "packages/coding-agent/package.json", + tui: "packages/tui/package.json", +}; function git(args, options = {}) { const result = spawnSync("git", args, { cwd: root, encoding: "utf8", stdio: "pipe" }); @@ -18,86 +26,206 @@ function git(args, options = {}) { return result.stdout.trim(); } -function versionAt(ref) { - const packageJson = git(["show", `${ref}:package.json`]); - return JSON.parse(packageJson).version; +function assertCommitSha(value, name) { + if (!commitShaPattern.test(value)) { + throw new Error(`${name} must be a full 40-character commit SHA: ${value}`); + } } -function resolveTagTarget(tag) { - return git(["rev-parse", `refs/tags/${tag}^{commit}`], { allowFailure: true }); +function assertPositiveInteger(value, name) { + if (!/^[1-9][0-9]*$/.test(value)) throw new Error(`${name} must be a positive integer: ${value}`); } -function planPush() { - const repository = validateReleaseRepository(root); - const beforeSha = process.env.BEFORE_SHA; - const previousVersion = - beforeSha && !/^0+$/.test(beforeSha) && git(["cat-file", "-e", `${beforeSha}:package.json`], { allowFailure: true }) !== undefined - ? versionAt(beforeSha) - : undefined; - const tagTarget = resolveTagTarget(`v${repository.version}`); - const plan = createReleasePlan({ - eventName: "push", - previousVersion, - runAttempt: process.env.RUN_ATTEMPT, - runNumber: process.env.RUN_NUMBER, - sha: process.env.GITHUB_SHA_VALUE, - tagTarget, - version: repository.version, - }); - if (plan.publishProduction) { - validateReleaseRepository(root, { requireChangelogs: true, version: plan.productionVersion }); +export function assertLockstepVersions(packageVersions) { + const entries = Object.entries(packageVersions); + if (entries.length === 0) throw new Error("At least one package version is required"); + const expected = entries[0][1]; + const mismatches = entries.filter(([, version]) => version !== expected); + if (mismatches.length > 0) { + throw new Error( + `Release package versions must match ${expected}: ${mismatches + .map(([name, version]) => `${name}=${version}`) + .join(", ")}`, + ); } - return plan; + return expected; } -function planRetry() { - const defaultBranch = process.env.DEFAULT_BRANCH; - const command = parseReleaseComment({ - actorPermission: process.env.ACTOR_PERMISSION, - body: process.env.COMMENT_BODY, - defaultBranch, - expectedOperation: "retry-production", - workflowRef: process.env.WORKFLOW_REF, - }); - const releaseTag = command.releaseTag; - const tagTarget = resolveTagTarget(releaseTag); - const tagOnDefaultBranch = - tagTarget !== undefined && - git(["merge-base", "--is-ancestor", tagTarget, `origin/${defaultBranch}`], { allowFailure: true }) !== undefined; - const version = tagTarget ? versionAt(tagTarget) : releaseTag.slice(1); - return createReleasePlan({ - eventName: "issue_comment", - operation: command.operation, - releaseTag, - tagOnDefaultBranch, - tagTarget, - version, - }); +function requireTrustedWorkflowRun(options) { + const run = options.workflowRun; + if (!run) throw new Error("workflow_run metadata is required"); + if (run.conclusion !== "success") { + throw new Error(`CI conclusion must be success, received ${run.conclusion || "missing"}`); + } + if (run.event !== "push") throw new Error(`CI event must be push, received ${run.event || "missing"}`); + if (run.headBranch !== options.defaultBranch) { + throw new Error(`CI branch must be ${options.defaultBranch}, received ${run.headBranch || "missing"}`); + } + if (run.headRepository !== options.repository) { + throw new Error(`CI repository must be ${options.repository}, received ${run.headRepository || "missing"}`); + } + if (run.workflowPath !== ".github/workflows/ci.yml") { + throw new Error(`CI workflow path is not trusted: ${run.workflowPath || "missing"}`); + } + if (run.headSha !== options.buildSha) { + throw new Error(`CI verified ${run.headSha || "missing"}, not ${options.buildSha}`); + } +} + +export function resolveReleaseContext(options) { + assertCommitSha(options.buildSha, "Build SHA"); + const version = assertLockstepVersions(options.packageVersions); + if (options.eventName === "workflow_run") { + requireTrustedWorkflowRun(options); + assertPositiveInteger(options.runNumber, "Run number"); + assertPositiveInteger(options.runAttempt, "Run attempt"); + return { + ...createReleasePlan({ + eventName: "push", + previousVersion: options.previousVersion, + runAttempt: options.runAttempt, + runNumber: options.runNumber, + sha: options.buildSha, + tagTarget: options.tagTarget, + version, + }), + buildSha: options.buildSha, + trigger: "main", + }; + } + if (options.eventName === "issue_comment") { + const command = parseReleaseComment({ + actorPermission: options.actorPermission, + body: options.commentBody, + defaultBranch: options.defaultBranch, + expectedOperation: "retry-production", + workflowRef: options.workflowRef, + }); + return { + ...createReleasePlan({ + eventName: "issue_comment", + operation: command.operation, + releaseTag: command.releaseTag, + tagOnDefaultBranch: options.tagOnDefaultBranch, + tagTarget: options.buildSha, + version, + }), + buildSha: options.buildSha, + trigger: "retry", + }; + } + throw new Error(`Unsupported release event: ${options.eventName || "missing"}`); +} + +function jsonAt(ref, path) { + return JSON.parse(git(["show", `${ref}:${path}`])); } -function writeOutputs(plan) { - const outputPath = process.env.GITHUB_OUTPUT; +function packageVersionsAt(ref) { + return Object.fromEntries( + Object.entries(packageManifests).map(([name, path]) => [name, jsonAt(ref, path).version]), + ); +} + +function resolveCommit(ref, name) { + const sha = git(["rev-parse", "--verify", `${ref}^{commit}`]); + assertCommitSha(sha, name); + return sha; +} + +function resolveTagTarget(tag) { + const sha = git(["rev-parse", "--verify", `refs/tags/${tag}^{commit}`], { allowFailure: true }); + if (sha) assertCommitSha(sha, `Tag ${tag}`); + return sha; +} + +function isOnDefaultBranch(sha, defaultBranch) { + return ( + git(["merge-base", "--is-ancestor", sha, `origin/${defaultBranch}`], { allowFailure: true }) !== undefined + ); +} + +function env(name) { + return process.env[name] || ""; +} + +function writeOutputs(context) { + const outputPath = env("GITHUB_OUTPUT"); if (!outputPath) throw new Error("GITHUB_OUTPUT is required"); const outputs = { - beta_version: plan.betaVersion, - build_ref: plan.buildRef, - production_version: plan.productionVersion, - publish_beta: String(plan.publishBeta), - publish_production: String(plan.publishProduction), + beta_version: context.betaVersion, + build_ref: context.buildRef, + build_sha: context.buildSha, + production_version: context.productionVersion, + publish_beta: String(context.publishBeta), + publish_production: String(context.publishProduction), + trigger: context.trigger, }; - appendFileSync(outputPath, Object.entries(outputs).map(([name, value]) => `${name}=${value}`).join("\n") + "\n"); - console.log(`Build ref: ${plan.buildRef}`); - console.log(`Production: ${plan.publishProduction} ${plan.productionVersion ? `v${plan.productionVersion}` : ""}`); - console.log(`Beta: ${plan.publishBeta} ${plan.betaVersion ? `v${plan.betaVersion}` : ""}`); + appendFileSync(outputPath, `${Object.entries(outputs).map(([key, value]) => `${key}=${value}`).join("\n")}\n`); + console.log(`Build SHA: ${context.buildSha}`); + console.log(`Production: ${context.publishProduction} ${context.productionVersion ? `v${context.productionVersion}` : ""}`); + console.log(`Beta: ${context.publishBeta} ${context.betaVersion ? `v${context.betaVersion}` : ""}`); } -try { - const eventName = process.env.EVENT_NAME; - const plan = eventName === "push" ? planPush() : eventName === "issue_comment" ? planRetry() : undefined; - if (!plan) throw new Error(`Unsupported release event: ${eventName ?? ""}`); - writeOutputs(plan); -} catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - process.exit(1); +function main() { + const eventName = env("EVENT_NAME"); + const workflowSha = resolveCommit(env("WORKFLOW_SHA"), "Protected workflow SHA"); + const checkedOutSha = git(["rev-parse", "HEAD"]); + if (checkedOutSha !== workflowSha) { + throw new Error(`Protected tooling checkout is ${checkedOutSha}, expected ${workflowSha}`); + } + + let buildSha; + let previousVersion; + let tagTarget; + let tagOnDefaultBranch; + if (eventName === "workflow_run") { + buildSha = resolveCommit(env("UPSTREAM_HEAD_SHA"), "Upstream CI SHA"); + const parent = git(["rev-parse", "--verify", `${buildSha}^`], { allowFailure: true }); + previousVersion = parent ? jsonAt(parent, "package.json").version : undefined; + const version = assertLockstepVersions(packageVersionsAt(buildSha)); + tagTarget = resolveTagTarget(`v${version}`); + } else if (eventName === "issue_comment") { + const match = env("COMMENT_BODY").trim().match(/^\/prime-agent release retry (v0\.\d+\.\d+)$/); + if (!match) throw new Error("Retry command must be exactly: /prime-agent release retry v0.x.y"); + buildSha = resolveTagTarget(match[1]); + if (!buildSha) throw new Error(`Retry tag ${match[1]} does not exist`); + tagOnDefaultBranch = isOnDefaultBranch(buildSha, env("DEFAULT_BRANCH")); + } else { + throw new Error(`Unsupported release event: ${eventName || "missing"}`); + } + + const context = resolveReleaseContext({ + actorPermission: env("ACTOR_PERMISSION"), + buildSha, + commentBody: env("COMMENT_BODY"), + defaultBranch: env("DEFAULT_BRANCH"), + eventName, + packageVersions: packageVersionsAt(buildSha), + previousVersion, + repository: env("GITHUB_REPOSITORY"), + runAttempt: env("RUN_ATTEMPT"), + runNumber: env("RUN_NUMBER"), + tagOnDefaultBranch, + tagTarget, + workflowRef: env("WORKFLOW_REF"), + workflowRun: { + conclusion: env("UPSTREAM_CONCLUSION"), + event: env("UPSTREAM_EVENT"), + headBranch: env("UPSTREAM_HEAD_BRANCH"), + headRepository: env("UPSTREAM_HEAD_REPOSITORY"), + headSha: env("UPSTREAM_HEAD_SHA"), + workflowPath: env("UPSTREAM_WORKFLOW_PATH"), + }, + }); + writeOutputs(context); } +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/resolve-rollback-context.mjs b/scripts/resolve-rollback-context.mjs index 930cb9d943..4de095df3f 100755 --- a/scripts/resolve-rollback-context.mjs +++ b/scripts/resolve-rollback-context.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node import { appendFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; import { parseReleaseComment, validateRollbackRequest } from "./lib/release-lifecycle.mjs"; @@ -13,10 +14,22 @@ try { workflowRef: process.env.WORKFLOW_REF, }); validateRollbackRequest(command.releaseTag, command.confirmation); + const tagResult = spawnSync("git", ["rev-parse", "--verify", `refs/tags/${command.releaseTag}^{commit}`], { + encoding: "utf8", + stdio: "pipe", + }); + if (tagResult.status !== 0) throw new Error(`Rollback tag ${command.releaseTag} does not exist`); + const sourceSha = tagResult.stdout.trim(); + if (!/^[0-9a-f]{40}$/.test(sourceSha)) throw new Error(`Rollback tag has an invalid target: ${sourceSha}`); + const ancestry = spawnSync("git", ["merge-base", "--is-ancestor", sourceSha, `origin/${process.env.DEFAULT_BRANCH}`], { + encoding: "utf8", + stdio: "pipe", + }); + if (ancestry.status !== 0) throw new Error(`Rollback tag ${command.releaseTag} is not on the default branch`); if (!process.env.GITHUB_OUTPUT) throw new Error("GITHUB_OUTPUT is required"); appendFileSync( process.env.GITHUB_OUTPUT, - `release_tag=${command.releaseTag}\nconfirmation=${command.confirmation}\n`, + `release_tag=${command.releaseTag}\nconfirmation=${command.confirmation}\nsource_sha=${sourceSha}\n`, ); console.log(`Authorized rollback request for ${command.releaseTag}.`); } catch (error) { diff --git a/scripts/verify-release-artifacts.mjs b/scripts/verify-release-artifacts.mjs index 36e950d15c..97fc2644ea 100644 --- a/scripts/verify-release-artifacts.mjs +++ b/scripts/verify-release-artifacts.mjs @@ -1,19 +1,15 @@ #!/usr/bin/env node import { createHash } from "node:crypto"; -import { readFileSync } from "node:fs"; +import { readFileSync, readdirSync } from "node:fs"; import { basename, join, resolve } from "node:path"; +import { isDeepStrictEqual } from "node:util"; import { fileURLToPath } from "node:url"; +import { RELEASE_ARTIFACTS } from "./lib/release-lifecycle.mjs"; + const commitShaPattern = /^[0-9a-f]{40}$/; const releaseChannels = new Set(["stable", "beta"]); -const releasePackages = [ - { filePrefix: "prime-agent", name: "prime-agent" }, - { filePrefix: "prime-agent-ai", name: "prime-agent-ai" }, - { filePrefix: "prime-agent-core", name: "prime-agent-core" }, - { filePrefix: "prime-agent-tui", name: "prime-agent-tui" }, -]; - function sha256File(path) { const hash = createHash("sha256"); hash.update(readFileSync(path)); @@ -21,8 +17,8 @@ function sha256File(path) { } function expectedTarballs(version) { - return releasePackages - .map((entry) => ({ ...entry, file: `${entry.filePrefix}-${version}.tgz` })) + return RELEASE_ARTIFACTS + .map((entry) => ({ file: `${entry.filePrefix}-${version}.tgz`, name: entry.packageName })) .sort((left, right) => left.file.localeCompare(right.file)); } @@ -43,8 +39,9 @@ function readChecksums(path, expectedFiles) { return entries; } -function verifyManifest({ checksums, manifest, manifestName, sourceSha, tarballs, version }) { +function verifyManifest({ channel, checksums, manifest, manifestName, sourceSha, tarballs, version }) { if (manifest.version !== `v${version}`) throw new Error(`${manifestName} version does not match v${version}`); + if (manifest.channel !== channel) throw new Error(`${manifestName} channel does not match ${channel}`); if (manifest.sourceSha !== sourceSha) throw new Error(`${manifestName} source SHA does not match ${sourceSha}`); if (manifest.package !== "prime-agent") throw new Error(`${manifestName} package must be prime-agent`); if (manifest.tarball !== `releases/v${version}/prime-agent-${version}.tgz`) { @@ -74,6 +71,18 @@ export function verifyReleaseArtifacts({ channel, directory, remoteChecksums, re const tarballs = expectedTarballs(version); const expectedFiles = tarballs.map((entry) => entry.file); + const manifestName = channel === "stable" ? "latest.json" : "beta.json"; + const expectedDirectoryFiles = [ + ...expectedFiles, + "SHA256SUMS", + channel, + manifestName, + "release-provenance.json", + ].sort(); + const actualDirectoryFiles = readdirSync(directory).sort(); + if (!isDeepStrictEqual(actualDirectoryFiles, expectedDirectoryFiles)) { + throw new Error(`Release artifact file set mismatch: ${actualDirectoryFiles.join(", ")}`); + } const checksumsPath = join(directory, "SHA256SUMS"); const checksums = readChecksums(checksumsPath, expectedFiles); for (const tarball of tarballs) { @@ -92,15 +101,29 @@ export function verifyReleaseArtifacts({ channel, directory, remoteChecksums, re const pointer = readFileSync(join(directory, channel), "utf8"); if (pointer !== `v${version}\n`) throw new Error(`${channel} pointer does not match v${version}`); - const manifestName = channel === "stable" ? "latest.json" : "beta.json"; const manifestContent = readFileSync(join(directory, manifestName), "utf8"); const manifest = JSON.parse(manifestContent); - verifyManifest({ checksums, manifest, manifestName, sourceSha, tarballs, version }); + const provenanceContent = readFileSync(join(directory, "release-provenance.json"), "utf8"); + const provenance = JSON.parse(provenanceContent); + verifyManifest({ + channel, + checksums, + manifest: provenance, + manifestName: "release-provenance.json", + sourceSha, + tarballs, + version, + }); + const { channel: _channel, sourceSha: _sourceSha, ...compatibilityManifest } = provenance; + if (!isDeepStrictEqual(manifest, compatibilityManifest)) { + throw new Error(`${manifestName} does not match the compatibility release manifest`); + } if (remoteManifest) { try { const remoteManifestContent = readFileSync(remoteManifest, "utf8"); - if (remoteManifestContent !== manifestContent) throw new Error("content does not match local manifest"); + if (remoteManifestContent !== provenanceContent) throw new Error("content does not match local provenance"); verifyManifest({ + channel, checksums, manifest: JSON.parse(remoteManifestContent), manifestName: "Remote immutable manifest", diff --git a/scripts/verify-release-gate.mjs b/scripts/verify-release-gate.mjs index bc66332e89..a6c0944748 100644 --- a/scripts/verify-release-gate.mjs +++ b/scripts/verify-release-gate.mjs @@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url"; import { verifyCiResults } from "./verify-ci-results.mjs"; -const releaseTriggers = new Set(["main", "manual", "tag"]); +const releaseTriggers = new Set(["main", "retry"]); export function verifyReleaseGate({ build, fullCi, releaseContext, trigger }) { if (!releaseTriggers.has(trigger)) throw new Error(`Unsupported release trigger: ${trigger || "missing"}`); From d2b2e8ef3f20679e0511605aef2805dc900d0a9d Mon Sep 17 00:00:00 2001 From: fettpl <38704082+fettpl@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:03:26 +0200 Subject: [PATCH 13/13] fix(release): bind publication to gated runs fixes #926 fixes #927 fixes #934 fixes #949 --- .github/workflows/build-binaries.yml | 307 ++------- .github/workflows/publish-release.yml | 580 ++++++++++++++++++ .github/workflows/rollback-release.yml | 4 +- RELEASING.md | 8 +- package.json | 2 +- .../scripts/check-workflow-security.mjs | 142 ++++- scripts/lib/release-command.mjs | 54 ++ scripts/lib/release-lifecycle.mjs | 10 + scripts/lib/release-publication.mjs | 9 +- scripts/publish-release.mjs | 151 +++-- scripts/release-command.test.mjs | 100 +++ scripts/release-lifecycle.test.mjs | 11 + scripts/release-publication-context.mjs | 172 ++++++ scripts/release-publication.test.mjs | 35 ++ scripts/release-workflow-contract.test.mjs | 46 +- scripts/release-workflow.test.mjs | 77 ++- scripts/verify-release-artifacts.mjs | 8 + 17 files changed, 1362 insertions(+), 354 deletions(-) create mode 100644 .github/workflows/publish-release.yml create mode 100644 scripts/lib/release-command.mjs create mode 100644 scripts/release-command.test.mjs create mode 100644 scripts/release-publication-context.mjs diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index e6cb57420e..e798c7f570 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -1,5 +1,17 @@ name: Release Prime Agent +run-name: >- + ${{ (github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == github.event.repository.default_branch && + github.event.workflow_run.head_repository.full_name == github.repository && + format('Prime Agent release candidate {0}', github.event.workflow_run.head_sha)) || + (github.event_name == 'issue_comment' && + startsWith(github.event.comment.body, '/prime-agent release retry ') && + format('Prime Agent release candidate retry {0}', github.run_id)) || + format('Rejected Prime Agent release event {0}', github.run_id) }} + on: workflow_run: workflows: [CI] @@ -120,6 +132,12 @@ jobs: working-directory: release-source run: test "$(git rev-parse HEAD)" = "$BUILD_SHA" + - name: Capture exact release source baseline + working-directory: release-source + run: | + git diff --binary --full-index > "$RUNNER_TEMP/release-source-worktree.diff" + git diff --cached --binary --full-index > "$RUNNER_TEMP/release-source-index.diff" + - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -134,12 +152,6 @@ jobs: working-directory: release-source run: npm run build - - name: Capture post-build source baseline - working-directory: release-source - run: | - git diff --binary --full-index > "$RUNNER_TEMP/release-build-worktree.diff" - git diff --cached --binary --full-index > "$RUNNER_TEMP/release-build-index.diff" - - name: Check working-directory: release-source run: npm run check @@ -153,17 +165,17 @@ jobs: ../release-tooling/scripts/release-workflow-contract.test.mjs ../release-tooling/scripts/release-workflow.test.mjs - - name: Verify validation did not mutate release source + - name: Verify build and validation did not mutate release source working-directory: release-source run: | - git diff --binary --full-index > "$RUNNER_TEMP/release-check-worktree.diff" - git diff --cached --binary --full-index > "$RUNNER_TEMP/release-check-index.diff" - if ! cmp -s "$RUNNER_TEMP/release-build-worktree.diff" "$RUNNER_TEMP/release-check-worktree.diff"; then - diff -u "$RUNNER_TEMP/release-build-worktree.diff" "$RUNNER_TEMP/release-check-worktree.diff" || true + git diff --binary --full-index > "$RUNNER_TEMP/release-final-worktree.diff" + git diff --cached --binary --full-index > "$RUNNER_TEMP/release-final-index.diff" + if ! cmp -s "$RUNNER_TEMP/release-source-worktree.diff" "$RUNNER_TEMP/release-final-worktree.diff"; then + diff -u "$RUNNER_TEMP/release-source-worktree.diff" "$RUNNER_TEMP/release-final-worktree.diff" || true exit 1 fi - if ! cmp -s "$RUNNER_TEMP/release-build-index.diff" "$RUNNER_TEMP/release-check-index.diff"; then - diff -u "$RUNNER_TEMP/release-build-index.diff" "$RUNNER_TEMP/release-check-index.diff" || true + if ! cmp -s "$RUNNER_TEMP/release-source-index.diff" "$RUNNER_TEMP/release-final-index.diff"; then + diff -u "$RUNNER_TEMP/release-source-index.diff" "$RUNNER_TEMP/release-final-index.diff" || true exit 1 fi @@ -267,261 +279,34 @@ jobs: "build=$BUILD_RESULT" \ "trigger=$RELEASE_TRIGGER" - publish: - runs-on: ubuntu-latest + publication-context: needs: release-gate - environment: production - concurrency: - group: release-prime-agent - cancel-in-progress: false + runs-on: ubuntu-latest permissions: - contents: write - env: - BETA_VERSION: ${{ needs.release-gate.outputs.beta_version }} - BUILD_SHA: ${{ needs.release-gate.outputs.build_sha }} - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - PRIME_AGENT_RELEASE_SOURCE_ROOT: ${{ github.workspace }}/release-source - PRODUCTION_VERSION: ${{ needs.release-gate.outputs.production_version }} - PUBLISH_BETA: ${{ needs.release-gate.outputs.publish_beta }} - PUBLISH_PRODUCTION: ${{ needs.release-gate.outputs.publish_production }} - R2_PUBLIC_BASE_URL: ${{ vars.R2_PUBLIC_BASE_URL }} + contents: read steps: - name: Checkout protected release tooling uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.workflow_sha }} - path: release-tooling persist-credentials: false - - name: Checkout validated release source - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - fetch-tags: true - ref: ${{ env.BUILD_SHA }} - path: release-source - persist-credentials: false - - - name: Verify release source commit - working-directory: release-source - run: test "$(git rev-parse HEAD)" = "$BUILD_SHA" - - - name: Download production artifacts - if: env.PUBLISH_PRODUCTION == 'true' - uses: actions/download-artifact@3e5f45b3455f8bb19e09b3fb854d966fb27682a2 # v8.0.1 - with: - name: prime-agent-production-${{ env.BUILD_SHA }} - path: release-artifacts/production - - - name: Download beta artifacts - if: env.PUBLISH_BETA == 'true' - uses: actions/download-artifact@3e5f45b3455f8bb19e09b3fb854d966fb27682a2 # v8.0.1 - with: - name: prime-agent-beta-${{ env.BUILD_SHA }} - path: release-artifacts/beta - - - name: Verify production artifact provenance - if: env.PUBLISH_PRODUCTION == 'true' - run: >- - node release-tooling/scripts/verify-release-artifacts.mjs - --channel stable - --directory release-artifacts/production - --source-sha "$BUILD_SHA" - --version "$PRODUCTION_VERSION" - - - name: Verify beta artifact provenance - if: env.PUBLISH_BETA == 'true' - run: >- - node release-tooling/scripts/verify-release-artifacts.mjs - --channel beta - --directory release-artifacts/beta - --source-sha "$BUILD_SHA" - --version "$BETA_VERSION" - - - name: Prepare installers - working-directory: release-source - run: | - INSTALL_BASE_URL="${R2_PUBLIC_BASE_URL%/}" - test -n "$INSTALL_BASE_URL" - node ../release-tooling/scripts/render-release-installers.mjs \ - "$INSTALL_BASE_URL" \ - /tmp/prime-agent-install.sh \ - /tmp/prime-agent-install-beta.sh - - - name: Extract production release notes - if: env.PUBLISH_PRODUCTION == 'true' - working-directory: release-source - run: | - awk "/^## \[${PRODUCTION_VERSION}\]/{flag=1; next} /^## \[/{flag=0} flag" packages/coding-agent/CHANGELOG.md > /tmp/release-notes.md - if [ ! -s /tmp/release-notes.md ]; then - echo "Release v${PRODUCTION_VERSION}" > /tmp/release-notes.md - fi - - - name: Prepare production GitHub release - if: env.PUBLISH_PRODUCTION == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - working-directory: release-source - run: | - node ../release-tooling/scripts/publish-release.mjs production-github-prepare \ - --artifacts-dir ../release-artifacts/production \ - --version "$PRODUCTION_VERSION" \ - --build-ref "$BUILD_SHA" \ - --source-sha "$BUILD_SHA" \ - --notes-file /tmp/release-notes.md - - - name: Publish production immutable objects to R2 - if: env.PUBLISH_PRODUCTION == 'true' - env: - AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - AWS_DEFAULT_REGION: auto - AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - R2_BUCKET: ${{ secrets.R2_BUCKET }} - R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} - working-directory: release-source - run: | - node ../release-tooling/scripts/publish-release.mjs production-r2-immutable \ - --artifacts-dir ../release-artifacts/production \ - --version "$PRODUCTION_VERSION" \ - --build-ref "$BUILD_SHA" \ - --source-sha "$BUILD_SHA" - - - name: Publish production GitHub assets - if: env.PUBLISH_PRODUCTION == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - working-directory: release-source - run: | - node ../release-tooling/scripts/publish-release.mjs production-github-assets \ - --artifacts-dir ../release-artifacts/production \ - --version "$PRODUCTION_VERSION" \ - --build-ref "$BUILD_SHA" \ - --source-sha "$BUILD_SHA" \ - --notes-file /tmp/release-notes.md - - - name: Publish production installers to R2 - if: env.PUBLISH_PRODUCTION == 'true' - env: - AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - AWS_DEFAULT_REGION: auto - AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - R2_BUCKET: ${{ secrets.R2_BUCKET }} - R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} - working-directory: release-source - run: | - node ../release-tooling/scripts/publish-release.mjs production-r2-installers \ - --artifacts-dir ../release-artifacts/production \ - --version "$PRODUCTION_VERSION" \ - --build-ref "$BUILD_SHA" \ - --source-sha "$BUILD_SHA" \ - --stable-installer /tmp/prime-agent-install.sh \ - --beta-installer /tmp/prime-agent-install-beta.sh - - - name: Promote production channel in R2 - if: env.PUBLISH_PRODUCTION == 'true' - env: - AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - AWS_DEFAULT_REGION: auto - AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - R2_BUCKET: ${{ secrets.R2_BUCKET }} - R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} - working-directory: release-source - run: | - node ../release-tooling/scripts/publish-release.mjs production-r2-promote \ - --artifacts-dir ../release-artifacts/production \ - --version "$PRODUCTION_VERSION" \ - --build-ref "$BUILD_SHA" \ - --source-sha "$BUILD_SHA" - - - name: Publish beta immutable objects to R2 - if: env.PUBLISH_BETA == 'true' - env: - AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - AWS_DEFAULT_REGION: auto - AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - R2_BUCKET: ${{ secrets.R2_BUCKET }} - R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} - working-directory: release-source - run: | - node ../release-tooling/scripts/publish-release.mjs beta-r2-immutable \ - --artifacts-dir ../release-artifacts/beta \ - --version "$BETA_VERSION" \ - --build-ref "$BUILD_SHA" \ - --source-sha "$BUILD_SHA" - - - name: Check beta freshness before GitHub mirror - if: env.PUBLISH_BETA == 'true' - id: beta_github - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - latest_main_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${DEFAULT_BRANCH}" --jq .sha) - test "$latest_main_sha" = "$BUILD_SHA" - echo "current=true" >> "$GITHUB_OUTPUT" - - - name: Advance beta GitHub release - if: env.PUBLISH_BETA == 'true' && steps.beta_github.outputs.current == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - working-directory: release-source - run: | - node ../release-tooling/scripts/publish-release.mjs beta-github \ - --artifacts-dir ../release-artifacts/beta \ - --version "$BETA_VERSION" \ - --build-ref "$BUILD_SHA" \ - --source-sha "$BUILD_SHA" \ - --default-branch "$DEFAULT_BRANCH" - - - name: Check beta freshness before installers - if: env.PUBLISH_BETA == 'true' - id: beta_installers - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - latest_main_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${DEFAULT_BRANCH}" --jq .sha) - test "$latest_main_sha" = "$BUILD_SHA" - echo "current=true" >> "$GITHUB_OUTPUT" - - - name: Advance beta installers in R2 - if: env.PUBLISH_BETA == 'true' && steps.beta_installers.outputs.current == 'true' + - name: Create gated publication context env: - AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - AWS_DEFAULT_REGION: auto - AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - R2_BUCKET: ${{ secrets.R2_BUCKET }} - R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} - working-directory: release-source - run: | - node ../release-tooling/scripts/publish-release.mjs beta-r2-installers \ - --artifacts-dir ../release-artifacts/beta \ - --version "$BETA_VERSION" \ - --build-ref "$BUILD_SHA" \ - --source-sha "$BUILD_SHA" \ - --stable-installer /tmp/prime-agent-install.sh \ - --beta-installer /tmp/prime-agent-install-beta.sh - - - name: Check beta freshness before channel promotion - if: env.PUBLISH_BETA == 'true' - id: beta_promotion - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - latest_main_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${DEFAULT_BRANCH}" --jq .sha) - test "$latest_main_sha" = "$BUILD_SHA" - echo "current=true" >> "$GITHUB_OUTPUT" - - - name: Advance beta channel in R2 - if: env.PUBLISH_BETA == 'true' && steps.beta_promotion.outputs.current == 'true' - env: - AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - AWS_DEFAULT_REGION: auto - AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - R2_BUCKET: ${{ secrets.R2_BUCKET }} - R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} - working-directory: release-source - run: | - node ../release-tooling/scripts/publish-release.mjs beta-r2-promote \ - --artifacts-dir ../release-artifacts/beta \ - --version "$BETA_VERSION" \ - --build-ref "$BUILD_SHA" \ - --source-sha "$BUILD_SHA" + BETA_VERSION: ${{ needs.release-gate.outputs.beta_version }} + BUILD_SHA: ${{ needs.release-gate.outputs.build_sha }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + PRODUCTION_VERSION: ${{ needs.release-gate.outputs.production_version }} + PUBLISH_BETA: ${{ needs.release-gate.outputs.publish_beta }} + PUBLISH_PRODUCTION: ${{ needs.release-gate.outputs.publish_production }} + RELEASE_RUN_ID: ${{ github.run_id }} + TOOLING_SHA: ${{ github.workflow_sha }} + run: node scripts/release-publication-context.mjs create publication-context.json + + - name: Upload gated publication context + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: prime-agent-publication-context + path: publication-context.json + if-no-files-found: error + overwrite: false diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 0000000000..185279e623 --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,580 @@ +name: Publish Prime Agent release + +on: + workflow_run: + workflows: [Release Prime Agent] + types: [completed] + +permissions: {} + +concurrency: + group: >- + ${{ github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.path == '.github/workflows/build-binaries.yml' && + github.event.workflow_run.head_branch == github.event.repository.default_branch && + github.event.workflow_run.head_repository.full_name == github.repository && + startsWith(github.event.workflow_run.display_title, 'Prime Agent release candidate ') && + 'release-prime-agent' || format('rejected-release-{0}', github.run_id) }} + cancel-in-progress: false + +jobs: + authorize-publication: + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.path == '.github/workflows/build-binaries.yml' && + github.event.workflow_run.head_branch == github.event.repository.default_branch && + github.event.workflow_run.head_repository.full_name == github.repository && + startsWith(github.event.workflow_run.display_title, 'Prime Agent release candidate ') && + (github.event.workflow_run.event == 'workflow_run' || + github.event.workflow_run.event == 'issue_comment') + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + outputs: + beta_version: ${{ steps.context.outputs.beta_version }} + build_sha: ${{ steps.context.outputs.build_sha }} + default_branch: ${{ steps.context.outputs.default_branch }} + production_version: ${{ steps.context.outputs.production_version }} + publish_beta: ${{ steps.context.outputs.publish_beta }} + publish_production: ${{ steps.context.outputs.publish_production }} + source_run_id: ${{ steps.context.outputs.source_run_id }} + tooling_sha: ${{ steps.context.outputs.tooling_sha }} + steps: + - name: Checkout protected release tooling + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.workflow_run.head_sha }} + path: release-tooling + persist-credentials: false + + - name: Download gated publication context + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SOURCE_RUN_ID: ${{ github.event.workflow_run.id }} + run: | + set -euo pipefail + artifact_ids=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${SOURCE_RUN_ID}/artifacts" \ + --paginate \ + --jq '.artifacts[] | select(.name == "prime-agent-publication-context" and .expired == false) | .id') + test "$(printf '%s\n' "$artifact_ids" | sed '/^$/d' | wc -l | tr -d ' ')" = "1" + gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${artifact_ids}/zip" > /tmp/publication-context.zip + unzip -q /tmp/publication-context.zip -d /tmp/publication-context + test "$(find /tmp/publication-context -type f | wc -l | tr -d ' ')" = "1" + test -f /tmp/publication-context/publication-context.json + + - name: Authorize completed release gate + id: context + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + UPSTREAM_CONCLUSION: ${{ github.event.workflow_run.conclusion }} + UPSTREAM_DISPLAY_TITLE: ${{ github.event.workflow_run.display_title }} + UPSTREAM_EVENT: ${{ github.event.workflow_run.event }} + UPSTREAM_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + UPSTREAM_HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }} + UPSTREAM_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + UPSTREAM_RUN_ID: ${{ github.event.workflow_run.id }} + UPSTREAM_WORKFLOW_PATH: ${{ github.event.workflow_run.path }} + run: >- + node release-tooling/scripts/release-publication-context.mjs validate + /tmp/publication-context/publication-context.json + + production-github-prepare: + if: needs.authorize-publication.outputs.publish_production == 'true' + needs: authorize-publication + runs-on: ubuntu-latest + environment: production + permissions: + actions: read + contents: write + env: + BUILD_SHA: ${{ needs.authorize-publication.outputs.build_sha }} + PRIME_AGENT_RELEASE_SOURCE_ROOT: ${{ github.workspace }}/release-source + PRODUCTION_VERSION: ${{ needs.authorize-publication.outputs.production_version }} + SOURCE_RUN_ID: ${{ needs.authorize-publication.outputs.source_run_id }} + TOOLING_SHA: ${{ needs.authorize-publication.outputs.tooling_sha }} + steps: + - name: Prepare production GitHub release + env: + ARTIFACT_DIRECTORY: release-artifacts/production + ARTIFACT_NAME: prime-agent-production-${{ env.BUILD_SHA }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + checkout_exact() { + directory="$1" + sha="$2" + git init --quiet "$directory" + git -C "$directory" remote add origin "https://github.com/${GITHUB_REPOSITORY}.git" + git -C "$directory" fetch --quiet --depth=1 origin "$sha" + git -C "$directory" checkout --quiet --detach FETCH_HEAD + test "$(git -C "$directory" rev-parse HEAD)" = "$sha" + } + checkout_exact release-tooling "$TOOLING_SHA" + checkout_exact release-source "$BUILD_SHA" + mkdir -p "$ARTIFACT_DIRECTORY" + artifact_ids=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${SOURCE_RUN_ID}/artifacts" \ + --paginate \ + --jq '.artifacts[] | select(.name == env.ARTIFACT_NAME and .expired == false) | .id') + test "$(printf '%s\n' "$artifact_ids" | sed '/^$/d' | wc -l | tr -d ' ')" = "1" + gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${artifact_ids}/zip" > /tmp/release-artifact.zip + unzip -q /tmp/release-artifact.zip -d "$ARTIFACT_DIRECTORY" + node release-tooling/scripts/verify-release-artifacts.mjs \ + --channel stable \ + --directory "$ARTIFACT_DIRECTORY" \ + --source-sha "$BUILD_SHA" \ + --version "$PRODUCTION_VERSION" + awk "/^## \[${PRODUCTION_VERSION}\]/{flag=1; next} /^## \[/{flag=0} flag" \ + release-source/packages/coding-agent/CHANGELOG.md > /tmp/release-notes.md + if [ ! -s /tmp/release-notes.md ]; then + echo "Release v${PRODUCTION_VERSION}" > /tmp/release-notes.md + fi + ( + cd release-source + node ../release-tooling/scripts/publish-release.mjs production-github-prepare \ + --artifacts-dir "../$ARTIFACT_DIRECTORY" \ + --version "$PRODUCTION_VERSION" \ + --build-ref "$BUILD_SHA" \ + --source-sha "$BUILD_SHA" \ + --notes-file /tmp/release-notes.md + ) + + production-r2-immutable: + if: needs.authorize-publication.outputs.publish_production == 'true' + needs: [authorize-publication, production-github-prepare] + runs-on: ubuntu-latest + environment: production + permissions: + actions: read + contents: read + env: + BUILD_SHA: ${{ needs.authorize-publication.outputs.build_sha }} + PRIME_AGENT_RELEASE_SOURCE_ROOT: ${{ github.workspace }}/release-source + PRODUCTION_VERSION: ${{ needs.authorize-publication.outputs.production_version }} + SOURCE_RUN_ID: ${{ needs.authorize-publication.outputs.source_run_id }} + TOOLING_SHA: ${{ needs.authorize-publication.outputs.tooling_sha }} + steps: + - name: Checkout protected release tooling + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.TOOLING_SHA }} + path: release-tooling + persist-credentials: false + + - name: Checkout validated release source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.BUILD_SHA }} + path: release-source + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + + - name: Download production artifacts + uses: actions/download-artifact@3e5f45b3455f8bb19e09b3fb854d966fb27682a2 # v8.0.1 + with: + name: prime-agent-production-${{ env.BUILD_SHA }} + path: release-artifacts/production + run-id: ${{ env.SOURCE_RUN_ID }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Verify production artifact provenance + run: >- + node release-tooling/scripts/verify-release-artifacts.mjs + --channel stable + --directory release-artifacts/production + --source-sha "$BUILD_SHA" + --version "$PRODUCTION_VERSION" + + - name: Publish production immutable objects to R2 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_DEFAULT_REGION: auto + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + R2_BUCKET: ${{ secrets.R2_BUCKET }} + R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} + working-directory: release-source + run: | + node ../release-tooling/scripts/publish-release.mjs production-r2-immutable \ + --artifacts-dir ../release-artifacts/production \ + --version "$PRODUCTION_VERSION" \ + --build-ref "$BUILD_SHA" \ + --source-sha "$BUILD_SHA" + + production-github-assets: + if: needs.authorize-publication.outputs.publish_production == 'true' + needs: [authorize-publication, production-r2-immutable] + runs-on: ubuntu-latest + environment: production + permissions: + actions: read + contents: write + env: + BUILD_SHA: ${{ needs.authorize-publication.outputs.build_sha }} + PRIME_AGENT_RELEASE_SOURCE_ROOT: ${{ github.workspace }}/release-source + PRODUCTION_VERSION: ${{ needs.authorize-publication.outputs.production_version }} + SOURCE_RUN_ID: ${{ needs.authorize-publication.outputs.source_run_id }} + TOOLING_SHA: ${{ needs.authorize-publication.outputs.tooling_sha }} + steps: + - name: Publish production GitHub assets + env: + ARTIFACT_DIRECTORY: release-artifacts/production + ARTIFACT_NAME: prime-agent-production-${{ env.BUILD_SHA }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + checkout_exact() { + directory="$1" + sha="$2" + git init --quiet "$directory" + git -C "$directory" remote add origin "https://github.com/${GITHUB_REPOSITORY}.git" + git -C "$directory" fetch --quiet --depth=1 origin "$sha" + git -C "$directory" checkout --quiet --detach FETCH_HEAD + test "$(git -C "$directory" rev-parse HEAD)" = "$sha" + } + checkout_exact release-tooling "$TOOLING_SHA" + checkout_exact release-source "$BUILD_SHA" + mkdir -p "$ARTIFACT_DIRECTORY" + artifact_ids=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${SOURCE_RUN_ID}/artifacts" \ + --paginate \ + --jq '.artifacts[] | select(.name == env.ARTIFACT_NAME and .expired == false) | .id') + test "$(printf '%s\n' "$artifact_ids" | sed '/^$/d' | wc -l | tr -d ' ')" = "1" + gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${artifact_ids}/zip" > /tmp/release-artifact.zip + unzip -q /tmp/release-artifact.zip -d "$ARTIFACT_DIRECTORY" + node release-tooling/scripts/verify-release-artifacts.mjs \ + --channel stable \ + --directory "$ARTIFACT_DIRECTORY" \ + --source-sha "$BUILD_SHA" \ + --version "$PRODUCTION_VERSION" + awk "/^## \[${PRODUCTION_VERSION}\]/{flag=1; next} /^## \[/{flag=0} flag" \ + release-source/packages/coding-agent/CHANGELOG.md > /tmp/release-notes.md + if [ ! -s /tmp/release-notes.md ]; then + echo "Release v${PRODUCTION_VERSION}" > /tmp/release-notes.md + fi + ( + cd release-source + node ../release-tooling/scripts/publish-release.mjs production-github-assets \ + --artifacts-dir "../$ARTIFACT_DIRECTORY" \ + --version "$PRODUCTION_VERSION" \ + --build-ref "$BUILD_SHA" \ + --source-sha "$BUILD_SHA" \ + --notes-file /tmp/release-notes.md + ) + + production-r2-finalize: + if: needs.authorize-publication.outputs.publish_production == 'true' + needs: [authorize-publication, production-github-assets] + runs-on: ubuntu-latest + environment: production + permissions: + actions: read + contents: read + env: + BUILD_SHA: ${{ needs.authorize-publication.outputs.build_sha }} + PRIME_AGENT_RELEASE_SOURCE_ROOT: ${{ github.workspace }}/release-source + PRODUCTION_VERSION: ${{ needs.authorize-publication.outputs.production_version }} + R2_PUBLIC_BASE_URL: ${{ vars.R2_PUBLIC_BASE_URL }} + SOURCE_RUN_ID: ${{ needs.authorize-publication.outputs.source_run_id }} + TOOLING_SHA: ${{ needs.authorize-publication.outputs.tooling_sha }} + steps: + - name: Checkout protected release tooling + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.TOOLING_SHA }} + path: release-tooling + persist-credentials: false + + - name: Checkout validated release source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.BUILD_SHA }} + path: release-source + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + + - name: Download production artifacts + uses: actions/download-artifact@3e5f45b3455f8bb19e09b3fb854d966fb27682a2 # v8.0.1 + with: + name: prime-agent-production-${{ env.BUILD_SHA }} + path: release-artifacts/production + run-id: ${{ env.SOURCE_RUN_ID }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Verify production artifact provenance + run: >- + node release-tooling/scripts/verify-release-artifacts.mjs + --channel stable + --directory release-artifacts/production + --source-sha "$BUILD_SHA" + --version "$PRODUCTION_VERSION" + + - name: Prepare installers + working-directory: release-source + run: | + INSTALL_BASE_URL="${R2_PUBLIC_BASE_URL%/}" + test -n "$INSTALL_BASE_URL" + node ../release-tooling/scripts/render-release-installers.mjs \ + "$INSTALL_BASE_URL" \ + /tmp/prime-agent-install.sh \ + /tmp/prime-agent-install-beta.sh + + - name: Publish production installers to R2 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_DEFAULT_REGION: auto + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + R2_BUCKET: ${{ secrets.R2_BUCKET }} + R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} + working-directory: release-source + run: | + node ../release-tooling/scripts/publish-release.mjs production-r2-installers \ + --artifacts-dir ../release-artifacts/production \ + --version "$PRODUCTION_VERSION" \ + --build-ref "$BUILD_SHA" \ + --source-sha "$BUILD_SHA" \ + --stable-installer /tmp/prime-agent-install.sh \ + --beta-installer /tmp/prime-agent-install-beta.sh + + - name: Promote production channel in R2 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_DEFAULT_REGION: auto + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + R2_BUCKET: ${{ secrets.R2_BUCKET }} + R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} + working-directory: release-source + run: | + node ../release-tooling/scripts/publish-release.mjs production-r2-promote \ + --artifacts-dir ../release-artifacts/production \ + --version "$PRODUCTION_VERSION" \ + --build-ref "$BUILD_SHA" \ + --source-sha "$BUILD_SHA" + + beta-r2-immutable: + if: >- + always() && needs.authorize-publication.outputs.publish_beta == 'true' && + needs.authorize-publication.result == 'success' && + (needs.authorize-publication.outputs.publish_production != 'true' || + needs.production-r2-finalize.result == 'success') + needs: [authorize-publication, production-r2-finalize] + runs-on: ubuntu-latest + environment: production + permissions: + actions: read + contents: read + env: + BETA_VERSION: ${{ needs.authorize-publication.outputs.beta_version }} + BUILD_SHA: ${{ needs.authorize-publication.outputs.build_sha }} + DEFAULT_BRANCH: ${{ needs.authorize-publication.outputs.default_branch }} + PRIME_AGENT_RELEASE_SOURCE_ROOT: ${{ github.workspace }}/release-source + SOURCE_RUN_ID: ${{ needs.authorize-publication.outputs.source_run_id }} + TOOLING_SHA: ${{ needs.authorize-publication.outputs.tooling_sha }} + steps: + - name: Checkout protected release tooling + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.TOOLING_SHA }} + path: release-tooling + persist-credentials: false + + - name: Checkout validated release source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.BUILD_SHA }} + path: release-source + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + + - name: Download beta artifacts + uses: actions/download-artifact@3e5f45b3455f8bb19e09b3fb854d966fb27682a2 # v8.0.1 + with: + name: prime-agent-beta-${{ env.BUILD_SHA }} + path: release-artifacts/beta + run-id: ${{ env.SOURCE_RUN_ID }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Verify beta artifact provenance + run: >- + node release-tooling/scripts/verify-release-artifacts.mjs + --channel beta + --directory release-artifacts/beta + --source-sha "$BUILD_SHA" + --version "$BETA_VERSION" + + - name: Publish beta immutable objects to R2 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_DEFAULT_REGION: auto + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + R2_BUCKET: ${{ secrets.R2_BUCKET }} + R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} + working-directory: release-source + run: | + node ../release-tooling/scripts/publish-release.mjs beta-r2-immutable \ + --artifacts-dir ../release-artifacts/beta \ + --version "$BETA_VERSION" \ + --build-ref "$BUILD_SHA" \ + --source-sha "$BUILD_SHA" + + beta-github: + if: needs.authorize-publication.outputs.publish_beta == 'true' + needs: [authorize-publication, beta-r2-immutable] + runs-on: ubuntu-latest + environment: production + permissions: + actions: read + contents: write + env: + BETA_VERSION: ${{ needs.authorize-publication.outputs.beta_version }} + BUILD_SHA: ${{ needs.authorize-publication.outputs.build_sha }} + DEFAULT_BRANCH: ${{ needs.authorize-publication.outputs.default_branch }} + PRIME_AGENT_RELEASE_SOURCE_ROOT: ${{ github.workspace }}/release-source + SOURCE_RUN_ID: ${{ needs.authorize-publication.outputs.source_run_id }} + TOOLING_SHA: ${{ needs.authorize-publication.outputs.tooling_sha }} + steps: + - name: Advance beta GitHub release + env: + ARTIFACT_DIRECTORY: release-artifacts/beta + ARTIFACT_NAME: prime-agent-beta-${{ env.BUILD_SHA }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + checkout_exact() { + directory="$1" + sha="$2" + git init --quiet "$directory" + git -C "$directory" remote add origin "https://github.com/${GITHUB_REPOSITORY}.git" + git -C "$directory" fetch --quiet --depth=1 origin "$sha" + git -C "$directory" checkout --quiet --detach FETCH_HEAD + test "$(git -C "$directory" rev-parse HEAD)" = "$sha" + } + checkout_exact release-tooling "$TOOLING_SHA" + checkout_exact release-source "$BUILD_SHA" + mkdir -p "$ARTIFACT_DIRECTORY" + artifact_ids=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${SOURCE_RUN_ID}/artifacts" \ + --paginate \ + --jq '.artifacts[] | select(.name == env.ARTIFACT_NAME and .expired == false) | .id') + test "$(printf '%s\n' "$artifact_ids" | sed '/^$/d' | wc -l | tr -d ' ')" = "1" + gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${artifact_ids}/zip" > /tmp/release-artifact.zip + unzip -q /tmp/release-artifact.zip -d "$ARTIFACT_DIRECTORY" + node release-tooling/scripts/verify-release-artifacts.mjs \ + --channel beta \ + --directory "$ARTIFACT_DIRECTORY" \ + --source-sha "$BUILD_SHA" \ + --version "$BETA_VERSION" + ( + cd release-source + node ../release-tooling/scripts/publish-release.mjs beta-github \ + --artifacts-dir "../$ARTIFACT_DIRECTORY" \ + --version "$BETA_VERSION" \ + --build-ref "$BUILD_SHA" \ + --source-sha "$BUILD_SHA" \ + --default-branch "$DEFAULT_BRANCH" + ) + + beta-r2-finalize: + if: needs.authorize-publication.outputs.publish_beta == 'true' + needs: [authorize-publication, beta-github] + runs-on: ubuntu-latest + environment: production + permissions: + actions: read + contents: read + env: + BETA_VERSION: ${{ needs.authorize-publication.outputs.beta_version }} + BUILD_SHA: ${{ needs.authorize-publication.outputs.build_sha }} + DEFAULT_BRANCH: ${{ needs.authorize-publication.outputs.default_branch }} + PRIME_AGENT_RELEASE_SOURCE_ROOT: ${{ github.workspace }}/release-source + R2_PUBLIC_BASE_URL: ${{ vars.R2_PUBLIC_BASE_URL }} + SOURCE_RUN_ID: ${{ needs.authorize-publication.outputs.source_run_id }} + TOOLING_SHA: ${{ needs.authorize-publication.outputs.tooling_sha }} + steps: + - name: Checkout protected release tooling + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.TOOLING_SHA }} + path: release-tooling + persist-credentials: false + + - name: Checkout validated release source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.BUILD_SHA }} + path: release-source + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + + - name: Download beta artifacts + uses: actions/download-artifact@3e5f45b3455f8bb19e09b3fb854d966fb27682a2 # v8.0.1 + with: + name: prime-agent-beta-${{ env.BUILD_SHA }} + path: release-artifacts/beta + run-id: ${{ env.SOURCE_RUN_ID }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Verify beta artifact provenance + run: >- + node release-tooling/scripts/verify-release-artifacts.mjs + --channel beta + --directory release-artifacts/beta + --source-sha "$BUILD_SHA" + --version "$BETA_VERSION" + + - name: Prepare installers + working-directory: release-source + run: | + INSTALL_BASE_URL="${R2_PUBLIC_BASE_URL%/}" + test -n "$INSTALL_BASE_URL" + node ../release-tooling/scripts/render-release-installers.mjs \ + "$INSTALL_BASE_URL" \ + /tmp/prime-agent-install.sh \ + /tmp/prime-agent-install-beta.sh + + - name: Advance beta installers in R2 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_DEFAULT_REGION: auto + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + R2_BUCKET: ${{ secrets.R2_BUCKET }} + R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} + working-directory: release-source + run: | + node ../release-tooling/scripts/publish-release.mjs beta-r2-installers \ + --artifacts-dir ../release-artifacts/beta \ + --version "$BETA_VERSION" \ + --build-ref "$BUILD_SHA" \ + --source-sha "$BUILD_SHA" \ + --default-branch "$DEFAULT_BRANCH" \ + --stable-installer /tmp/prime-agent-install.sh \ + --beta-installer /tmp/prime-agent-install-beta.sh + + - name: Advance beta channel in R2 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_DEFAULT_REGION: auto + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + R2_BUCKET: ${{ secrets.R2_BUCKET }} + R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} + working-directory: release-source + run: | + node ../release-tooling/scripts/publish-release.mjs beta-r2-promote \ + --artifacts-dir ../release-artifacts/beta \ + --version "$BETA_VERSION" \ + --build-ref "$BUILD_SHA" \ + --source-sha "$BUILD_SHA" \ + --default-branch "$DEFAULT_BRANCH" diff --git a/.github/workflows/rollback-release.yml b/.github/workflows/rollback-release.yml index 7529cdd268..a993623584 100644 --- a/.github/workflows/rollback-release.yml +++ b/.github/workflows/rollback-release.yml @@ -76,7 +76,6 @@ jobs: node-version: '22' - name: Verify rollback GitHub release - id: rollback_release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} R2_PUBLIC_BASE_URL: ${{ vars.R2_PUBLIC_BASE_URL }} @@ -84,6 +83,7 @@ jobs: node scripts/publish-release.mjs rollback-github-verify \ --release-tag "${{ needs.authorize.outputs.release_tag }}" \ --confirmation "${{ needs.authorize.outputs.confirmation }}" \ + --source-sha "${{ needs.authorize.outputs.source_sha }}" \ --default-branch "${{ github.event.repository.default_branch }}" \ --artifacts-dir rollback-artifacts @@ -99,5 +99,5 @@ jobs: node scripts/publish-release.mjs rollback-r2-promote \ --release-tag "${{ needs.authorize.outputs.release_tag }}" \ --confirmation "${{ needs.authorize.outputs.confirmation }}" \ - --source-sha "${{ steps.rollback_release.outputs.source_sha }}" \ + --source-sha "${{ needs.authorize.outputs.source_sha }}" \ --artifacts-dir rollback-artifacts diff --git a/RELEASING.md b/RELEASING.md index d216a7a25e..3d555d222c 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -53,7 +53,9 @@ Open and review the release-preparation pull request. Merging the exact version- ## CI Publication -The Release Prime Agent workflow starts only after the canonical `CI` workflow completes successfully for a same-repository push to the protected default branch. It binds every checkout, workflow artifact, manifest, checksum, and publication phase to that CI run's complete source SHA. Each successful default-branch commit builds a beta; production is additionally published only when the root version changed to a strictly newer validated version. +The Release Prime Agent workflow starts only after the canonical `CI` workflow completes successfully for a same-repository push to the protected default branch. It binds every checkout, workflow artifact, manifest, and checksum to that CI run's complete source SHA. After its aggregate gate succeeds, it uploads one run-scoped publication context containing the exact source SHA, protected tooling SHA, versions, channel flags, and release run ID. + +The separate Publish Prime Agent release workflow is loaded only from the protected default branch and starts only after a Release Prime Agent run completes. Only successful runs whose event metadata already matches the exact workflow path, repository, and default branch enter the shared publication lock; rejected runs use isolated groups. Before any protected mutation job, the workflow verifies the conclusion, event, run ID, tooling SHA, and complete context schema, then downloads artifacts only from that validated run. Each successful default-branch commit builds a beta; production is additionally published only when the root version changed to a strictly newer validated version. The upstream CI result already covers the complete Node and locked Python suites. A protected production retry invokes the same reusable CI workflow against the immutable tag SHA before rebuilding or publishing. Failed, cancelled, skipped, fork-originated, wrong-branch, wrong-workflow, and wrong-SHA results cannot reach publication. @@ -69,7 +71,7 @@ The production transaction is ordered as follows: 8. Write and verify `/stable`. 9. Write and verify `/latest.json` last. -The workflow serializes publication and refuses to move either stable surface backward: the effective monotonic floor is the higher version named by `/stable` or `/latest.json`. Only the protected rollback workflow may lower both surfaces. GitHub and R2 mutation steps never receive each other's credentials. Beta freshness is rechecked against the default-branch head before each mutable GitHub, installer, and pointer phase. +The workflow serializes publication and refuses to move either stable surface backward: the effective monotonic floor is the higher version named by `/stable` or `/latest.json`. Only the protected rollback workflow may lower both surfaces. GitHub mutations run in dedicated write-token jobs, while R2 jobs have only a read token and receive R2 credentials at the individual mutation step. Beta freshness is rechecked inside each mutable GitHub, installer, and pointer phase immediately before its first write. ## Retry @@ -103,7 +105,7 @@ Prefer a forward fix. If stable must be restored immediately, a maintainer with ROLLBACK vX.Y.Z ``` -The default-branch workflow and an API-derived maintainer permission are the repository-enforced authorization boundary. Authorization, exact command parsing, tag resolution, and default-branch ancestry checks complete in an ungated preflight job before the workflow can acquire the shared release lock or enter the `production` environment, so an unauthorized comment cannot block publication. The mutation job targets the `production` environment, so configured environment reviewers provide an additional gate but are not assumed to exist. A GitHub-read-only phase downloads and validates the immutable Release assets; a separate R2-only phase verifies the matching remote objects before writing `/stable` and then `/latest.json`. It does not create or move tags, rewrite immutable objects, or change GitHub Releases. +The default-branch workflow and an API-derived maintainer permission are the repository-enforced authorization boundary. Authorization, exact command parsing, tag resolution, and default-branch ancestry checks complete in an ungated preflight job before the workflow can acquire the shared release lock or enter the `production` environment, so an unauthorized comment cannot block publication. The authorized tag commit is carried across the protected wait and must still match before any asset is accepted. The mutation job targets the `production` environment, so configured environment reviewers provide an additional gate but are not assumed to exist. A GitHub-read-only phase downloads and validates the immutable Release assets; a separate R2-only phase verifies the matching remote objects before writing `/stable` and then `/latest.json`. It does not create or move tags, rewrite immutable objects, or change GitHub Releases. Rollback changes what fresh installations and future update checks select. It does not force already-installed newer clients to downgrade. diff --git a/package.json b/package.json index b2f3465baf..05ebb0d214 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "release:pack": "node scripts/pack-prime-agent-release.mjs", "release:prepare": "node scripts/prepare-release.mjs", "release:dry-run": "node scripts/release-dry-run.mjs", - "release:test": "node --test scripts/release-lifecycle.test.mjs scripts/release-publication.test.mjs scripts/release-workflow-contract.test.mjs scripts/release-workflow.test.mjs", + "release:test": "node --test scripts/release-command.test.mjs scripts/release-lifecycle.test.mjs scripts/release-publication.test.mjs scripts/release-workflow-contract.test.mjs scripts/release-workflow.test.mjs", "release:patch": "node scripts/release.mjs", "release:minor": "node scripts/release.mjs", "release:major": "node scripts/release.mjs", diff --git a/packages/coding-agent/scripts/check-workflow-security.mjs b/packages/coding-agent/scripts/check-workflow-security.mjs index 7cec51ee5d..5f9b52fda3 100644 --- a/packages/coding-agent/scripts/check-workflow-security.mjs +++ b/packages/coding-agent/scripts/check-workflow-security.mjs @@ -8,10 +8,20 @@ const expectedPermissionsByWorkflow = { "build-binaries.yml": { build: { contents: "read" }, "full-ci": { contents: "read" }, - publish: { contents: "write" }, + "publication-context": { contents: "read" }, "release-gate": { contents: "read" }, "release-context": { contents: "read" }, }, + "publish-release.yml": { + "authorize-publication": { actions: "read", contents: "read" }, + "beta-github": { actions: "read", contents: "write" }, + "beta-r2-finalize": { actions: "read", contents: "read" }, + "beta-r2-immutable": { actions: "read", contents: "read" }, + "production-github-assets": { actions: "read", contents: "write" }, + "production-github-prepare": { actions: "read", contents: "write" }, + "production-r2-finalize": { actions: "read", contents: "read" }, + "production-r2-immutable": { actions: "read", contents: "read" }, + }, "ci.yml": { "build-check": { contents: "read" }, "build-check-test": { contents: "read" }, @@ -131,7 +141,7 @@ test("workflows default to no token permissions and grant only allowlisted job p test("R2 credentials are scoped to R2-only mutation steps", async () => { const allowedByWorkflow = { - "build-binaries.yml": new Set([ + "publish-release.yml": new Set([ "Publish production immutable objects to R2", "Publish production installers to R2", "Promote production channel in R2", @@ -155,6 +165,17 @@ test("R2 credentials are scoped to R2-only mutation steps", async () => { assert.match(step, /publish-release\.mjs [a-z0-9-]*r2[a-z0-9-]*/, `${name} must invoke an R2 phase`); assert.doesNotMatch(step, /secrets\.GITHUB_TOKEN|\bGH_TOKEN\b|\bgh (?:api|release)\b/); } + const document = parse(source); + for (const job of getJobBlocks(source)) { + if (!job.source.includes("secrets.R2_")) continue; + const expectedPermissions = + workflowName === "publish-release.yml" ? { actions: "read", contents: "read" } : { contents: "read" }; + assert.deepEqual( + document.jobs[job.name].permissions, + expectedPermissions, + `${workflowName}/${job.name} must not receive a write-capable GitHub token with R2 credentials`, + ); + } for (const name of allowedSecretSteps) { const step = allSteps.get(name); assert.ok(step, `${workflowName} must define ${name}`); @@ -165,30 +186,107 @@ test("R2 credentials are scoped to R2-only mutation steps", async () => { } }); -test("every mutable beta phase has a fresh main guard without R2 credentials", async () => { - const releaseWorkflow = await readFile(new URL("build-binaries.yml", workflowsDirectory), "utf8"); - const publishJob = getJobBlocks(releaseWorkflow).find((job) => job.name === "publish"); - assert.ok(publishJob, "release workflow must define the publish job"); - const steps = getNamedSteps(publishJob.source); - const phases = [ - ["Check beta freshness before GitHub mirror", "beta_github", "Advance beta GitHub release"], - ["Check beta freshness before installers", "beta_installers", "Advance beta installers in R2"], - ["Check beta freshness before channel promotion", "beta_promotion", "Advance beta channel in R2"], - ]; - for (const [guardName, outputId, mutationName] of phases) { - const guard = steps.get(guardName); - assert.ok(guard, `beta publication must define ${guardName}`); - assert.match(guard, new RegExp(`id: ${outputId}`)); - assert.match(guard, /commits\/\$\{DEFAULT_BRANCH\}/); - assert.match(guard, /test "\$latest_main_sha" = "\$BUILD_SHA"/); - assert.doesNotMatch(guard, /secrets\.R2_/); - assert.match( - steps.get(mutationName) ?? "", - new RegExp(`steps\\.${outputId}\\.outputs\\.current == 'true'`), +test("write-token publication jobs contain only controlled GitHub mutation steps", async () => { + const source = await readFile(new URL("publish-release.yml", workflowsDirectory), "utf8"); + const document = parse(source); + for (const job of getJobBlocks(source)) { + if (document.jobs[job.name].permissions?.contents !== "write") continue; + assert.doesNotMatch(job.source, /^\s*uses:/m, `${job.name} must not run third-party actions with a write token`); + assert.doesNotMatch(job.source, /secrets\.R2_/, `${job.name} must not receive R2 credentials`); + const steps = getNamedSteps(job.source); + assert.equal(steps.size, 1, `${job.name} must contain exactly one controlled GitHub mutation step`); + const step = [...steps.values()][0]; + assert.match(step, /GH_TOKEN: \$\{\{ secrets\.GITHUB_TOKEN \}\}/); + assert.match(step, /publish-release\.mjs (?:production-github|beta-github)/); + } +}); + +test("publication accepts only a successful gated default-branch release run", async () => { + const caller = await readFile(new URL("build-binaries.yml", workflowsDirectory), "utf8"); + const publication = await readFile(new URL("publish-release.yml", workflowsDirectory), "utf8"); + const callerDocument = parse(caller); + const publicationDocument = parse(publication); + const authorization = publicationDocument.jobs["authorize-publication"]; + + assert.equal(callerDocument.jobs["publication-context"].needs, "release-gate"); + assert.match(callerDocument.jobs["publication-context"].steps.at(-1).uses, /^actions\/upload-artifact@[0-9a-f]{40}$/); + assert.equal( + callerDocument.jobs["publication-context"].steps.at(-1).with.name, + "prime-agent-publication-context", + ); + const releaseRunName = callerDocument["run-name"]; + assert.equal(typeof releaseRunName, "string"); + assert.match(releaseRunName, /workflow_run\.conclusion == 'success'/); + assert.match(releaseRunName, /workflow_run\.event == 'push'/); + assert.match(releaseRunName, /workflow_run\.head_branch == github\.event\.repository\.default_branch/); + assert.match(releaseRunName, /workflow_run\.head_repository\.full_name == github\.repository/); + assert.match(releaseRunName, /Prime Agent release candidate/); + assert.match(releaseRunName, /Rejected Prime Agent release event/); + assert.match(publication, /workflow_run:\n\s+workflows: \[Release Prime Agent\]\n\s+types: \[completed\]/); + assert.match(publicationDocument.concurrency.group, /release-prime-agent/); + assert.match(publicationDocument.concurrency.group, /workflow_run\.conclusion == 'success'/); + assert.match(publicationDocument.concurrency.group, /workflow_run\.path == '\.github\/workflows\/build-binaries\.yml'/); + assert.match(publicationDocument.concurrency.group, /workflow_run\.display_title/); + assert.deepEqual(authorization.permissions, { actions: "read", contents: "read" }); + assert.equal(authorization.environment, undefined); + assert.equal(authorization.concurrency, undefined); + assert.doesNotMatch(JSON.stringify(authorization), /R2_/); + assert.match(authorization.if, /workflow_run\.conclusion == 'success'/); + assert.match(authorization.if, /workflow_run\.head_branch == github\.event\.repository\.default_branch/); + assert.match(authorization.if, /workflow_run\.head_repository\.full_name == github\.repository/); + + const downloadStep = authorization.steps.find((step) => step.name === "Download gated publication context"); + assert.ok(downloadStep); + assert.match(downloadStep.run, /actions\/runs\/\$\{SOURCE_RUN_ID\}\/artifacts/); + assert.match(downloadStep.run, /prime-agent-publication-context/); + const authorizationStep = authorization.steps.find((step) => step.name === "Authorize completed release gate"); + assert.ok(authorizationStep); + assert.equal(authorizationStep.name, "Authorize completed release gate"); + assert.match(authorizationStep.run, /release-publication-context\.mjs validate/); + for (const name of [ + "UPSTREAM_CONCLUSION", + "UPSTREAM_DISPLAY_TITLE", + "UPSTREAM_EVENT", + "UPSTREAM_HEAD_BRANCH", + "UPSTREAM_HEAD_REPOSITORY", + "UPSTREAM_HEAD_SHA", + "UPSTREAM_RUN_ID", + "UPSTREAM_WORKFLOW_PATH", + ]) { + assert.ok(Object.hasOwn(authorizationStep.env, name), `authorization must bind ${name}`); + } + for (const [jobName, job] of Object.entries(publicationDocument.jobs)) { + if (jobName === "authorize-publication") continue; + assert.equal(job.environment, "production", `${jobName} must receive protected publication secrets`); + assert.ok( + Array.isArray(job.needs) ? job.needs.includes("authorize-publication") : job.needs === "authorize-publication", + `${jobName} must depend on publication authorization`, ); } }); +test("beta R2 freshness is checked inside read-token mutation jobs", async () => { + const workflow = await readFile(new URL("publish-release.yml", workflowsDirectory), "utf8"); + const document = parse(workflow); + const steps = new Map(); + for (const job of getJobBlocks(workflow)) { + for (const [name, step] of getNamedSteps(job.source)) steps.set(name, step); + } + for (const [name, phase] of [ + ["Advance beta installers in R2", "beta-r2-installers"], + ["Advance beta channel in R2", "beta-r2-promote"], + ]) { + const step = steps.get(name); + assert.ok(step, `beta publication must define ${name}`); + assert.match(step, new RegExp(`publish-release\\.mjs ${phase}`)); + assert.match(step, /--default-branch "\$DEFAULT_BRANCH"/); + assert.doesNotMatch(step, /GH_TOKEN|secrets\.GITHUB_TOKEN/); + } + for (const jobName of ["beta-r2-finalize", "beta-r2-immutable"]) { + assert.deepEqual(document.jobs[jobName].permissions, { actions: "read", contents: "read" }); + } +}); + test("Dependabot continues updating pinned GitHub Actions", async () => { const dependabot = await readFile(new URL("../../../.github/dependabot.yml", import.meta.url), "utf8"); assert.match(dependabot, /package-ecosystem:\s+github-actions/); diff --git a/scripts/lib/release-command.mjs b/scripts/lib/release-command.mjs new file mode 100644 index 0000000000..8da9e783c2 --- /dev/null +++ b/scripts/lib/release-command.mjs @@ -0,0 +1,54 @@ +import { spawnSync } from "node:child_process"; + +export function isGitHubNotFoundError(output) { + return /^gh: [^\n]+ \(HTTP 404\)$/.test(output.trim()); +} + +export function isR2MissingObjectError(output) { + return /^An error occurred \((?:NoSuchKey|404)\) when calling the GetObject operation:[^\n]*$/.test(output.trim()); +} + +export function isR2PreconditionFailure(output) { + return /^An error occurred \((?:PreconditionFailed|412)\) when calling the PutObject operation:[^\n]*$/.test( + output.trim(), + ); +} + +export function runCommand(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd, + encoding: "utf8", + env: { ...process.env, AWS_PAGER: "" }, + maxBuffer: 10 * 1024 * 1024, + stdio: "pipe", + }); + if (result.error) throw result.error; + if (result.status !== 0) { + const output = `${result.stderr ?? ""}\n${result.stdout ?? ""}`.trim(); + if (options.acceptFailure?.(output)) return undefined; + if (options.allowFailure) return undefined; + throw new Error(output || `${command} ${args.join(" ")} failed with exit code ${result.status ?? "unknown"}`); + } + return (result.stdout ?? "").trim(); +} + +export function readPublicGitHubBranchSha(repository, branch, runner = runCommand) { + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) { + throw new Error(`Invalid GitHub repository: ${repository}`); + } + if (!branch || /\s|\.\.|^[-/]|[/.]$/.test(branch)) { + throw new Error(`Invalid GitHub branch: ${branch}`); + } + const ref = `refs/heads/${branch}`; + const output = runner("git", [ + "ls-remote", + "--exit-code", + `https://github.com/${repository}.git`, + ref, + ]); + const [sha, resolvedRef, extra] = output.split("\t"); + if (extra !== undefined || !/^[0-9a-f]{40}$/.test(sha) || resolvedRef !== ref) { + throw new Error(`Unable to resolve exact default-branch commit from: ${output}`); + } + return sha; +} diff --git a/scripts/lib/release-lifecycle.mjs b/scripts/lib/release-lifecycle.mjs index 9f6f73a928..5a4bd0ff7b 100644 --- a/scripts/lib/release-lifecycle.mjs +++ b/scripts/lib/release-lifecycle.mjs @@ -366,6 +366,16 @@ export function validateRollbackRequest(releaseTag, confirmation) { }; } +export function validateAuthorizedRollbackTarget(releaseTag, expectedSha, currentSha) { + if (!/^[0-9a-f]{40}$/.test(expectedSha) || !/^[0-9a-f]{40}$/.test(currentSha)) { + throw new Error(`Rollback tag ${releaseTag} must resolve to full commit SHAs`); + } + if (currentSha !== expectedSha) { + throw new Error(`Rollback tag ${releaseTag} moved from authorized commit ${expectedSha} to ${currentSha}`); + } + return currentSha; +} + export function parseReleaseComment(input) { if (input.actorPermission !== "admin" && input.actorPermission !== "maintain") { throw new Error("Release commands require repository admin or maintain permission"); diff --git a/scripts/lib/release-publication.mjs b/scripts/lib/release-publication.mjs index e0df12d98a..ad61d25ba9 100644 --- a/scripts/lib/release-publication.mjs +++ b/scripts/lib/release-publication.mjs @@ -92,6 +92,7 @@ export function promoteChannel(artifactsDir, channel, store, options = {}) { const pointer = readFileSync(pointerPath); const manifest = readFileSync(manifestPath); + options.beforeWrite?.("promotion"); store.putMutable(pointerKey, pointerPath, { cacheControl: "no-cache", contentType: "text/plain" }); requireRemoteMatch(store, pointerKey, pointer); store.putMutable(manifestKey, manifestPath, { cacheControl: "no-cache", contentType: "application/json" }); @@ -99,7 +100,8 @@ export function promoteChannel(artifactsDir, channel, store, options = {}) { return { manifestKey, pointerKey }; } -export function publishInstallers(installers, store) { +export function publishInstallers(installers, store, options = {}) { + options.beforeWrite?.("installers"); for (const installer of installers) { const local = readFileSync(installer.path); store.putMutable(installer.key, installer.path, { @@ -115,11 +117,10 @@ export function publishChannel(options) { options.beforeMutable?.("mirror"); options.mirror?.(); verifyRemoteRelease(options.artifactsDir, options.version, options.store); - options.beforeMutable?.("installers"); - publishInstallers(options.installers ?? [], options.store); - options.beforeMutable?.("promotion"); + publishInstallers(options.installers ?? [], options.store, { beforeWrite: options.beforeMutable }); const promotion = promoteChannel(options.artifactsDir, options.channel, options.store, { allowRegression: options.allowRegression, + beforeWrite: options.beforeMutable, }); return { ...immutable, ...promotion }; } diff --git a/scripts/publish-release.mjs b/scripts/publish-release.mjs index 8c9793208a..fe34ad2d98 100755 --- a/scripts/publish-release.mjs +++ b/scripts/publish-release.mjs @@ -1,11 +1,15 @@ #!/usr/bin/env node -import { spawnSync } from "node:child_process"; -import { appendFileSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { validateReleaseRepository, validateRollbackRequest, verifyReleaseArtifacts } from "./lib/release-lifecycle.mjs"; +import { + validateAuthorizedRollbackTarget, + validateReleaseRepository, + validateRollbackRequest, + verifyReleaseArtifacts, +} from "./lib/release-lifecycle.mjs"; import { promoteChannel, publishImmutableArtifacts, @@ -13,6 +17,13 @@ import { validatePromotion, verifyRemoteRelease, } from "./lib/release-publication.mjs"; +import { + isGitHubNotFoundError, + isR2MissingObjectError, + isR2PreconditionFailure, + readPublicGitHubBranchSha, + runCommand, +} from "./lib/release-command.mjs"; const sourceRoot = resolve(process.env.PRIME_AGENT_RELEASE_SOURCE_ROOT || process.cwd()); @@ -46,20 +57,6 @@ function parseArgs(args) { return options; } -function run(command, args, options = {}) { - const result = spawnSync(command, args, { - cwd: options.cwd, - encoding: "utf8", - env: { ...process.env, AWS_PAGER: "" }, - maxBuffer: 10 * 1024 * 1024, - stdio: "pipe", - }); - if (result.status !== 0 && !options.allowFailure) { - throw new Error(result.stderr.trim() || result.stdout.trim() || `${command} ${args.join(" ")} failed`); - } - return result.status === 0 ? result.stdout.trim() : undefined; -} - class AwsR2Store { constructor(bucket, endpoint) { if (!bucket || !endpoint) throw new Error("R2_BUCKET and R2_ENDPOINT_URL are required"); @@ -75,7 +72,7 @@ class AwsR2Store { read(key) { const output = join(this.temporaryDir, `object-${this.temporaryIndex++}`); - const result = spawnSync( + const result = runCommand( "aws", [ "s3api", @@ -88,18 +85,14 @@ class AwsR2Store { this.endpoint, output, ], - { encoding: "utf8", env: { ...process.env, AWS_PAGER: "" }, stdio: "pipe" }, + { acceptFailure: isR2MissingObjectError }, ); - if (result.status !== 0) { - const error = `${result.stderr}\n${result.stdout}`; - if (/NoSuchKey|Not Found|404/i.test(error)) return undefined; - throw new Error(error.trim() || `Unable to read R2 object ${key}`); - } + if (result === undefined) return undefined; return readFileSync(output); } putImmutable(key, path, metadata) { - const result = spawnSync( + const result = runCommand( "aws", [ "s3api", @@ -119,16 +112,13 @@ class AwsR2Store { "--endpoint-url", this.endpoint, ], - { encoding: "utf8", env: { ...process.env, AWS_PAGER: "" }, stdio: "pipe" }, + { acceptFailure: isR2PreconditionFailure }, ); - if (result.status === 0) return true; - const error = `${result.stderr}\n${result.stdout}`; - if (/PreconditionFailed|412/i.test(error)) return false; - throw new Error(error.trim() || `Unable to create immutable R2 object ${key}`); + return result !== undefined; } putMutable(key, path, metadata) { - run("aws", [ + runCommand("aws", [ "s3api", "put-object", "--bucket", @@ -154,23 +144,49 @@ class GitHubReleaseMirror { } gh(args, options = {}) { - return run("gh", [...args, "--repo", this.repository], options); + return runCommand("gh", [...args, "--repo", this.repository], options); + } + + api(args, options = {}) { + return runCommand("gh", ["api", ...args], options); } viewRelease(tag) { - const output = this.gh( - ["release", "view", tag, "--json", "assets,isDraft,isPrerelease,targetCommitish"], - { allowFailure: true }, + const output = this.api( + [ + `repos/${this.repository}/releases/tags/${encodeURIComponent(tag)}`, + "--jq", + "{assets: .assets, isDraft: .draft, isPrerelease: .prerelease, targetCommitish: .target_commitish}", + ], + { acceptFailure: isGitHubNotFoundError }, ); return output ? JSON.parse(output) : undefined; } readTagTarget(tag) { - return run( - "gh", - ["api", `repos/${this.repository}/git/ref/tags/${tag}`, "--jq", ".object.sha"], - { allowFailure: true }, + const output = this.api( + [ + `repos/${this.repository}/git/ref/tags/${encodeURIComponent(tag)}`, + "--jq", + "{sha: .object.sha, type: .object.type}", + ], + { acceptFailure: isGitHubNotFoundError }, ); + if (!output) return undefined; + let target = JSON.parse(output); + for (let depth = 0; target.type === "tag" && depth < 8; depth += 1) { + target = JSON.parse( + this.api([ + `repos/${this.repository}/git/tags/${target.sha}`, + "--jq", + "{sha: .object.sha, type: .object.type}", + ]), + ); + } + if (target.type !== "commit" || !/^[0-9a-f]{40}$/.test(target.sha)) { + throw new Error(`Tag ${tag} does not resolve to a commit`); + } + return target.sha; } ensureTag(tag, buildRef) { @@ -179,7 +195,7 @@ class GitHubReleaseMirror { throw new Error(`Immutable tag ${tag} points to ${target}, not ${buildRef}`); } if (!target) { - run("gh", [ + runCommand("gh", [ "api", "--method", "POST", @@ -262,7 +278,7 @@ class GitHubReleaseMirror { } } - verifyExistingRelease(tag, artifactsDir) { + verifyExistingRelease(tag, artifactsDir, expectedTarget) { const release = this.viewRelease(tag); if (!release || release.isDraft || release.isPrerelease) { throw new Error(`Stable GitHub Release ${tag} does not exist`); @@ -271,6 +287,7 @@ class GitHubReleaseMirror { if (!tagTarget || release.targetCommitish !== tagTarget) { throw new Error(`Stable GitHub Release ${tag} does not match its immutable tag`); } + validateAuthorizedRollbackTarget(tag, expectedTarget, tagTarget); const names = release.assets.map((asset) => asset.name).sort(); this.downloadAssets(tag, artifactsDir, names); return tagTarget; @@ -279,7 +296,7 @@ class GitHubReleaseMirror { replaceBetaRelease(buildRef, version, artifactsDir, defaultBranch) { const betaTarget = this.readTagTarget("beta"); if (betaTarget) { - run("gh", [ + runCommand("gh", [ "api", "--method", "PATCH", @@ -290,7 +307,7 @@ class GitHubReleaseMirror { "force=true", ]); } else { - run("gh", [ + runCommand("gh", [ "api", "--method", "POST", @@ -308,14 +325,14 @@ class GitHubReleaseMirror { writeFileSync(notesFile, `Automated beta build from \`${defaultBranch}\` (\`${buildRef}\`).\n`); const release = this.viewRelease("beta"); if (release) { - const assetIds = run( + const assetIds = runCommand( "gh", ["api", `repos/${this.repository}/releases/tags/beta`, "--jq", ".assets[].id"], ) .split("\n") .filter(Boolean); for (const assetId of assetIds) { - run("gh", ["api", "--method", "DELETE", `repos/${this.repository}/releases/assets/${assetId}`]); + runCommand("gh", ["api", "--method", "DELETE", `repos/${this.repository}/releases/assets/${assetId}`]); } this.gh([ "release", @@ -346,13 +363,26 @@ class GitHubReleaseMirror { for (const name of readdirSync(artifactsDir).sort()) { this.gh(["release", "upload", "beta", join(artifactsDir, name)]); } + const finalRelease = this.viewRelease("beta"); + if (!finalRelease || finalRelease.isDraft || !finalRelease.isPrerelease || finalRelease.targetCommitish !== buildRef) { + throw new Error(`Beta GitHub Release does not match commit ${buildRef}`); + } + const expectedNames = readdirSync(artifactsDir).sort(); + const finalNames = finalRelease.assets.map((asset) => asset.name).sort(); + const finalDir = mkdtempSync(join(tmpdir(), "prime-agent-beta-final-")); + try { + this.downloadAssets("beta", finalDir, finalNames); + this.verifyAssetDirectory(artifactsDir, finalDir, expectedNames); + } finally { + rmSync(finalDir, { force: true, recursive: true }); + } } finally { rmSync(notesDir, { force: true, recursive: true }); } } latestDefaultBranchSha(defaultBranch) { - return run("gh", ["api", `repos/${this.repository}/commits/${defaultBranch}`, "--jq", ".sha"]); + return this.api([`repos/${this.repository}/commits/${encodeURIComponent(defaultBranch)}`, "--jq", ".sha"]); } } @@ -368,6 +398,16 @@ function installers(options) { ]; } +function assertLatestDefaultBranch(options, buildRef) { + const repository = process.env.GITHUB_REPOSITORY; + if (!repository) throw new Error("GITHUB_REPOSITORY is required for beta mutation"); + const defaultBranch = requireOption(options, "defaultBranch"); + const latestSha = readPublicGitHubBranchSha(repository, defaultBranch); + if (latestSha !== buildRef) { + throw new Error(`A newer default-branch commit exists; refusing stale beta update (${latestSha})`); + } +} + function validatePhaseArtifacts(options, baseUrl, channel) { const version = requireOption(options, "version"); const buildRef = requireOption(options, "buildRef"); @@ -429,31 +469,37 @@ function publishBetaGitHub(options, github, baseUrl) { } function publishR2Installers(options, store, baseUrl, channel) { - const { artifactsDir, version } = validatePhaseArtifacts(options, baseUrl, channel); + const { artifactsDir, buildRef, version } = validatePhaseArtifacts(options, baseUrl, channel); verifyRemoteRelease(artifactsDir, version, store); - publishInstallers(installers(options), store); + publishInstallers(installers(options), store, { + beforeWrite: channel === "beta" ? () => assertLatestDefaultBranch(options, buildRef) : undefined, + }); console.log(`Published ${channel} installers for v${version}.`); } function promoteBetaR2(options, store, baseUrl) { - const { artifactsDir, version } = validatePhaseArtifacts(options, baseUrl, "beta"); + const { artifactsDir, buildRef, version } = validatePhaseArtifacts(options, baseUrl, "beta"); verifyRemoteRelease(artifactsDir, version, store); - promoteChannel(artifactsDir, "beta", store); + promoteChannel(artifactsDir, "beta", store, { + beforeWrite: () => assertLatestDefaultBranch(options, buildRef), + }); console.log(`Promoted beta v${version}.`); } function verifyRollbackGitHub(options, github, baseUrl) { const releaseTag = requireOption(options, "releaseTag"); validateRollbackRequest(releaseTag, requireOption(options, "confirmation")); + const sourceSha = requireOption(options, "sourceSha"); + if (!/^[0-9a-f]{40}$/.test(sourceSha)) throw new Error("Rollback source SHA must be a full commit SHA"); const artifactsDir = resolve(requireOption(options, "artifactsDir")); mkdirSync(artifactsDir, { recursive: true }); if (readdirSync(artifactsDir).length > 0) { throw new Error(`Rollback artifact directory must be empty: ${artifactsDir}`); } - const tagTarget = github.verifyExistingRelease(releaseTag, artifactsDir); + const tagTarget = github.verifyExistingRelease(releaseTag, artifactsDir, sourceSha); const defaultBranch = requireOption(options, "defaultBranch"); if ( - run("git", ["merge-base", "--is-ancestor", tagTarget, `origin/${defaultBranch}`], { + runCommand("git", ["merge-base", "--is-ancestor", tagTarget, `origin/${defaultBranch}`], { allowFailure: true, cwd: sourceRoot, }) === undefined @@ -467,7 +513,6 @@ function verifyRollbackGitHub(options, github, baseUrl) { sourceSha: tagTarget, version: releaseTag.slice(1), }); - if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `source_sha=${tagTarget}\n`); console.log(`Verified rollback release ${releaseTag} at ${tagTarget}.`); } diff --git a/scripts/release-command.test.mjs b/scripts/release-command.test.mjs new file mode 100644 index 0000000000..ca35936c56 --- /dev/null +++ b/scripts/release-command.test.mjs @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + isGitHubNotFoundError, + isR2MissingObjectError, + isR2PreconditionFailure, + readPublicGitHubBranchSha, + runCommand, +} from "./lib/release-command.mjs"; + +function withFailingCommand(callback) { + const root = mkdtempSync(join(tmpdir(), "prime-agent-release-command-")); + const fixture = join(root, "fail.mjs"); + writeFileSync( + fixture, + "process.stderr.write(process.argv[2]); process.exit(Number.parseInt(process.argv[3], 10));\n", + ); + try { + callback((message, status, options) => runCommand(process.execPath, [fixture, message, String(status)], options)); + } finally { + rmSync(root, { force: true, recursive: true }); + } +} + +test("GitHub reads accept only a structured HTTP 404 as absence", () => { + withFailingCommand((fail) => { + assert.equal(fail("gh: release not found (HTTP 404)\n", 1, { acceptFailure: isGitHubNotFoundError }), undefined); + for (const message of [ + "gh: HTTP 401: Bad credentials (HTTP 401)\n", + "gh: Resource not accessible by integration (HTTP 403)\n", + "gh: API rate limit exceeded (HTTP 429)\n", + "gh: Internal Server Error (HTTP 500)\n", + "error connecting to api.github.com\n", + ]) { + assert.throws(() => fail(message, 1, { acceptFailure: isGitHubNotFoundError })); + } + }); +}); + +test("R2 reads accept only a structured GetObject absence", () => { + withFailingCommand((fail) => { + assert.equal( + fail("An error occurred (NoSuchKey) when calling the GetObject operation: missing\n", 1, { + acceptFailure: isR2MissingObjectError, + }), + undefined, + ); + for (const message of [ + "An error occurred (AccessDenied) when calling the GetObject operation: 404 policy\n", + "An error occurred (429) when calling the GetObject operation: throttled\n", + "An error occurred (500) when calling the GetObject operation: unavailable\n", + "Could not connect to the endpoint URL: https://example.invalid/404\n", + ]) { + assert.throws(() => fail(message, 1, { acceptFailure: isR2MissingObjectError })); + } + }); +}); + +test("immutable R2 writes accept only a structured PutObject precondition failure", () => { + assert.equal( + isR2PreconditionFailure( + "An error occurred (PreconditionFailed) when calling the PutObject operation: already exists", + ), + true, + ); + for (const message of [ + "An error occurred (AccessDenied) when calling the PutObject operation: 412 policy", + "An error occurred (500) when calling the PutObject operation: unavailable", + ]) { + assert.equal(isR2PreconditionFailure(message), false); + } +}); + +test("beta freshness resolves one exact public default-branch SHA", () => { + const sha = "a".repeat(40); + const runner = (command, args) => { + assert.equal(command, "git"); + assert.deepEqual(args, [ + "ls-remote", + "--exit-code", + "https://github.com/PrimeIntellect-ai/prime-agent.git", + "refs/heads/main", + ]); + return `${sha}\trefs/heads/main`; + }; + assert.equal(readPublicGitHubBranchSha("PrimeIntellect-ai/prime-agent", "main", runner), sha); + assert.throws( + () => readPublicGitHubBranchSha("PrimeIntellect-ai/prime-agent", "main", () => `${sha}\trefs/heads/other`), + /Unable to resolve exact default-branch commit/, + ); + assert.throws(() => readPublicGitHubBranchSha("invalid", "main", runner), /Invalid GitHub repository/); + assert.throws( + () => readPublicGitHubBranchSha("PrimeIntellect-ai/prime-agent", "../main", runner), + /Invalid GitHub branch/, + ); +}); diff --git a/scripts/release-lifecycle.test.mjs b/scripts/release-lifecycle.test.mjs index 8499b38fbe..2b52390a6b 100644 --- a/scripts/release-lifecycle.test.mjs +++ b/scripts/release-lifecycle.test.mjs @@ -15,6 +15,7 @@ import { parseReleaseComment, prepareRelease, promotionKeys, + validateAuthorizedRollbackTarget, validateReleaseRepository, validateRollbackRequest, verifyReleaseArtifacts, @@ -385,6 +386,16 @@ test("rollback requires exact confirmation and remains pointer-only", () => { assert.throws(() => validateRollbackRequest("beta", "ROLLBACK beta"), /plain stable release tag/i); }); +test("rollback remains bound to the commit authorized before the protected wait", () => { + const authorizedSha = "a".repeat(40); + assert.equal(validateAuthorizedRollbackTarget("v0.7.1", authorizedSha, authorizedSha), authorizedSha); + assert.throws( + () => validateAuthorizedRollbackTarget("v0.7.1", authorizedSha, "b".repeat(40)), + /moved from authorized commit/, + ); + assert.throws(() => validateAuthorizedRollbackTarget("v0.7.1", "short", authorizedSha), /full commit SHAs/); +}); + test("artifact verification checks branded manifests, internal URLs, and every checksum", () => { const { artifactsDir, root } = createArtifactFixture(); try { diff --git a/scripts/release-publication-context.mjs b/scripts/release-publication-context.mjs new file mode 100644 index 0000000000..59596465c0 --- /dev/null +++ b/scripts/release-publication-context.mjs @@ -0,0 +1,172 @@ +#!/usr/bin/env node + +import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const shaPattern = /^[0-9a-f]{40}$/; +const stableVersionPattern = /^[0-9]+\.[0-9]+\.[0-9]+$/; +const betaVersionPattern = /^[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+\.[0-9]+\.[0-9a-f]{7}$/; +const allowedReleaseEvents = new Set(["issue_comment", "workflow_run"]); +const contextKeys = [ + "betaVersion", + "buildSha", + "defaultBranch", + "productionVersion", + "publishBeta", + "publishProduction", + "releaseRunId", + "schemaVersion", + "toolingSha", +]; + +function requireString(value, label) { + if (typeof value !== "string" || value.length === 0) throw new Error(`${label} must be a non-empty string`); + return value; +} + +function requireSha(value, label) { + if (typeof value !== "string" || !shaPattern.test(value)) throw new Error(`${label} must be a 40-character SHA`); + return value; +} + +function parseBoolean(value, label) { + if (value === true || value === "true") return true; + if (value === false || value === "false") return false; + throw new Error(`${label} must be true or false`); +} + +function requireRunId(value, label) { + const normalized = String(value); + if (!/^[1-9][0-9]*$/.test(normalized)) throw new Error(`${label} must be a positive integer`); + return normalized; +} + +function requireExactKeys(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Publication context must be an object"); + } + const actualKeys = Object.keys(value).sort(); + if (JSON.stringify(actualKeys) !== JSON.stringify(contextKeys)) { + throw new Error(`Publication context fields must be exactly: ${contextKeys.join(", ")}`); + } +} + +export function createPublicationContext(input) { + const publishBeta = parseBoolean(input.publishBeta, "publishBeta"); + const publishProduction = parseBoolean(input.publishProduction, "publishProduction"); + if (!publishBeta && !publishProduction) throw new Error("At least one publication channel must be enabled"); + const betaVersion = publishBeta ? requireString(input.betaVersion, "betaVersion") : input.betaVersion; + const productionVersion = publishProduction + ? requireString(input.productionVersion, "productionVersion") + : input.productionVersion; + if (publishBeta && !betaVersionPattern.test(betaVersion)) throw new Error("betaVersion has an invalid format"); + if (!publishBeta && betaVersion !== "") throw new Error("betaVersion must be empty when beta publication is disabled"); + if (publishProduction && !stableVersionPattern.test(productionVersion)) { + throw new Error("productionVersion has an invalid format"); + } + if (!publishProduction && productionVersion !== "") { + throw new Error("productionVersion must be empty when production publication is disabled"); + } + return { + betaVersion, + buildSha: requireSha(input.buildSha, "buildSha"), + defaultBranch: requireString(input.defaultBranch, "defaultBranch"), + productionVersion, + publishBeta, + publishProduction, + releaseRunId: requireRunId(input.releaseRunId, "releaseRunId"), + schemaVersion: 1, + toolingSha: requireSha(input.toolingSha, "toolingSha"), + }; +} + +export function validatePublicationContext(context, upstream) { + requireExactKeys(context); + if (context.schemaVersion !== 1) throw new Error("Unsupported publication context schema"); + const expectedRepository = requireString(upstream.repository, "repository"); + const expectedDefaultBranch = requireString(upstream.defaultBranch, "defaultBranch"); + if (upstream.conclusion !== "success") throw new Error("Release workflow conclusion must be success"); + if (!upstream.displayTitle?.startsWith("Prime Agent release candidate ")) { + throw new Error("Release workflow is not a publication candidate"); + } + if (!allowedReleaseEvents.has(upstream.event)) throw new Error(`Unsupported release event: ${upstream.event}`); + if (upstream.path !== ".github/workflows/build-binaries.yml") throw new Error("Unexpected release workflow path"); + if (upstream.headRepository !== expectedRepository) throw new Error("Release workflow repository does not match"); + if (upstream.headBranch !== expectedDefaultBranch) throw new Error("Release workflow branch does not match"); + const expectedRunId = requireRunId(upstream.runId, "upstream run ID"); + const expectedToolingSha = requireSha(upstream.headSha, "upstream SHA"); + const normalized = createPublicationContext(context); + if (normalized.releaseRunId !== expectedRunId) throw new Error("Publication context run ID does not match"); + if (normalized.toolingSha !== expectedToolingSha) throw new Error("Publication context tooling SHA does not match"); + if (normalized.defaultBranch !== expectedDefaultBranch) throw new Error("Publication context branch does not match"); + return normalized; +} + +function environmentContext() { + return { + betaVersion: process.env.BETA_VERSION ?? "", + buildSha: process.env.BUILD_SHA, + defaultBranch: process.env.DEFAULT_BRANCH, + productionVersion: process.env.PRODUCTION_VERSION ?? "", + publishBeta: process.env.PUBLISH_BETA, + publishProduction: process.env.PUBLISH_PRODUCTION, + releaseRunId: process.env.RELEASE_RUN_ID, + toolingSha: process.env.TOOLING_SHA, + }; +} + +function upstreamEnvironment() { + return { + conclusion: process.env.UPSTREAM_CONCLUSION, + defaultBranch: process.env.DEFAULT_BRANCH, + displayTitle: process.env.UPSTREAM_DISPLAY_TITLE, + event: process.env.UPSTREAM_EVENT, + headBranch: process.env.UPSTREAM_HEAD_BRANCH, + headRepository: process.env.UPSTREAM_HEAD_REPOSITORY, + headSha: process.env.UPSTREAM_HEAD_SHA, + path: process.env.UPSTREAM_WORKFLOW_PATH, + repository: process.env.GITHUB_REPOSITORY, + runId: process.env.UPSTREAM_RUN_ID, + }; +} + +function writeOutputs(context) { + const outputPath = requireString(process.env.GITHUB_OUTPUT, "GITHUB_OUTPUT"); + appendFileSync( + outputPath, + [ + `beta_version=${context.betaVersion}`, + `build_sha=${context.buildSha}`, + `default_branch=${context.defaultBranch}`, + `production_version=${context.productionVersion}`, + `publish_beta=${context.publishBeta}`, + `publish_production=${context.publishProduction}`, + `source_run_id=${context.releaseRunId}`, + `tooling_sha=${context.toolingSha}`, + "", + ].join("\n"), + ); +} + +function main() { + const [operation, contextPath] = process.argv.slice(2); + if (!contextPath || !new Set(["create", "validate"]).has(operation)) { + throw new Error("Usage: release-publication-context.mjs "); + } + if (operation === "create") { + writeFileSync(contextPath, `${JSON.stringify(createPublicationContext(environmentContext()), null, 2)}\n`); + return; + } + const context = JSON.parse(readFileSync(contextPath, "utf8")); + writeOutputs(validatePublicationContext(context, upstreamEnvironment())); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/release-publication.test.mjs b/scripts/release-publication.test.mjs index 34af257c21..8deea0f16f 100644 --- a/scripts/release-publication.test.mjs +++ b/scripts/release-publication.test.mjs @@ -8,6 +8,7 @@ import { promoteChannel, publishChannel, publishImmutableArtifacts, + publishInstallers, verifyRemoteRelease, } from "./lib/release-publication.mjs"; @@ -162,6 +163,40 @@ test("a beta that becomes stale during mutable work cannot advance channel point } }); +test("split beta mutation helpers run their freshness guard inside the write boundary", () => { + const { artifactsDir, root } = createPublicationFixture("0.7.2-beta.42.1.0123456", "beta"); + const installerPath = join(root, "install-beta.sh"); + writeFileSync(installerPath, "installer"); + try { + const installerStore = new MemoryStore(); + assert.throws( + () => + publishInstallers([{ key: "install-beta.sh", path: installerPath }], installerStore, { + beforeWrite: () => { + throw new Error("newer main commit"); + }, + }), + /newer main commit/, + ); + assert.equal(installerStore.objects.has("install-beta.sh"), false); + + const promotionStore = new MemoryStore(); + assert.throws( + () => + promoteChannel(artifactsDir, "beta", promotionStore, { + beforeWrite: () => { + throw new Error("newer main commit"); + }, + }), + /newer main commit/, + ); + assert.equal(promotionStore.objects.has("beta"), false); + assert.equal(promotionStore.objects.has("beta.json"), false); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("stable promotion is monotonic unless an explicit rollback allows regression", () => { const { artifactsDir, root } = createPublicationFixture(); const store = new MemoryStore({ diff --git a/scripts/release-workflow-contract.test.mjs b/scripts/release-workflow-contract.test.mjs index 55eb64aade..806c49124b 100644 --- a/scripts/release-workflow-contract.test.mjs +++ b/scripts/release-workflow-contract.test.mjs @@ -7,6 +7,7 @@ const dryRun = readFileSync(new URL("release-dry-run.mjs", import.meta.url), "ut const legacyRelease = readFileSync(new URL("release.mjs", import.meta.url), "utf8"); const legacySync = readFileSync(new URL("sync-versions.js", import.meta.url), "utf8"); const releaseWorkflow = readFileSync(new URL("../.github/workflows/build-binaries.yml", import.meta.url), "utf8"); +const publicationWorkflow = readFileSync(new URL("../.github/workflows/publish-release.yml", import.meta.url), "utf8"); const rollbackWorkflow = readFileSync(new URL("../.github/workflows/rollback-release.yml", import.meta.url), "utf8"); const rollbackResolver = readFileSync(new URL("resolve-rollback-context.mjs", import.meta.url), "utf8"); const publisher = readFileSync(new URL("publish-release.mjs", import.meta.url), "utf8"); @@ -44,6 +45,27 @@ test("release authority is successful canonical CI or an authorized immutable re assert.match(releaseWorkflow, /path: release-source/); assert.match(releaseWorkflow, /full-ci:[\s\S]*outputs\.trigger == 'retry'/); assert.match(releaseWorkflow, /uses: \.\/\.github\/workflows\/ci\.yml/); + assert.match(releaseWorkflow, /publication-context:[\s\S]*needs: release-gate/); + assert.match(releaseWorkflow, /name: prime-agent-publication-context/); + assert.match(publicationWorkflow, /workflow_run:\n\s+workflows: \[Release Prime Agent\]/); + + const authorizationStart = publicationWorkflow.indexOf(" authorize-publication:"); + const firstMutationStart = publicationWorkflow.indexOf(" production-github-prepare:"); + assert.ok(authorizationStart > -1 && firstMutationStart > authorizationStart); + const authorization = publicationWorkflow.slice(authorizationStart, firstMutationStart); + assert.match(authorization, /actions: read/); + assert.match(authorization, /contents: read/); + assert.match(authorization, /workflow_run\.conclusion == 'success'/); + assert.match(authorization, /workflow_run\.path == '\.github\/workflows\/build-binaries\.yml'/); + assert.match(authorization, /workflow_run\.head_repository\.full_name == github\.repository/); + assert.match(authorization, /workflow_run\.display_title/); + assert.match(authorization, /actions\/runs\/\$\{SOURCE_RUN_ID\}\/artifacts/); + assert.match(authorization, /prime-agent-publication-context/); + assert.match(authorization, /release-publication-context\.mjs validate/); + assert.match(authorization, /UPSTREAM_RUN_ID: \$\{\{ github\.event\.workflow_run\.id \}\}/); + assert.match(authorization, /UPSTREAM_HEAD_SHA: \$\{\{ github\.event\.workflow_run\.head_sha \}\}/); + assert.doesNotMatch(authorization, /environment: production|group: release-prime-agent|secrets\.R2_/); + assert.match(publicationWorkflow.slice(0, authorizationStart), /group:[\s\S]*release-prime-agent/); }); test("release artifacts, verification, and publication use one exact source SHA", () => { @@ -52,8 +74,17 @@ test("release artifacts, verification, and publication use one exact source SHA" assert.match(releaseWorkflow, /prime-agent-production-\$\{\{ env\.BUILD_SHA \}\}/); assert.match(releaseWorkflow, /prime-agent-beta-\$\{\{ env\.BUILD_SHA \}\}/); assert.match(releaseWorkflow, /Verify release source commit[\s\S]*git rev-parse HEAD/); + const baselineIndex = releaseWorkflow.indexOf("Capture exact release source baseline"); + const buildIndex = releaseWorkflow.indexOf("- name: Build"); + const finalGuardIndex = releaseWorkflow.indexOf("Verify build and validation did not mutate release source"); + assert.ok(baselineIndex > -1 && baselineIndex < buildIndex); + assert.ok(finalGuardIndex > buildIndex); + assert.match(releaseWorkflow, /release-source-worktree\.diff[\s\S]*release-final-worktree\.diff/); assert.match(releaseWorkflow, /release-gate:[\s\S]*needs: \[release-context, full-ci, build\]/); - assert.match(releaseWorkflow, /publish:[\s\S]*needs: release-gate/); + assert.match(releaseWorkflow, /publication-context:[\s\S]*needs: release-gate/); + assert.match(releaseWorkflow, /release-publication-context\.mjs create publication-context\.json/); + assert.match(releaseWorkflow, /BUILD_SHA: \$\{\{ needs\.release-gate\.outputs\.build_sha \}\}/); + assert.match(releaseWorkflow, /TOOLING_SHA: \$\{\{ github\.workflow_sha \}\}/); }); test("production and beta mutation phases preserve ordering and credential separation", () => { @@ -64,22 +95,21 @@ test("production and beta mutation phases preserve ordering and credential separ "Publish production installers to R2", "Promote production channel in R2", "Publish beta immutable objects to R2", - "Check beta freshness before GitHub mirror", "Advance beta GitHub release", - "Check beta freshness before installers", "Advance beta installers in R2", - "Check beta freshness before channel promotion", "Advance beta channel in R2", ]; let previous = -1; for (const name of orderedNames) { - const index = releaseWorkflow.indexOf(`- name: ${name}`); + const index = publicationWorkflow.indexOf(`- name: ${name}`); assert.ok(index > previous, `${name} must follow the preceding publication phase`); previous = index; } - assert.doesNotMatch(releaseWorkflow, /gh release upload[^\n]*--clobber/); + assert.doesNotMatch(publicationWorkflow, /gh release upload[^\n]*--clobber/); assert.match(publisher, /putImmutable/); assert.match(publisher, /ensureProductionAssets/); + assert.match(publisher, /prime-agent-beta-final-/); + assert.match(publisher, /verifyAssetDirectory\(artifactsDir, finalDir, expectedNames\)/); assert.match(publisher, /promoteChannel\(artifactsDir, "stable"/); }); @@ -101,6 +131,10 @@ test("rollback authorizes before protected resources and splits GitHub verificat assert.match(mutation, /group: release-prime-agent/); assert.match(mutation, /rollback-github-verify/); assert.match(mutation, /rollback-r2-promote/); + assert.equal( + mutation.match(/--source-sha "\$\{\{ needs\.authorize\.outputs\.source_sha \}\}"/g)?.length, + 2, + ); const githubStep = mutation.slice( mutation.indexOf("- name: Verify rollback GitHub release"), mutation.indexOf("- name: Promote verified stable pointers in R2"), diff --git a/scripts/release-workflow.test.mjs b/scripts/release-workflow.test.mjs index 817953304f..8698a2e2e3 100644 --- a/scripts/release-workflow.test.mjs +++ b/scripts/release-workflow.test.mjs @@ -6,6 +6,7 @@ import { join } from "node:path"; import { afterEach, describe, it } from "node:test"; import { assertLockstepVersions, resolveReleaseContext } from "./resolve-release-context.mjs"; +import { createPublicationContext, validatePublicationContext } from "./release-publication-context.mjs"; import { verifyCiResults } from "./verify-ci-results.mjs"; import { verifyReleaseGate } from "./verify-release-gate.mjs"; import { verifyReleaseArtifacts } from "./verify-release-artifacts.mjs"; @@ -21,6 +22,7 @@ const packageVersions = { }; const releaseWorkflow = readFileSync(new URL("../.github/workflows/build-binaries.yml", import.meta.url), "utf8"); const ciWorkflow = readFileSync(new URL("../.github/workflows/ci.yml", import.meta.url), "utf8"); +const publicationWorkflow = readFileSync(new URL("../.github/workflows/publish-release.yml", import.meta.url), "utf8"); describe("workflow contract", () => { it("publishes main only from completed CI and reuses full CI only for protected retry", () => { @@ -39,7 +41,11 @@ describe("workflow contract", () => { assert.match(releaseWorkflow, /Verify release source commit[\s\S]*git rev-parse HEAD/); assert.match(releaseWorkflow, /name: prime-agent-production-\$\{\{ env\.BUILD_SHA \}\}/); assert.match(releaseWorkflow, /name: prime-agent-beta-\$\{\{ env\.BUILD_SHA \}\}/); - assert.match(releaseWorkflow, /publish:[\s\S]*needs: release-gate/); + assert.match(releaseWorkflow, /Capture exact release source baseline[\s\S]*- name: Build/); + assert.match(releaseWorkflow, /Verify build and validation did not mutate release source/); + assert.match(releaseWorkflow, /publication-context:[\s\S]*needs: release-gate/); + assert.match(releaseWorkflow, /name: prime-agent-publication-context/); + assert.match(publicationWorkflow, /workflow_run:\n\s+workflows: \[Release Prime Agent\]/); }); it("validates source provenance before every split publication phase", () => { @@ -55,7 +61,7 @@ describe("workflow contract", () => { "beta-r2-installers", "beta-r2-promote", ]) { - assert.match(releaseWorkflow, new RegExp(`publish-release\\.mjs ${phase}`)); + assert.match(publicationWorkflow, new RegExp(`publish-release\\.mjs ${phase}`)); } }); }); @@ -206,6 +212,73 @@ describe("aggregate CI gate", () => { }); }); +function validPublicationContext(overrides = {}) { + return createPublicationContext({ + betaVersion: "0.7.1-beta.123.2.aaaaaaa", + buildSha, + defaultBranch: "main", + productionVersion: "0.7.1", + publishBeta: true, + publishProduction: true, + releaseRunId: "321", + toolingSha: buildSha, + ...overrides, + }); +} + +function validPublicationUpstream(overrides = {}) { + return { + conclusion: "success", + defaultBranch: "main", + displayTitle: `Prime Agent release candidate ${buildSha}`, + event: "workflow_run", + headBranch: "main", + headRepository: "PrimeIntellect-ai/prime-agent", + headSha: buildSha, + path: ".github/workflows/build-binaries.yml", + repository: "PrimeIntellect-ai/prime-agent", + runId: "321", + ...overrides, + }; +} + +describe("publication context gate", () => { + it("accepts only an exact successful release-run artifact", () => { + assert.deepEqual(validatePublicationContext(validPublicationContext(), validPublicationUpstream()), + validPublicationContext()); + }); + + it("rejects noncanonical upstream workflow metadata", () => { + for (const invalid of [ + { conclusion: "failure" }, + { displayTitle: "Rejected Prime Agent release event 321" }, + { event: "push" }, + { headBranch: "feature" }, + { headRepository: "attacker/prime-agent" }, + { path: ".github/workflows/other.yml" }, + { runId: "999" }, + { headSha: otherSha }, + ]) { + assert.throws(() => validatePublicationContext(validPublicationContext(), validPublicationUpstream(invalid))); + } + }); + + it("rejects malformed, mismatched, or disabled publication artifacts", () => { + assert.throws(() => validatePublicationContext({ ...validPublicationContext(), unexpected: true }, validPublicationUpstream())); + assert.throws(() => validatePublicationContext({ ...validPublicationContext(), releaseRunId: "999" }, validPublicationUpstream())); + assert.throws(() => validatePublicationContext({ ...validPublicationContext(), toolingSha: otherSha }, validPublicationUpstream())); + assert.throws(() => + createPublicationContext({ + ...validPublicationContext(), + betaVersion: "", + productionVersion: "", + publishBeta: false, + publishProduction: false, + }), + ); + }); +}); + const temporaryDirectories = []; function checksum(value) { diff --git a/scripts/verify-release-artifacts.mjs b/scripts/verify-release-artifacts.mjs index 97fc2644ea..105bfa4ea5 100644 --- a/scripts/verify-release-artifacts.mjs +++ b/scripts/verify-release-artifacts.mjs @@ -7,6 +7,7 @@ import { isDeepStrictEqual } from "node:util"; import { fileURLToPath } from "node:url"; import { RELEASE_ARTIFACTS } from "./lib/release-lifecycle.mjs"; +import { runCommand } from "./lib/release-command.mjs"; const commitShaPattern = /^[0-9a-f]{40}$/; const releaseChannels = new Set(["stable", "beta"]); @@ -157,6 +158,13 @@ function main() { for (const required of ["channel", "directory", "source_sha", "version"]) { if (!args[required]) throw new Error(`--${required.replaceAll("_", "-")} is required`); } + if (process.env.PRIME_AGENT_RELEASE_SOURCE_ROOT) { + const sourceRoot = resolve(process.env.PRIME_AGENT_RELEASE_SOURCE_ROOT); + const checkedOutSha = runCommand("git", ["rev-parse", "HEAD"], { cwd: sourceRoot }); + if (checkedOutSha !== args.source_sha) { + throw new Error(`Release source checkout is ${checkedOutSha}; expected ${args.source_sha}`); + } + } verifyReleaseArtifacts({ channel: args.channel, directory: resolve(args.directory),