From a3246d6efbd12ae6fd97b36ade140ab2da3d7567 Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Sun, 31 May 2026 21:29:28 -0400 Subject: [PATCH 1/6] feat(release): add P2 distribution channels --- .dockerignore | 2 + .github/workflows/ci-pythinker-cli.yml | 8 + .github/workflows/docker.yml | 283 ++++++++++++++++++ .github/workflows/scoop-bucket.yml | 149 +++++++++ .github/workflows/update-flake-lock.yml | 33 ++ .github/workflows/winget.yml | 52 ++++ Dockerfile | 34 +++ README.md | 3 + flake.nix | 12 +- packages/scoop-bucket/generate-manifest.py | 141 +++++++++ .../scoop-bucket/pythinker-code.json.tmpl | 29 ++ tests/test_scoop_manifest.py | 68 +++++ 12 files changed, 813 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/docker.yml create mode 100644 .github/workflows/scoop-bucket.yml create mode 100644 .github/workflows/update-flake-lock.yml create mode 100644 .github/workflows/winget.yml create mode 100644 Dockerfile create mode 100644 packages/scoop-bucket/generate-manifest.py create mode 100644 packages/scoop-bucket/pythinker-code.json.tmpl create mode 100644 tests/test_scoop_manifest.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..5d0f124f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +* +!Dockerfile diff --git a/.github/workflows/ci-pythinker-cli.yml b/.github/workflows/ci-pythinker-cli.yml index d57deb8d..d9e032d2 100644 --- a/.github/workflows/ci-pythinker-cli.yml +++ b/.github/workflows/ci-pythinker-cli.yml @@ -334,3 +334,11 @@ jobs: - name: Run nix package run: nix run .#pythinker-code -- --version && nix run . -- --help + + - name: Run nix app (apps.default) and assert PYTHINKER_MANAGED + run: | + set -euo pipefail + nix run .#default -- --version + nix build .#default + grep -q 'PYTHINKER_MANAGED' result/bin/pythinker + echo "apps.default runs and the wrapper sets PYTHINKER_MANAGED" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 00000000..1efefbce --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,283 @@ +name: Docker (GHCR) + +on: + push: + tags: + - "v+([0-9]).+([0-9]).+([0-9])" + workflow_dispatch: + inputs: + version: + description: "Version to (re)build (e.g. 0.27.0)" + required: true + type: string + +permissions: + contents: read + packages: write + +env: + IMAGE_NAME: ghcr.io/techmatrix-labs/pythinker-code + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +# One run per tag/ref; never cancel a tag/dispatch run because each one may +# publish digests or manifests for a release artifact. +concurrency: + group: docker-${{ github.ref }} + cancel-in-progress: false + +jobs: + resolve: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.ver.outputs.version }} + steps: + - name: Resolve version + id: ver + env: + GITHUB_REF: ${{ github.ref }} + INPUT_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + if [[ "$GITHUB_REF" =~ ^refs/tags/v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + version="${BASH_REMATCH[1]}" + elif [[ -n "${INPUT_VERSION:-}" ]]; then + version="$INPUT_VERSION" + else + echo "::error::No version source available" >&2 + exit 1 + fi + echo "version=${version}" >> "$GITHUB_OUTPUT" + + # A tag-triggered Docker run can outrun release-pythinker-cli.yml's + # publish-python job. Wait for the PyPI JSON endpoint before buildx tries + # to pip-install the pinned wheel. + - name: Wait for the wheel on PyPI + env: + PKG_VERSION: ${{ steps.ver.outputs.version }} + run: | + set -euo pipefail + deadline=$(( $(date +%s) + 30 * 60 )) + url="https://pypi.org/pypi/pythinker-code/${PKG_VERSION}/json" + until [ "$(curl -s -o /dev/null -w '%{http_code}' "$url")" = "200" ]; do + if [ "$(date +%s)" -gt "$deadline" ]; then + echo "::error::pythinker-code==${PKG_VERSION} not on PyPI within 30 minutes" >&2 + exit 1 + fi + echo "pythinker-code==${PKG_VERSION} not on PyPI yet; sleeping 30s" + sleep 30 + done + echo "pythinker-code==${PKG_VERSION} is live on PyPI" + + build-amd64: + needs: resolve + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # pinned from v3 + + - name: Log in to GHCR + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # pinned from v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Push amd64 by digest + id: push + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # pinned from v6 + with: + context: . + file: Dockerfile + platforms: linux/amd64 + build-args: | + PYTHINKER_VERSION=${{ needs.resolve.outputs.version }} + labels: | + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.version=${{ needs.resolve.outputs.version }} + outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=docker-amd64 + cache-to: type=gha,mode=max,scope=docker-amd64 + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.push.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pinned from v7.0.1 + with: + name: digest-amd64 + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + build-arm64: + needs: resolve + runs-on: ubuntu-24.04-arm + timeout-minutes: 45 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # pinned from v3 + + - name: Log in to GHCR + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # pinned from v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Push arm64 by digest + id: push + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # pinned from v6 + with: + context: . + file: Dockerfile + platforms: linux/arm64 + build-args: | + PYTHINKER_VERSION=${{ needs.resolve.outputs.version }} + labels: | + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.version=${{ needs.resolve.outputs.version }} + outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=docker-arm64 + cache-to: type=gha,mode=max,scope=docker-arm64 + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.push.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pinned from v7.0.1 + with: + name: digest-arm64 + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + needs: [resolve, build-amd64, build-arm64] + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Download digests + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # pinned from v8.0.1 + with: + path: /tmp/digests + pattern: digest-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # pinned from v3 + + - name: Log in to GHCR + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # pinned from v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create version manifest and push + working-directory: /tmp/digests + env: + IMAGE_NAME: ${{ env.IMAGE_NAME }} + TAG: ${{ needs.resolve.outputs.version }} + run: | + set -euo pipefail + args=() + for digest_file in *; do + args+=("${IMAGE_NAME}@sha256:${digest_file}") + done + docker buildx imagetools create -t "${IMAGE_NAME}:${TAG}" "${args[@]}" + docker buildx imagetools inspect "${IMAGE_NAME}:${TAG}" + + move-latest: + needs: [resolve, merge] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + packages: write + concurrency: + group: docker-move-latest + cancel-in-progress: false + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2 + with: + fetch-depth: 0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # pinned from v3 + + - name: Log in to GHCR + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # pinned from v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # :latest intentionally does not auto-advance at tag push time while the + # release is still prerelease. After promote-release.yml flips the release + # to non-prerelease, a maintainer re-dispatches this workflow with the + # promoted version to move :latest. + - name: Decide whether to move :latest + id: gate + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.resolve.outputs.version }} + IMAGE_NAME: ${{ env.IMAGE_NAME }} + run: | + set -euo pipefail + is_pre=$(gh release view "v${VERSION}" \ + --repo "${GITHUB_REPOSITORY}" --json isPrerelease -q '.isPrerelease' 2>/dev/null || echo "true") + if [ "$is_pre" != "false" ]; then + echo "Release v${VERSION} is still prerelease (or missing); not advancing :latest." + echo "move=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + image_json=$(docker buildx imagetools inspect "${IMAGE_NAME}:latest" \ + --format '{{ json (index .Image "linux/amd64") }}' 2>/dev/null || true) + if [ -z "${image_json}" ]; then + echo "move=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + current_sha=$(printf '%s' "${image_json}" | jq -r '.config.Labels."org.opencontainers.image.revision" // ""') + if [ -z "${current_sha}" ] || [ "${current_sha}" = "${GITHUB_SHA}" ]; then + echo "move=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then + git fetch --no-tags --prune origin "+refs/heads/main:refs/remotes/origin/main" || true + fi + if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then + echo "Registry :latest points at an unknown commit; refusing to overwrite." + echo "move=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + if git merge-base --is-ancestor "${current_sha}" "${GITHUB_SHA}"; then + echo "move=true" >> "$GITHUB_OUTPUT" + else + echo "Existing :latest is newer (likely a backport); leaving it alone." + echo "move=false" >> "$GITHUB_OUTPUT" + fi + + - name: Move :latest + if: steps.gate.outputs.move == 'true' + env: + IMAGE_NAME: ${{ env.IMAGE_NAME }} + VERSION: ${{ needs.resolve.outputs.version }} + run: | + set -euo pipefail + docker buildx imagetools create --tag "${IMAGE_NAME}:latest" "${IMAGE_NAME}:${VERSION}" + docker buildx imagetools inspect "${IMAGE_NAME}:latest" diff --git a/.github/workflows/scoop-bucket.yml b/.github/workflows/scoop-bucket.yml new file mode 100644 index 00000000..6d2a5689 --- /dev/null +++ b/.github/workflows/scoop-bucket.yml @@ -0,0 +1,149 @@ +name: Update Scoop bucket + +on: + push: + tags: + - "v+([0-9]).+([0-9]).+([0-9])" + workflow_dispatch: + inputs: + version: + description: "Version to push to the Scoop bucket (e.g. 0.27.0)" + required: true + type: string + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + update: + runs-on: ubuntu-latest + permissions: + contents: read + env: + BUCKET_OWNER: TechMatrix-labs + BUCKET_REPO: scoop-pythinker + steps: + - name: Checkout source repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2 + + - name: Resolve version + id: ver + env: + GITHUB_REF: ${{ github.ref }} + INPUT_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + if [[ "$GITHUB_REF" =~ ^refs/tags/v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + version="${BASH_REMATCH[1]}" + elif [[ -n "${INPUT_VERSION:-}" ]]; then + version="$INPUT_VERSION" + else + echo "::error::No version source available" >&2 + exit 1 + fi + echo "version=${version}" >> "$GITHUB_OUTPUT" + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # pinned from v6.2.0 + with: + python-version: "3.13" + + - name: Generate Scoop manifest + env: + PKG_VERSION: ${{ steps.ver.outputs.version }} + run: | + set -euxo pipefail + mkdir -p out/bucket + deadline=$(( $(date +%s) + 30 * 60 )) + until python packages/scoop-bucket/generate-manifest.py \ + --version "$PKG_VERSION" \ + --template packages/scoop-bucket/pythinker-code.json.tmpl \ + --output out/bucket/pythinker-code.json; do + if [ "$(date +%s)" -gt "$deadline" ]; then + echo "::error::Windows onedir zip for ${PKG_VERSION} was not ready within 30 minutes" >&2 + exit 1 + fi + echo "Windows zip not ready for ${PKG_VERSION}; sleeping 30s" + sleep 30 + done + echo "--- generated manifest ---" + cat out/bucket/pythinker-code.json + + - name: Verify Scoop App credentials are configured + env: + APP_ID: ${{ secrets.SCOOP_BUCKET_APP_ID }} + APP_PRIVATE_KEY: ${{ secrets.SCOOP_BUCKET_APP_PRIVATE_KEY }} + run: | + set -euo pipefail + if [ -z "${APP_ID:-}" ] || [ -z "${APP_PRIVATE_KEY:-}" ]; then + echo "::error::No Scoop GitHub App credentials available. Configure SCOOP_BUCKET_APP_ID and SCOOP_BUCKET_APP_PRIVATE_KEY, and install the App on ${BUCKET_OWNER}/${BUCKET_REPO} with Contents: Read and write." >&2 + exit 1 + fi + + # Mint a short-lived installation token for the org-owned + # pythinker-scoop-publisher App (Contents: Read and write on + # scoop-pythinker only). This workflow runs in pythinker-code and pushes + # cross-repo, so the App token is the authorization boundary being tested. + - name: Mint GitHub App token for the bucket repo + id: app-token + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # pinned from v2.2.2 + with: + app-id: ${{ secrets.SCOOP_BUCKET_APP_ID }} + private-key: ${{ secrets.SCOOP_BUCKET_APP_PRIVATE_KEY }} + owner: ${{ env.BUCKET_OWNER }} + repositories: ${{ env.BUCKET_REPO }} + permission-contents: write + + - name: Sync manifest into bucket repo (handles empty repo on first run) + env: + PKG_VERSION: ${{ steps.ver.outputs.version }} + BUCKET_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -euo pipefail + if [ -z "${BUCKET_TOKEN:-}" ]; then + echo "::error::No bucket token available — the GitHub App token mint produced an empty value. Confirm SCOOP_BUCKET_APP_ID and SCOOP_BUCKET_APP_PRIVATE_KEY are set and the App is installed on ${BUCKET_OWNER}/${BUCKET_REPO} with Contents: Read and write, then re-run." >&2 + exit 1 + fi + rm -rf bucket-repo + mkdir bucket-repo + cd bucket-repo + git init -q -b main + git remote add origin \ + "https://x-access-token:${BUCKET_TOKEN}@github.com/${BUCKET_OWNER}/${BUCKET_REPO}.git" + if git ls-remote --exit-code --heads origin main >/dev/null 2>&1; then + git fetch --depth 1 origin main + git reset --hard FETCH_HEAD + fi + + mkdir -p bucket + cp ../out/bucket/pythinker-code.json bucket/pythinker-code.json + + if [ ! -f README.md ]; then + cat > README.md <<'EOF' + # scoop-pythinker + + Scoop bucket for [Pythinker Code](https://github.com/TechMatrix-labs/pythinker-code). + + ```pwsh + scoop bucket add pythinker https://github.com/TechMatrix-labs/scoop-pythinker + scoop install pythinker-code + ``` + + This bucket is auto-updated by the + [scoop-bucket.yml](https://github.com/TechMatrix-labs/pythinker-code/blob/main/.github/workflows/scoop-bucket.yml) + workflow on every semver release tag. Do not hand-edit `bucket/*` — your + edits will be overwritten on the next release. + EOF + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git add bucket/pythinker-code.json README.md + if git diff --cached --quiet; then + echo "Manifest already up to date for ${PKG_VERSION}; nothing to push." + exit 0 + fi + git commit -m "pythinker-code ${PKG_VERSION}" \ + -m "Auto-updated by pythinker-code/.github/workflows/scoop-bucket.yml" + git push -u origin HEAD:main diff --git a/.github/workflows/update-flake-lock.yml b/.github/workflows/update-flake-lock.yml new file mode 100644 index 00000000..d39f5fc8 --- /dev/null +++ b/.github/workflows/update-flake-lock.yml @@ -0,0 +1,33 @@ +name: Update flake.lock + +on: + schedule: + # 06:00 UTC on the 1st of each month. + - cron: "0 6 1 * *" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + update-lock: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@1d87d45818068401a10cf16bdc5f00b24994a83f # pinned from main + + - name: Update flake.lock and open PR + uses: DeterminateSystems/update-flake-lock@5ba4a20ae344a5edd7b97ed3002219974f4d20d7 # pinned from main + with: + pr-title: "chore(nix): monthly flake.lock update" + pr-labels: dependencies + # NOTE: this PR is opened with the default GITHUB_TOKEN, which does + # not trigger required status checks under branch protection. A + # maintainer must push an empty commit or close+reopen the PR to fire + # CI before merge. Use a fine-grained PAT/App token in a follow-up if + # fully hands-off dependency PRs become necessary. + branch: update-flake-lock diff --git a/.github/workflows/winget.yml b/.github/workflows/winget.yml new file mode 100644 index 00000000..5f904ffb --- /dev/null +++ b/.github/workflows/winget.yml @@ -0,0 +1,52 @@ +name: Submit to WinGet + +on: + workflow_dispatch: + inputs: + version: + description: "Released version to submit to winget-pkgs (e.g. 0.27.0)" + required: true + type: string + +permissions: + contents: read + +jobs: + submit: + # WinGet manifests can only be submitted from Windows (wingetcreate is a + # Windows tool). Manual-only by design: a human runs this after a release is + # promoted, so it never gates promote and never auto-fires on a tag. + runs-on: windows-latest + steps: + - name: Verify the release is published and non-prerelease + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + is_pre=$(gh release view "v${VERSION}" --repo "${GITHUB_REPOSITORY}" --json isPrerelease -q '.isPrerelease') + if [ "$is_pre" != "false" ]; then + echo "::error::Release v${VERSION} is not a promoted (non-prerelease) release; refusing to submit to WinGet." >&2 + exit 1 + fi + + - name: Submit manifest update with wingetcreate + shell: pwsh + env: + WINGET_TOKEN: ${{ secrets.WINGET_SUBMIT_TOKEN }} + VERSION: ${{ inputs.version }} + run: | + $ErrorActionPreference = "Stop" + if (-not $env:WINGET_TOKEN) { + throw "WINGET_SUBMIT_TOKEN is not configured" + } + $installerUrl = "https://github.com/TechMatrix-labs/pythinker-code/releases/download/v$env:VERSION/PythinkerSetup-$env:VERSION.exe" + Invoke-WebRequest -Uri "https://aka.ms/wingetcreate/latest" -OutFile wingetcreate.exe + # PackageIdentifier must match the existing winget-pkgs entry; create it + # once manually via `wingetcreate new` before the first automated update. + .\wingetcreate.exe update TechMatrixLabs.PythinkerCode ` + --version $env:VERSION ` + --urls "$installerUrl" ` + --submit ` + --token $env:WINGET_TOKEN diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..0c7da8a5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# syntax=docker/dockerfile:1 +# Thin Pythinker Code image: installs the published wheel from PyPI so the +# container ships the exact same artifact users get from `pip install`. No +# source build, no new Python runtime deps. The version is pinned at build time +# by docker.yml after the wheel is confirmed live on PyPI. +FROM python:3.14-slim + +# Build-time pin. docker.yml passes --build-arg PYTHINKER_VERSION=. +ARG PYTHINKER_VERSION +RUN test -n "$PYTHINKER_VERSION" || (echo "PYTHINKER_VERSION build-arg is required" >&2; exit 1) + +# ripgrep is the external binary the agent shells out to; git/ca-certificates +# keep common repository and HTTPS workflows usable inside the image. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ripgrep git ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Some already-published wheels on this release line include immutable +# dependency metadata that the current source tree has since corrected. The +# image still needs to rebuild those promoted releases from PyPI, so use pip's +# legacy resolver and keep protobuf under opentelemetry-proto's declared bound. +RUN printf 'protobuf<7\n' >/tmp/constraints.txt \ + && pip install --no-cache-dir --root-user-action=ignore \ + --constraint /tmp/constraints.txt \ + --use-deprecated=legacy-resolver \ + "pythinker-code==${PYTHINKER_VERSION}" \ + && rm /tmp/constraints.txt + +# Channel marker: the in-app updater reads this and prints a docker-native hint +# instead of trying to pip-upgrade inside an immutable image. +ENV PYTHINKER_MANAGED=docker + +ENTRYPOINT ["pythinker"] +CMD ["--help"] diff --git a/README.md b/README.md index c422953e..7aa33a1a 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,9 @@ matches your OS — no Python, Node, or `uv` prerequisite. | **🪟 Windows** | `irm https://pythinker.com/install.ps1 \| iex` | `PythinkerSetup-0.27.0.exe` from [Releases](https://github.com/TechMatrix-labs/pythinker-code/releases/latest) | | **macOS / Linux** | `curl -fsSL https://pythinker.com/install.sh \| bash` | native tarball from [Releases](https://github.com/TechMatrix-labs/pythinker-code/releases/latest) | | **macOS — Homebrew** | `brew install TechMatrix-labs/pythinker/pythinker-code` | auto-published Homebrew tap | +| **🐳 Docker** | `docker run --rm -it ghcr.io/techmatrix-labs/pythinker-code` | GHCR multi-arch image | +| **🪟 Windows — Scoop** | `scoop bucket add pythinker https://github.com/TechMatrix-labs/scoop-pythinker && scoop install pythinker-code` | auto-published Scoop bucket | +| **❄️ Nix** | `nix run github:TechMatrix-labs/pythinker-code` | flake `apps.default` | | **Linux — system package** | Download the `.deb` or `.rpm` for your distro below | [Releases](https://github.com/TechMatrix-labs/pythinker-code/releases/latest) | | **🐍 Python fallback** | `pip install pythinker-code` | PyPI | diff --git a/flake.nix b/flake.nix index b675ba01..dd9a555a 100644 --- a/flake.nix +++ b/flake.nix @@ -102,7 +102,8 @@ mkdir -p $out/bin makeWrapper ${pythinkerCodePackage}/bin/pythinker $out/bin/pythinker \ --prefix PATH : ${lib.makeBinPath [ ripgrep ]} \ - --set PYTHINKER_CLI_NO_AUTO_UPDATE "1" + --set PYTHINKER_CLI_NO_AUTO_UPDATE "1" \ + --set PYTHINKER_MANAGED "nix" runHook postInstall ''; @@ -130,5 +131,14 @@ } ); formatter = forAllSystems ({ pkgs, ... }: pkgs.nixfmt-tree); + apps = forAllSystems ( + { system, ... }: + { + default = { + type = "app"; + program = "${self.packages.${system}.default}/bin/pythinker"; + }; + } + ); }; } diff --git a/packages/scoop-bucket/generate-manifest.py b/packages/scoop-bucket/generate-manifest.py new file mode 100644 index 00000000..35281860 --- /dev/null +++ b/packages/scoop-bucket/generate-manifest.py @@ -0,0 +1,141 @@ +"""Generate the Scoop manifest for pythinker-code from GitHub Releases. + +Runs in scoop-bucket.yml after the Windows onedir zip is attached to the +Pythinker GitHub Release. Points at the existing +pythinker-{version}-x86_64-pc-windows-msvc-onedir.zip asset produced by +release-pythinker-cli.yml; it does not enumerate macOS/Linux native targets. + +Usage: + python generate-manifest.py \ + --version 0.27.0 \ + --template packages/scoop-bucket/pythinker-code.json.tmpl \ + --output bucket/pythinker-code.json +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +import urllib.request +from pathlib import Path +from typing import Any + +GITHUB_REPO = "TechMatrix-labs/pythinker-code" +GITHUB_RELEASE_API = f"https://api.github.com/repos/{GITHUB_REPO}/releases/tags/v{{version}}" + + +def windows_zip_asset_name(version: str) -> str: + """Exact Windows onedir zip name from release-pythinker-cli.yml.""" + return f"pythinker-{version}-x86_64-pc-windows-msvc-onedir.zip" + + +def _fetch_json(url: str) -> dict[str, Any]: + request = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"}) + with urllib.request.urlopen(request, timeout=30) as resp: + data = json.load(resp) + if not isinstance(data, dict): + raise RuntimeError(f"unexpected JSON payload from {url}") + return data + + +def _fetch_text(url: str) -> str: + with urllib.request.urlopen(url, timeout=30) as resp: + return resp.read().decode("utf-8", errors="replace") + + +def _parse_sha256_text(text: str) -> str | None: + match = re.search(r"(?i)\b([a-f0-9]{64})\b", text) + return match.group(1).lower() if match else None + + +def _asset_digest_sha256(asset: dict[str, Any]) -> str | None: + digest = asset.get("digest") + if not isinstance(digest, str) or not digest.startswith("sha256:"): + return None + sha = digest[len("sha256:") :].lower() + return sha if re.fullmatch(r"[a-f0-9]{64}", sha) else None + + +def fetch_release_assets(version: str) -> dict[str, dict[str, Any]]: + release = _fetch_json(GITHUB_RELEASE_API.format(version=version)) + tag_name = release.get("tag_name") + if tag_name != f"v{version}": + raise RuntimeError(f"release tag mismatch: expected v{version}, got {tag_name!r}") + + assets: dict[str, dict[str, Any]] = {} + for asset in release.get("assets", []): + if not isinstance(asset, dict): + continue + name = asset.get("name") + if isinstance(name, str): + assets[name] = asset + return assets + + +def _asset_url_and_sha(assets: dict[str, dict[str, Any]], asset_name: str) -> tuple[str, str]: + asset = assets.get(asset_name) + if asset is None: + raise RuntimeError(f"release asset missing: {asset_name}") + + url = asset.get("browser_download_url") + if not isinstance(url, str) or not url: + raise RuntimeError(f"release asset {asset_name} has no browser_download_url") + + sha = _asset_digest_sha256(asset) + if sha is not None: + return url, sha + + sha_asset = assets.get(asset_name + ".sha256") + if sha_asset is None: + raise RuntimeError(f"release asset checksum missing: {asset_name}.sha256") + sha_url = sha_asset.get("browser_download_url") + if not isinstance(sha_url, str) or not sha_url: + raise RuntimeError(f"release asset checksum {asset_name}.sha256 has no download URL") + sha = _parse_sha256_text(_fetch_text(sha_url)) + if sha is None: + raise RuntimeError(f"could not parse SHA-256 for {asset_name}") + return url, sha + + +def manifest_replacements(version: str, assets: dict[str, dict[str, Any]]) -> dict[str, str]: + url, sha = _asset_url_and_sha(assets, windows_zip_asset_name(version)) + return {"__VERSION__": version, "__URL__": url, "__SHA256__": sha} + + +def render_manifest(template: str, replacements: dict[str, str]) -> str: + manifest = template + for placeholder, value in replacements.items(): + manifest = manifest.replace(placeholder, value) + leftovers = sorted(set(re.findall(r"__[A-Z0-9_]+__", manifest))) + if leftovers: + raise RuntimeError(f"unresolved template placeholders: {', '.join(leftovers)}") + json.loads(manifest) + return manifest + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--version", required=True) + ap.add_argument("--template", type=Path, required=True) + ap.add_argument("--output", type=Path, required=True) + args = ap.parse_args() + + assets = fetch_release_assets(args.version) + replacements = manifest_replacements(args.version, assets) + manifest = render_manifest(args.template.read_text(encoding="utf-8"), replacements) + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(manifest, encoding="utf-8") + + digest = hashlib.sha256(manifest.encode("utf-8")).hexdigest() + print(f"manifest written to {args.output}") + print(f"version : {args.version}") + print(f"manifest sha: {digest}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packages/scoop-bucket/pythinker-code.json.tmpl b/packages/scoop-bucket/pythinker-code.json.tmpl new file mode 100644 index 00000000..5f544b33 --- /dev/null +++ b/packages/scoop-bucket/pythinker-code.json.tmpl @@ -0,0 +1,29 @@ +{ + "version": "__VERSION__", + "description": "Pythinker Code is your next CLI agent.", + "homepage": "https://pythinker.com", + "license": "Apache-2.0", + "architecture": { + "64bit": { + "url": "__URL__", + "hash": "__SHA256__" + } + }, + "bin": "pythinker\\pythinker.exe", + "env_set": { + "PYTHINKER_MANAGED": "scoop" + }, + "checkver": { + "github": "https://github.com/TechMatrix-labs/pythinker-code" + }, + "autoupdate": { + "architecture": { + "64bit": { + "url": "https://github.com/TechMatrix-labs/pythinker-code/releases/download/v$version/pythinker-$version-x86_64-pc-windows-msvc-onedir.zip" + } + }, + "hash": { + "url": "$url.sha256" + } + } +} diff --git a/tests/test_scoop_manifest.py b/tests/test_scoop_manifest.py new file mode 100644 index 00000000..06ba3597 --- /dev/null +++ b/tests/test_scoop_manifest.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path +from types import ModuleType + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +GENERATOR = ROOT / "packages" / "scoop-bucket" / "generate-manifest.py" +TEMPLATE = ROOT / "packages" / "scoop-bucket" / "pythinker-code.json.tmpl" + + +def load_generator() -> ModuleType: + spec = importlib.util.spec_from_file_location("scoop_generate_manifest", GENERATOR) + assert spec is not None + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _fake_assets(generator: ModuleType, version: str) -> dict[str, dict[str, str]]: + name = generator.windows_zip_asset_name(version) + return { + name: { + "browser_download_url": f"https://example.invalid/{name}", + "digest": "sha256:" + ("a" * 64), + } + } + + +def test_scoop_manifest_renders_windows_zip() -> None: + generator = load_generator() + version = "1.2.3" + assets = _fake_assets(generator, version) + + manifest_text = generator.render_manifest( + TEMPLATE.read_text(encoding="utf-8"), generator.manifest_replacements(version, assets) + ) + manifest = json.loads(manifest_text) + + assert manifest["version"] == "1.2.3" + assert ( + manifest["architecture"]["64bit"]["url"] + == "https://example.invalid/pythinker-1.2.3-x86_64-pc-windows-msvc-onedir.zip" + ) + assert manifest["architecture"]["64bit"]["hash"] == "a" * 64 + assert manifest["bin"] == "pythinker\\pythinker.exe" + assert manifest["env_set"]["PYTHINKER_MANAGED"] == "scoop" + + +def test_scoop_manifest_fails_when_asset_missing() -> None: + generator = load_generator() + with pytest.raises(RuntimeError, match="release asset missing"): + generator.manifest_replacements("1.2.3", {}) + + +def test_windows_zip_asset_name_matches_release_workflow() -> None: + generator = load_generator() + # Exact shape produced by release-pythinker-cli.yml's onedir packaging step. + assert ( + generator.windows_zip_asset_name("0.27.0") + == "pythinker-0.27.0-x86_64-pc-windows-msvc-onedir.zip" + ) From 3fb5983021a7e7c2447af89ecdb6dc55e0bc9b16 Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Sun, 31 May 2026 21:33:02 -0400 Subject: [PATCH 2/6] chore(gitignore): ignore graphify understand cache --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 568bb108..4507a64e 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ src/pythinker_code/vis/static/ # Graphify generated graph, cache, wiki, and Obsidian vault outputs graphify-out/ graphify-out*/ +src/pythinker_code/.understand-anything/ .graphify_*.json .graphify_*.txt tests_ai/report.json From 17feca71516824c8f3fcfa5ea4e4fcd1f3e5f60b Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Sun, 31 May 2026 21:49:49 -0400 Subject: [PATCH 3/6] docs(changelog): note P2 distribution channels --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c2ac53a..e7e9dfe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **Broadened distribution channels for releases.** Releases now include best-effort Docker/GHCR, Scoop, Nix, and manual WinGet distribution plumbing with channel-native update markers where the installer format supports them. - **Release preparation now uses a version single source of truth.** `scripts/release.py` rewrites derived release files from `pyproject.toml`, verifies version lockstep on every PR, enforces the frozen `pythinker-review==0.1.0` pin, and managed-channel installs now show channel-native update guidance instead of trying to self-update. - CI and release workflows now run on Node.js 24-backed GitHub Actions, pin action revisions to immutable commits, and preflight optional website/tap GitHub App credentials with clear errors or notices instead of opaque token failures. - Release pipeline: migrate the pythinker-home website-sync dispatch to the org-owned `pythinker-release-bot` GitHub App and fail loud on an empty token; retire the dead pythinker-core API-docs gh-pages publish step; add exponential backoff to the native install scripts and fix the Windows installer's release-pagination cliff. From 637b7ee3ce0c7722b29e8aa02618c5132ae2a0ad Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Sun, 31 May 2026 22:01:27 -0400 Subject: [PATCH 4/6] ci(release): harden P2 distribution workflows --- .github/workflows/docker.yml | 10 ++++++++++ .github/workflows/winget.yml | 8 +++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 1efefbce..9911a476 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -246,6 +246,16 @@ jobs: exit 0 fi + # Guard against manually re-dispatching an older promoted release: Docker + # :latest must follow GitHub /releases/latest, which excludes drafts and + # prereleases. The commit-label ancestor check below is only defense in depth. + latest_tag=$(gh api "/repos/${GITHUB_REPOSITORY}/releases/latest" --jq '.tag_name' 2>/dev/null || true) + if [ "$latest_tag" != "v${VERSION}" ]; then + echo "GitHub /releases/latest is ${latest_tag:-unknown}, not v${VERSION}; not advancing :latest." + echo "move=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + image_json=$(docker buildx imagetools inspect "${IMAGE_NAME}:latest" \ --format '{{ json (index .Image "linux/amd64") }}' 2>/dev/null || true) if [ -z "${image_json}" ]; then diff --git a/.github/workflows/winget.yml b/.github/workflows/winget.yml index 5f904ffb..278d4eb4 100644 --- a/.github/workflows/winget.yml +++ b/.github/workflows/winget.yml @@ -42,7 +42,13 @@ jobs: throw "WINGET_SUBMIT_TOKEN is not configured" } $installerUrl = "https://github.com/TechMatrix-labs/pythinker-code/releases/download/v$env:VERSION/PythinkerSetup-$env:VERSION.exe" - Invoke-WebRequest -Uri "https://aka.ms/wingetcreate/latest" -OutFile wingetcreate.exe + $wingetCreateUrl = "https://github.com/microsoft/winget-create/releases/download/v1.12.8.0/wingetcreate.exe" + $wingetCreateSha256 = "8BD738851B524885410112678E3771B341C5C716DE60FBBECB88AB0A363ED85D" + Invoke-WebRequest -Uri $wingetCreateUrl -OutFile wingetcreate.exe + $actualHash = (Get-FileHash -Algorithm SHA256 wingetcreate.exe).Hash + if ($actualHash -ne $wingetCreateSha256) { + throw "wingetcreate.exe SHA-256 mismatch: expected $wingetCreateSha256, got $actualHash" + } # PackageIdentifier must match the existing winget-pkgs entry; create it # once manually via `wingetcreate new` before the first automated update. .\wingetcreate.exe update TechMatrixLabs.PythinkerCode ` From 2d1417f0e6f52877ae1994e60c781f78eb0661af Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Sun, 31 May 2026 22:11:56 -0400 Subject: [PATCH 5/6] ci(release): address P2 review findings --- .github/workflows/ci-pythinker-cli.yml | 2 +- .github/workflows/docker.yml | 25 +++++++++++++++++----- .github/workflows/scoop-bucket.yml | 11 ++++++++-- .github/workflows/update-flake-lock.yml | 12 ++++++++--- .github/workflows/winget.yml | 4 ++++ Dockerfile | 5 +++++ packages/scoop-bucket/generate-manifest.py | 15 +++++++++++-- 7 files changed, 61 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci-pythinker-cli.yml b/.github/workflows/ci-pythinker-cli.yml index d9e032d2..365dec32 100644 --- a/.github/workflows/ci-pythinker-cli.yml +++ b/.github/workflows/ci-pythinker-cli.yml @@ -340,5 +340,5 @@ jobs: set -euo pipefail nix run .#default -- --version nix build .#default - grep -q 'PYTHINKER_MANAGED' result/bin/pythinker + grep -q 'PYTHINKER_MANAGED.*"nix"' result/bin/pythinker echo "apps.default runs and the wrapper sets PYTHINKER_MANAGED" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 9911a476..2a7bd7ae 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -13,7 +13,6 @@ on: permissions: contents: read - packages: write env: IMAGE_NAME: ghcr.io/techmatrix-labs/pythinker-code @@ -58,19 +57,29 @@ jobs: set -euo pipefail deadline=$(( $(date +%s) + 30 * 60 )) url="https://pypi.org/pypi/pythinker-code/${PKG_VERSION}/json" - until [ "$(curl -s -o /dev/null -w '%{http_code}' "$url")" = "200" ]; do + while true; do + if json=$(curl --fail --silent --show-error --connect-timeout 10 --max-time 30 "$url"); then + if printf '%s' "$json" | python3 -c 'import json, sys; data = json.load(sys.stdin); sys.exit(0 if any(u.get("packagetype") == "bdist_wheel" for u in data.get("urls", [])) else 1)'; then + echo "pythinker-code==${PKG_VERSION} wheel is live on PyPI" + break + fi + echo "pythinker-code==${PKG_VERSION} exists on PyPI but has no wheel yet; sleeping 30s" + else + echo "pythinker-code==${PKG_VERSION} not on PyPI yet; sleeping 30s" + fi if [ "$(date +%s)" -gt "$deadline" ]; then - echo "::error::pythinker-code==${PKG_VERSION} not on PyPI within 30 minutes" >&2 + echo "::error::pythinker-code==${PKG_VERSION} wheel not on PyPI within 30 minutes" >&2 exit 1 fi - echo "pythinker-code==${PKG_VERSION} not on PyPI yet; sleeping 30s" sleep 30 done - echo "pythinker-code==${PKG_VERSION} is live on PyPI" build-amd64: needs: resolve runs-on: ubuntu-latest + permissions: + contents: read + packages: write timeout-minutes: 45 steps: - name: Checkout repository @@ -119,6 +128,9 @@ jobs: build-arm64: needs: resolve runs-on: ubuntu-24.04-arm + permissions: + contents: read + packages: write timeout-minutes: 45 steps: - name: Checkout repository @@ -167,6 +179,9 @@ jobs: merge: needs: [resolve, build-amd64, build-arm64] runs-on: ubuntu-latest + permissions: + contents: read + packages: write timeout-minutes: 10 steps: - name: Download digests diff --git a/.github/workflows/scoop-bucket.yml b/.github/workflows/scoop-bucket.yml index 6d2a5689..d03b0511 100644 --- a/.github/workflows/scoop-bucket.yml +++ b/.github/workflows/scoop-bucket.yml @@ -14,6 +14,10 @@ on: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" +concurrency: + group: scoop-bucket + cancel-in-progress: false + jobs: update: runs-on: ubuntu-latest @@ -25,6 +29,8 @@ jobs: steps: - name: Checkout source repo uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2 + with: + persist-credentials: false - name: Resolve version id: ver @@ -35,10 +41,10 @@ jobs: set -euo pipefail if [[ "$GITHUB_REF" =~ ^refs/tags/v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then version="${BASH_REMATCH[1]}" - elif [[ -n "${INPUT_VERSION:-}" ]]; then + elif [[ "${INPUT_VERSION:-}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then version="$INPUT_VERSION" else - echo "::error::No version source available" >&2 + echo "::error::Version must match MAJOR.MINOR.PATCH (e.g. 0.27.0)" >&2 exit 1 fi echo "version=${version}" >> "$GITHUB_OUTPUT" @@ -51,6 +57,7 @@ jobs: - name: Generate Scoop manifest env: PKG_VERSION: ${{ steps.ver.outputs.version }} + GITHUB_TOKEN: ${{ github.token }} run: | set -euxo pipefail mkdir -p out/bucket diff --git a/.github/workflows/update-flake-lock.yml b/.github/workflows/update-flake-lock.yml index d39f5fc8..50d5268a 100644 --- a/.github/workflows/update-flake-lock.yml +++ b/.github/workflows/update-flake-lock.yml @@ -6,16 +6,22 @@ on: - cron: "0 6 1 * *" workflow_dispatch: -permissions: - contents: write - pull-requests: write +concurrency: + group: update-flake-lock + cancel-in-progress: false jobs: update-lock: + name: Update Nix flake.lock runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2 + with: + persist-credentials: false - name: Install Nix uses: DeterminateSystems/nix-installer-action@1d87d45818068401a10cf16bdc5f00b24994a83f # pinned from main diff --git a/.github/workflows/winget.yml b/.github/workflows/winget.yml index 278d4eb4..4fce6bf1 100644 --- a/.github/workflows/winget.yml +++ b/.github/workflows/winget.yml @@ -11,6 +11,10 @@ on: permissions: contents: read +concurrency: + group: winget-submit-${{ inputs.version }} + cancel-in-progress: false + jobs: submit: # WinGet manifests can only be submitted from Windows (wingetcreate is a diff --git a/Dockerfile b/Dockerfile index 0c7da8a5..a968fbc5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,6 +22,7 @@ RUN apt-get update \ RUN printf 'protobuf<7\n' >/tmp/constraints.txt \ && pip install --no-cache-dir --root-user-action=ignore \ --constraint /tmp/constraints.txt \ + --only-binary=:all: \ --use-deprecated=legacy-resolver \ "pythinker-code==${PYTHINKER_VERSION}" \ && rm /tmp/constraints.txt @@ -29,6 +30,10 @@ RUN printf 'protobuf<7\n' >/tmp/constraints.txt \ # Channel marker: the in-app updater reads this and prints a docker-native hint # instead of trying to pip-upgrade inside an immutable image. ENV PYTHINKER_MANAGED=docker +ENV HOME=/home/pythinker + +RUN useradd --create-home --shell /usr/sbin/nologin pythinker +USER pythinker ENTRYPOINT ["pythinker"] CMD ["--help"] diff --git a/packages/scoop-bucket/generate-manifest.py b/packages/scoop-bucket/generate-manifest.py index 35281860..b2d3a4c1 100644 --- a/packages/scoop-bucket/generate-manifest.py +++ b/packages/scoop-bucket/generate-manifest.py @@ -17,8 +17,10 @@ import argparse import hashlib import json +import os import re import sys +import urllib.parse import urllib.request from pathlib import Path from typing import Any @@ -32,8 +34,16 @@ def windows_zip_asset_name(version: str) -> str: return f"pythinker-{version}-x86_64-pc-windows-msvc-onedir.zip" +def _github_headers(url: str, *, accept: str = "application/vnd.github+json") -> dict[str, str]: + headers = {"Accept": accept, "User-Agent": "pythinker-scoop-manifest-generator"} + token = os.environ.get("GITHUB_TOKEN") + if token and urllib.parse.urlparse(url).netloc == "api.github.com": + headers["Authorization"] = f"Bearer {token}" + return headers + + def _fetch_json(url: str) -> dict[str, Any]: - request = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"}) + request = urllib.request.Request(url, headers=_github_headers(url)) with urllib.request.urlopen(request, timeout=30) as resp: data = json.load(resp) if not isinstance(data, dict): @@ -42,7 +52,8 @@ def _fetch_json(url: str) -> dict[str, Any]: def _fetch_text(url: str) -> str: - with urllib.request.urlopen(url, timeout=30) as resp: + request = urllib.request.Request(url, headers=_github_headers(url, accept="text/plain")) + with urllib.request.urlopen(request, timeout=30) as resp: return resp.read().decode("utf-8", errors="replace") From 7249629fad24bdbb708f7523caa09bb3d550cf14 Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Sun, 31 May 2026 22:21:02 -0400 Subject: [PATCH 6/6] ci(nix): relax managed wrapper assertion --- .github/workflows/ci-pythinker-cli.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-pythinker-cli.yml b/.github/workflows/ci-pythinker-cli.yml index 365dec32..82a2d247 100644 --- a/.github/workflows/ci-pythinker-cli.yml +++ b/.github/workflows/ci-pythinker-cli.yml @@ -340,5 +340,5 @@ jobs: set -euo pipefail nix run .#default -- --version nix build .#default - grep -q 'PYTHINKER_MANAGED.*"nix"' result/bin/pythinker - echo "apps.default runs and the wrapper sets PYTHINKER_MANAGED" + grep -Eq 'PYTHINKER_MANAGED.*nix' result/bin/pythinker + echo "apps.default runs and the wrapper sets PYTHINKER_MANAGED=nix"