From 2ff0f201ee61a6d986eb333532731d8e7efcf553 Mon Sep 17 00:00:00 2001 From: Antoine Zambelli Date: Mon, 17 Aug 2026 00:43:31 -0500 Subject: [PATCH 1/4] fix proxy release artifact pipeline --- .github/workflows/proxy-release-candidate.yml | 154 +++++++++--- .github/workflows/proxy-release.yml | 224 ++++-------------- CHANGELOG.md | 19 ++ installer/proxy-stable.txt | 2 +- packaging/standalone/README.md | 47 ++-- packaging/standalone/linux/Dockerfile | 4 +- pyproject.toml | 2 +- scripts/standalone/release.py | 74 +++++- tests/unit/test_proxy_release.py | 65 ++++- tests/unit/test_proxy_release_workflows.py | 130 +++++----- 10 files changed, 419 insertions(+), 302 deletions(-) diff --git a/.github/workflows/proxy-release-candidate.yml b/.github/workflows/proxy-release-candidate.yml index 11fd426..911eb82 100644 --- a/.github/workflows/proxy-release-candidate.yml +++ b/.github/workflows/proxy-release-candidate.yml @@ -10,28 +10,15 @@ permissions: contents: read jobs: - native: - name: Proxy ${{ matrix.target }} - strategy: - fail-fast: false - matrix: - include: - - target: windows-x86_64 - runner: windows-2022 - artifact: standalone-dist/windows-x86_64/onefile/forge-proxy.exe - - target: linux-x86_64-gnu - runner: ubuntu-22.04 - artifact: standalone-dist/linux-x86_64-gnu/onefile/forge-proxy - - target: macos-arm64 - runner: macos-14 - artifact: standalone-dist/macos-arm64/onefile/forge-proxy - runs-on: ${{ matrix.runner }} + windows: + name: Proxy windows-x86_64 + runs-on: windows-2022 steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: '3.14' - - run: python -m pip install '.[anthropic]' pyinstaller pytest + - run: python -m pip install '.[anthropic]' pytest - name: Verify Proxy and Forge versions agree run: >- python -c "from pathlib import Path; from scripts.standalone.release import project_version; @@ -43,10 +30,9 @@ jobs: run: >- python -m pytest -m integration tests/integration/bootstrap_contract -v --tb=short - - name: Build once and run packaged smoke - run: python -m scripts.standalone.build --target ${{ matrix.target }} --form all + - name: Build through the Windows release entrypoint + run: .\scripts\standalone\build_windows.ps1 - name: Run Windows installer acceptance - if: runner.os == 'Windows' run: >- python -m pytest -m acceptance tests/integration/platform_acceptance -v --tb=short @@ -54,41 +40,147 @@ jobs: shell: bash run: | version=$(python -c "from scripts.standalone.release import project_version; print(project_version())") - digest=$(python -c "from pathlib import Path; from scripts.standalone.release import sha256; print(sha256(Path(r'${{ matrix.artifact }}')))") - python -m scripts.standalone.lifecycle_smoke '${{ matrix.artifact }}' --version "$version" --sha256 "$digest" --target '${{ matrix.target }}' --output 'standalone-dist/${{ matrix.target }}/lifecycle.json' + digest=$(python -c "from pathlib import Path; from scripts.standalone.release import sha256; print(sha256(Path(r'standalone-dist/windows-x86_64/onefile/forge-proxy.exe')))") + python -m scripts.standalone.lifecycle_smoke 'standalone-dist/windows-x86_64/onefile/forge-proxy.exe' --version "$version" --sha256 "$digest" --target windows-x86_64 --output 'standalone-dist/windows-x86_64/lifecycle.json' - name: Record selected immutable bytes and portable evidence shell: bash run: | - python -m scripts.standalone.release record --artifact '${{ matrix.artifact }}' --target '${{ matrix.target }}' --output 'release-input/${{ matrix.target }}' --evidence 'standalone-dist/${{ matrix.target }}/onefile/evidence.json' --evidence 'standalone-dist/${{ matrix.target }}/lifecycle.json' - python -m scripts.standalone.release verify 'release-input/${{ matrix.target }}' - tar -czf 'proxy-${{ matrix.target }}.tgz' -C release-input '${{ matrix.target }}' + python -m scripts.standalone.release record --artifact 'standalone-dist/windows-x86_64/onefile/forge-proxy.exe' --target windows-x86_64 --output 'release-input/windows-x86_64' --evidence 'standalone-dist/windows-x86_64/onefile/evidence.json' --evidence 'standalone-dist/windows-x86_64/lifecycle.json' + python -m scripts.standalone.release verify 'release-input/windows-x86_64' + tar -czf 'proxy-windows-x86_64.tgz' -C release-input windows-x86_64 - uses: actions/upload-artifact@v4 with: - name: proxy-${{ matrix.target }} - path: proxy-${{ matrix.target }}.tgz + name: proxy-windows-x86_64 + path: proxy-windows-x86_64.tgz + if-no-files-found: error + + macos: + name: Proxy macos-arm64 + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + - run: python -m pip install '.[anthropic]' pytest + - name: Verify Proxy and Forge versions agree + run: >- + python -c "from pathlib import Path; from scripts.standalone.release import project_version; + proxy_version = Path('installer/proxy-stable.txt').read_text(encoding='utf-8').strip(); + forge_version = project_version(); + assert proxy_version == forge_version, + f'Proxy version {proxy_version!r} does not match Forge version {forge_version!r}'" + - name: Run public bootstrap contracts + run: >- + python -m pytest -m integration + tests/integration/bootstrap_contract -v --tb=short + - name: Build through the macOS release entrypoint + run: sh ./scripts/standalone/build_macos.sh + - name: Run selected-artifact lifecycle smoke + shell: bash + run: | + version=$(python -c "from scripts.standalone.release import project_version; print(project_version())") + digest=$(python -c "from pathlib import Path; from scripts.standalone.release import sha256; print(sha256(Path(r'standalone-dist/macos-arm64/onefile/forge-proxy')))") + python -m scripts.standalone.lifecycle_smoke 'standalone-dist/macos-arm64/onefile/forge-proxy' --version "$version" --sha256 "$digest" --target macos-arm64 --output 'standalone-dist/macos-arm64/lifecycle.json' + - name: Record selected immutable bytes and portable evidence + shell: bash + run: | + python -m scripts.standalone.release record --artifact 'standalone-dist/macos-arm64/onefile/forge-proxy' --target macos-arm64 --output 'release-input/macos-arm64' --evidence 'standalone-dist/macos-arm64/onefile/evidence.json' --evidence 'standalone-dist/macos-arm64/lifecycle.json' + python -m scripts.standalone.release verify 'release-input/macos-arm64' + tar -czf 'proxy-macos-arm64.tgz' -C release-input macos-arm64 + - uses: actions/upload-artifact@v4 + with: + name: proxy-macos-arm64 + path: proxy-macos-arm64.tgz + if-no-files-found: error + + linux: + name: Proxy linux-x86_64-gnu + needs: [windows, macos] + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + - run: python -m pip install '.[anthropic]' pytest + - name: Verify Proxy and Forge versions agree + run: >- + python -c "from pathlib import Path; from scripts.standalone.release import project_version; + proxy_version = Path('installer/proxy-stable.txt').read_text(encoding='utf-8').strip(); + forge_version = project_version(); + assert proxy_version == forge_version, + f'Proxy version {proxy_version!r} does not match Forge version {forge_version!r}'" + - name: Run public bootstrap contracts + run: >- + python -m pytest -m integration + tests/integration/bootstrap_contract -v --tb=short + - name: Build through the Linux release entrypoint + shell: bash + run: | + docker build --file packaging/standalone/linux/Dockerfile --tag forge-proxy-linux-builder . + container_id=$(docker create forge-proxy-linux-builder) + docker start --attach "$container_id" + mkdir -p standalone-dist + docker cp "${container_id}:/forge/standalone-dist/." standalone-dist/ + docker rm "$container_id" + - name: Run selected-artifact lifecycle smoke + shell: bash + run: | + version=$(python -c "from scripts.standalone.release import project_version; print(project_version())") + digest=$(python -c "from pathlib import Path; from scripts.standalone.release import sha256; print(sha256(Path(r'standalone-dist/linux-x86_64-gnu/onefile/forge-proxy')))") + python -m scripts.standalone.lifecycle_smoke 'standalone-dist/linux-x86_64-gnu/onefile/forge-proxy' --version "$version" --sha256 "$digest" --target linux-x86_64-gnu --output 'standalone-dist/linux-x86_64-gnu/lifecycle.json' + - name: Record selected immutable bytes and portable evidence + shell: bash + run: | + python -m scripts.standalone.release record --artifact 'standalone-dist/linux-x86_64-gnu/onefile/forge-proxy' --target linux-x86_64-gnu --output 'release-input/linux-x86_64-gnu' --evidence 'standalone-dist/linux-x86_64-gnu/onefile/evidence.json' --evidence 'standalone-dist/linux-x86_64-gnu/lifecycle.json' + python -m scripts.standalone.release verify 'release-input/linux-x86_64-gnu' + tar -czf 'proxy-linux-x86_64-gnu.tgz' -C release-input linux-x86_64-gnu + - uses: actions/upload-artifact@v4 + with: + name: proxy-linux-x86_64-gnu + path: proxy-linux-x86_64-gnu.tgz if-no-files-found: error - name: Exercise identical Linux bytes on Ubuntu 22.04 - if: runner.os == 'Linux' shell: bash run: | mkdir -p linux-evidence/ubuntu-22.04 docker run --rm -v "$PWD:/work" -w /work ubuntu:22.04 bash -lc 'apt-get update && apt-get install -y python3 ca-certificates curl && python3 -m scripts.standalone.release verify release-input/linux-x86_64-gnu && version=$(python3 -c "import json; print(json.load(open(\"release-input/linux-x86_64-gnu/selection.json\"))[\"version\"])") && digest=$(python3 -c "import json; print(json.load(open(\"release-input/linux-x86_64-gnu/selection.json\"))[\"sha256\"])") && cat /etc/os-release > /work/linux-evidence/ubuntu-22.04/os-release.txt && ldd --version > /work/linux-evidence/ubuntu-22.04/glibc.txt 2>&1 && sha256sum release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu > /work/linux-evidence/ubuntu-22.04/sha256.txt && python3 -m scripts.standalone.smoke release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu --form onefile --expected-version "$version" > /work/linux-evidence/ubuntu-22.04/packaged-smoke.json && python3 -m scripts.standalone.lifecycle_smoke release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu --version "$version" --sha256 "$digest" --target linux-x86_64-gnu --output /work/linux-evidence/ubuntu-22.04/lifecycle.json' - name: Exercise identical Linux bytes on Debian 12 - if: runner.os == 'Linux' shell: bash run: | mkdir -p linux-evidence/debian-12 docker run --rm -v "$PWD:/work" -w /work debian:12 bash -lc 'apt-get update && apt-get install -y python3 ca-certificates curl && python3 -m scripts.standalone.release verify release-input/linux-x86_64-gnu && version=$(python3 -c "import json; print(json.load(open(\"release-input/linux-x86_64-gnu/selection.json\"))[\"version\"])") && digest=$(python3 -c "import json; print(json.load(open(\"release-input/linux-x86_64-gnu/selection.json\"))[\"sha256\"])") && cat /etc/os-release > /work/linux-evidence/debian-12/os-release.txt && ldd --version > /work/linux-evidence/debian-12/glibc.txt 2>&1 && sha256sum release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu > /work/linux-evidence/debian-12/sha256.txt && python3 -m scripts.standalone.smoke release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu --form onefile --expected-version "$version" > /work/linux-evidence/debian-12/packaged-smoke.json && python3 -m scripts.standalone.lifecycle_smoke release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu --version "$version" --sha256 "$digest" --target linux-x86_64-gnu --output /work/linux-evidence/debian-12/lifecycle.json' - name: Exercise identical Linux bytes on Fedora 44 - if: runner.os == 'Linux' shell: bash run: | mkdir -p linux-evidence/fedora-44 docker run --rm -v "$PWD:/work" -w /work fedora:44 bash -lc 'dnf install -y python3 ca-certificates curl && python3 -m scripts.standalone.release verify release-input/linux-x86_64-gnu && version=$(python3 -c "import json; print(json.load(open(\"release-input/linux-x86_64-gnu/selection.json\"))[\"version\"])") && digest=$(python3 -c "import json; print(json.load(open(\"release-input/linux-x86_64-gnu/selection.json\"))[\"sha256\"])") && cat /etc/os-release > /work/linux-evidence/fedora-44/os-release.txt && ldd --version > /work/linux-evidence/fedora-44/glibc.txt 2>&1 && sha256sum release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu > /work/linux-evidence/fedora-44/sha256.txt && python3 -m scripts.standalone.smoke release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu --form onefile --expected-version "$version" > /work/linux-evidence/fedora-44/packaged-smoke.json && python3 -m scripts.standalone.lifecycle_smoke release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu --version "$version" --sha256 "$digest" --target linux-x86_64-gnu --output /work/linux-evidence/fedora-44/lifecycle.json' - name: Upload Linux runtime evidence - if: runner.os == 'Linux' uses: actions/upload-artifact@v4 with: name: linux-runtime-evidence path: linux-evidence if-no-files-found: error + - uses: actions/download-artifact@v4 + with: + pattern: proxy-* + path: downloads + - name: Assemble the exact tested publication bundle + shell: bash + run: | + mkdir inputs + find downloads -name '*.tgz' -print0 | while IFS= read -r -d '' archive; do tar -xzf "$archive" -C inputs; done + for target in windows-x86_64 linux-x86_64-gnu macos-arm64; do python -m scripts.standalone.release verify "inputs/$target"; done + python -m scripts.standalone.release assemble --input inputs/windows-x86_64 --input inputs/linux-x86_64-gnu --input inputs/macos-arm64 --output publication + python -m scripts.standalone.release verify-staging publication + source_tree=$(git rev-parse 'HEAD^{tree}') + python -m scripts.standalone.release record-candidate --source-tree "$source_tree" --output candidate-identity.json + tar -czf proxy-publication.tgz publication + - uses: actions/upload-artifact@v4 + with: + name: proxy-release-candidate + path: | + candidate-identity.json + proxy-publication.tgz + if-no-files-found: error diff --git a/.github/workflows/proxy-release.yml b/.github/workflows/proxy-release.yml index 09a0be5..e38ac53 100644 --- a/.github/workflows/proxy-release.yml +++ b/.github/workflows/proxy-release.yml @@ -1,4 +1,4 @@ -name: Publish exact-tag Proxy release +name: Publish tested Proxy release candidate on: workflow_dispatch: @@ -7,12 +7,17 @@ on: description: Existing exact Forge tag and GitHub Release (vX.Y.Z) required: true type: string + candidate_run_id: + description: Successful Proxy release candidate workflow run + required: true + type: string concurrency: group: proxy-release-${{ inputs.tag }} cancel-in-progress: false permissions: + actions: read contents: read jobs: @@ -21,147 +26,58 @@ jobs: outputs: version: ${{ steps.identity.outputs.version }} commit: ${{ steps.identity.outputs.commit }} + source_tree: ${{ steps.identity.outputs.source_tree }} steps: - uses: actions/checkout@v4 with: ref: ${{ inputs.tag }} fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + - uses: actions/download-artifact@v4 + with: + name: proxy-release-candidate + path: candidate + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ inputs.candidate_run_id }} - id: identity env: GH_TOKEN: ${{ github.token }} TAG: ${{ inputs.tag }} + CANDIDATE_RUN_ID: ${{ inputs.candidate_run_id }} DISPATCH_REF: ${{ github.ref }} - DISPATCH_SHA: ${{ github.sha }} REPOSITORY: ${{ github.repository }} + shell: bash run: | - version=$(python3 -c "from scripts.standalone.release import project_version; print(project_version())") + version=$(python -c "from scripts.standalone.release import project_version; print(project_version())") test "$TAG" = "v$version" - test "$DISPATCH_REF" = "refs/tags/$TAG" + test "$DISPATCH_REF" = "refs/heads/main" checkout=$(git rev-parse HEAD) peeled=$(git rev-parse "$TAG^{commit}") + source_tree=$(git rev-parse 'HEAD^{tree}') test "$checkout" = "$peeled" - test "$DISPATCH_SHA" = "$peeled" release=$(gh api "repos/$REPOSITORY/releases/tags/$TAG") test "$(printf '%s' "$release" | jq -r .tag_name)" = "$TAG" + candidate_run=$(gh api "repos/$REPOSITORY/actions/runs/$CANDIDATE_RUN_ID") + test "$(printf '%s' "$candidate_run" | jq -r .conclusion)" = "success" + test "$(printf '%s' "$candidate_run" | jq -r .event)" = "pull_request" + test "$(printf '%s' "$candidate_run" | jq -r .path)" = ".github/workflows/proxy-release-candidate.yml" + python -m scripts.standalone.release verify-candidate candidate/candidate-identity.json --version "$version" --source-tree "$source_tree" + tar -xzf candidate/proxy-publication.tgz + python -m scripts.standalone.release verify-staging publication printf 'Release target_commitish (informational only): %s\n' "$(printf '%s' "$release" | jq -r .target_commitish)" echo "version=$version" >> "$GITHUB_OUTPUT" echo "commit=$peeled" >> "$GITHUB_OUTPUT" - - native: - needs: identity - name: Tag bytes / ${{ matrix.target }} - strategy: - fail-fast: false - matrix: - include: - - target: windows-x86_64 - runner: windows-2022 - artifact: standalone-dist/windows-x86_64/onefile/forge-proxy.exe - - target: linux-x86_64-gnu - runner: ubuntu-22.04 - artifact: standalone-dist/linux-x86_64-gnu/onefile/forge-proxy - - target: macos-arm64 - runner: macos-14 - artifact: standalone-dist/macos-arm64/onefile/forge-proxy - runs-on: ${{ matrix.runner }} - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ inputs.tag }} - - uses: actions/setup-python@v5 - with: - python-version: '3.14' - - run: python -m pip install '.[anthropic]' pyinstaller pytest - - name: Build once and run packaged smoke - run: python -m scripts.standalone.build --target ${{ matrix.target }} --form all - - name: Run Windows installer acceptance - if: runner.os == 'Windows' - run: >- - python -m pytest -m acceptance - tests/integration/platform_acceptance -v --tb=short - - name: Run lifecycle on the selected bytes - shell: bash - run: | - digest=$(python -c "from pathlib import Path; from scripts.standalone.release import sha256; print(sha256(Path(r'${{ matrix.artifact }}')))") - python -m scripts.standalone.lifecycle_smoke '${{ matrix.artifact }}' --version '${{ needs.identity.outputs.version }}' --sha256 "$digest" --target '${{ matrix.target }}' --output 'standalone-dist/${{ matrix.target }}/lifecycle.json' - - name: Archive tested exact bytes with digest evidence - shell: bash - run: | - python -m scripts.standalone.release record --artifact '${{ matrix.artifact }}' --target '${{ matrix.target }}' --output 'release-input/${{ matrix.target }}' --evidence 'standalone-dist/${{ matrix.target }}/onefile/evidence.json' --evidence 'standalone-dist/${{ matrix.target }}/lifecycle.json' - python -m scripts.standalone.release verify 'release-input/${{ matrix.target }}' - tar -czf 'proxy-${{ matrix.target }}.tgz' -C release-input '${{ matrix.target }}' - - uses: actions/upload-artifact@v4 - with: - name: tag-proxy-${{ matrix.target }} - path: proxy-${{ matrix.target }}.tgz - if-no-files-found: error - - linux_compat: - needs: [identity, native] - name: Tag Linux bytes / ${{ matrix.name }} - runs-on: ubuntu-22.04 - strategy: - fail-fast: false - matrix: - include: - - name: ubuntu-22.04 - image: ubuntu:22.04 - setup: apt-get update && apt-get install -y python3 ca-certificates curl - - name: debian-12 - image: debian:12 - setup: apt-get update && apt-get install -y python3 ca-certificates curl - - name: fedora-44 - image: fedora:44 - setup: dnf install -y python3 ca-certificates curl - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ inputs.tag }} - - uses: actions/download-artifact@v4 - with: - name: tag-proxy-linux-x86_64-gnu - - name: Verify download and execute identical Linux bytes - run: | - tar -xzf proxy-linux-x86_64-gnu.tgz - python3 -m scripts.standalone.release verify release-input/linux-x86_64-gnu - mkdir -p 'linux-evidence/${{ matrix.name }}' - docker run --rm -v "$PWD:/work" -w /work '${{ matrix.image }}' bash -lc '${{ matrix.setup }} && python3 -m scripts.standalone.release verify release-input/linux-x86_64-gnu && cat /etc/os-release > /work/linux-evidence/${{ matrix.name }}/os-release.txt && ldd --version > /work/linux-evidence/${{ matrix.name }}/glibc.txt 2>&1 && sha256sum release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu > /work/linux-evidence/${{ matrix.name }}/sha256.txt && python3 -m scripts.standalone.smoke release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu --form onefile --expected-version "${{ needs.identity.outputs.version }}" > /work/linux-evidence/${{ matrix.name }}/packaged-smoke.json && digest=$(python3 -c "import json; print(json.load(open(\"release-input/linux-x86_64-gnu/selection.json\"))[\"sha256\"])") && python3 -m scripts.standalone.lifecycle_smoke release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu --version "${{ needs.identity.outputs.version }}" --sha256 "$digest" --target linux-x86_64-gnu --output /work/linux-evidence/${{ matrix.name }}/lifecycle.json' - - uses: actions/upload-artifact@v4 - with: - name: tag-linux-runtime-${{ matrix.name }} - path: linux-evidence/${{ matrix.name }} - if-no-files-found: error - - staging: - needs: [identity, native, linux_compat] - runs-on: ubuntu-22.04 - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ inputs.tag }} - - uses: actions/download-artifact@v4 - with: - pattern: tag-proxy-* - path: downloads - - name: Re-hash every download and assemble one immutable complete set - run: | - mkdir inputs - find downloads -name '*.tgz' -print0 | while IFS= read -r -d '' archive; do tar -xzf "$archive" -C inputs; done - for target in windows-x86_64 linux-x86_64-gnu macos-arm64; do python3 -m scripts.standalone.release verify "inputs/$target"; done - python3 -m scripts.standalone.release assemble --input inputs/windows-x86_64 --input inputs/linux-x86_64-gnu --input inputs/macos-arm64 --output publication - python3 -m scripts.standalone.release verify-staging publication - tar -czf proxy-publication.tgz publication - - uses: actions/upload-artifact@v4 - with: - name: immutable-proxy-publication - path: proxy-publication.tgz - if-no-files-found: error + echo "source_tree=$source_tree" >> "$GITHUB_OUTPUT" publish: - needs: [identity, staging] + needs: identity runs-on: ubuntu-22.04 environment: proxy-release permissions: + actions: read contents: write id-token: write attestations: write @@ -170,21 +86,31 @@ jobs: with: ref: ${{ inputs.tag }} fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: '3.14' - uses: actions/download-artifact@v4 with: - name: immutable-proxy-publication - - name: Re-hash the immutable staged set and recheck exact identity + name: proxy-release-candidate + path: candidate + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ inputs.candidate_run_id }} + - name: Recheck the retained candidate and exact tag identity env: GH_TOKEN: ${{ github.token }} TAG: ${{ inputs.tag }} REPOSITORY: ${{ github.repository }} + shell: bash run: | - tar -xzf proxy-publication.tgz - python3 -m scripts.standalone.release verify-staging publication test "$(git rev-parse "$TAG^{commit}")" = '${{ needs.identity.outputs.commit }}' test "$(git rev-parse HEAD)" = '${{ needs.identity.outputs.commit }}' - test "$(python3 -c "from scripts.standalone.release import project_version; print('v' + project_version())")" = "$TAG" + test "$(git rev-parse 'HEAD^{tree}')" = '${{ needs.identity.outputs.source_tree }}' + test "$(python -c "from scripts.standalone.release import project_version; print('v' + project_version())")" = "$TAG" test "$(gh api "repos/$REPOSITORY/releases/tags/$TAG" --jq .tag_name)" = "$TAG" + python -m scripts.standalone.release verify-candidate candidate/candidate-identity.json --version '${{ needs.identity.outputs.version }}' --source-tree '${{ needs.identity.outputs.source_tree }}' + tar -xzf candidate/proxy-publication.tgz + python -m scripts.standalone.release verify-staging publication - name: Attest all three platform artifacts uses: actions/attest-build-provenance@v2 with: @@ -192,59 +118,7 @@ jobs: publication/forge-proxy-windows-x86_64.exe publication/forge-proxy-linux-x86_64-gnu publication/forge-proxy-macos-arm64 - - name: Publish with journaled rollback and manifest last + - name: Publish retained candidate with manifest last env: GH_TOKEN: ${{ github.token }} - run: python3 -m scripts.standalone.release publish --repository '${{ github.repository }}' --tag '${{ inputs.tag }}' --peeled-commit '${{ needs.identity.outputs.commit }}' --expected-commit '${{ github.sha }}' publication - - exact_install: - needs: [identity, publish] - name: Published exact install / ${{ matrix.target }} - strategy: - fail-fast: false - matrix: - include: - - target: windows-x86_64 - runner: windows-2022 - - target: linux-x86_64-gnu - runner: ubuntu-22.04 - - target: macos-arm64 - runner: macos-14 - runs-on: ${{ matrix.runner }} - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ inputs.tag }} - - name: Exact install, initialize, check, and uninstall on Windows - if: runner.os == 'Windows' - shell: pwsh - run: | - $root = Join-Path $env:RUNNER_TEMP 'proxy exact install' - $env:APPDATA = Join-Path $env:RUNNER_TEMP 'proxy-appdata' - $env:LOCALAPPDATA = Join-Path $env:RUNNER_TEMP 'proxy-localappdata' - $env:FORGE_PROXY_PATH_FILE = Join-Path $env:RUNNER_TEMP 'proxy-path.txt' - Set-Content -NoNewline $env:FORGE_PROXY_PATH_FILE 'existing-path' - .\install.ps1 -Version '${{ needs.identity.outputs.version }}' -NoInit -InstallRoot $root - $proxy = Join-Path $root 'bin\forge-proxy.cmd' - & $proxy init --non-interactive --force --backend-url 'http://127.0.0.1:1' - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - & $proxy check - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - & $proxy uninstall - $deadline = (Get-Date).AddSeconds(20) - while ((Test-Path $root) -and (Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 100 } - if (Test-Path $root) { throw 'exact installation remained after uninstall' } - - name: Exact install, initialize, check, and uninstall on POSIX - if: runner.os != 'Windows' - shell: bash - run: | - root="$RUNNER_TEMP/proxy exact install" - export HOME="$RUNNER_TEMP/proxy-home" - export XDG_CONFIG_HOME="$HOME/.config" - mkdir -p "$HOME" - sh install.sh --version '${{ needs.identity.outputs.version }}' --no-init --install-root "$root" - "$root/bin/forge-proxy" init --non-interactive --force --backend-url 'http://127.0.0.1:1' - "$root/bin/forge-proxy" check - "$root/bin/forge-proxy" uninstall - for attempt in $(seq 1 200); do test ! -e "$root" && break; sleep 0.1; done - test ! -e "$root" + run: python -m scripts.standalone.release publish --repository '${{ github.repository }}' --tag '${{ inputs.tag }}' --peeled-commit '${{ needs.identity.outputs.commit }}' --expected-commit '${{ needs.identity.outputs.commit }}' publication diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a90921..91e0608 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to forge are documented here. +## [0.9.2] — 2026-08-17 + +A packaging-only maintenance release completing the standalone Forge Proxy +distribution introduced in 0.9.1. Forge’s forwarding, routing, backend, and +guardrail behavior is unchanged. + +### Fixed + +- **Platform release builds are exercised before publication.** Proxy release + pull requests build Windows x64, Linux x64/glibc, and macOS ARM64 artifacts + through their documented platform entrypoints, then retain the exact tested + bytes as one release candidate. +- **Linux artifacts use the declared compatibility build.** The published Linux + executable is produced by the Ubuntu 22.04/Python 3.14 Docker build and those + same bytes are exercised on Ubuntu 22.04, Debian 12, and Fedora. +- **Proxy publication no longer rebuilds release artifacts.** Post-merge + publication verifies the retained candidate against the release tag and + uploads it unchanged, with direct asset identities available for rollback. + ## [0.9.1] — 2026-08-16 A distribution and evaluation maintenance release. Forge 0.9.1 adds a diff --git a/installer/proxy-stable.txt b/installer/proxy-stable.txt index f374f66..2003b63 100644 --- a/installer/proxy-stable.txt +++ b/installer/proxy-stable.txt @@ -1 +1 @@ -0.9.1 +0.9.2 diff --git a/packaging/standalone/README.md b/packaging/standalone/README.md index 558775e..ec20f19 100644 --- a/packaging/standalone/README.md +++ b/packaging/standalone/README.md @@ -1,6 +1,6 @@ # Standalone Forge Proxy builds -All three targets use `forge_proxy.spec` through the Python 3.12 build driver. +All three targets use `forge_proxy.spec` through the Python 3.14 build driver. The driver rejects non-native target requests, builds and fully smokes `onedir` before allowing `onefile`, and writes artifact-derived `evidence.json` beside each generated payload under the ignored `standalone-dist/` directory. A fully @@ -37,7 +37,7 @@ failing if any referenced GLIBC symbol exceeds 2.35. ## macOS arm64 -On an arm64 Mac with Python 3.12: +On an arm64 Mac with Python 3.14: ```sh ./scripts/standalone/build_macos.sh @@ -54,29 +54,30 @@ publish release assets, tags, or remote state. Changing `installer/proxy-stable.txt` in a pull request declares that the Forge release is also a Proxy release and triggers `proxy-release-candidate.yml`. The workflow exposes exactly three jobs: Windows x64, Linux x64, and macOS. -Each job runs its public bootstrap contracts, builds the native artifact, and -exercises packaged smoke plus the isolated +Each job runs its public bootstrap contracts, builds through the documented +platform release entrypoint, and exercises packaged smoke plus the isolated install/init/check/same-version-repair/uninstall lifecycle. The Linux job also -executes the same Ubuntu-built bytes sequentially on Ubuntu 22.04, Debian 12, -and Fedora 44. An ordinary Forge release leaves the pointer unchanged and does -not run Proxy CI. +extracts the Docker-built artifact and executes those same bytes sequentially +on Ubuntu 22.04, Debian 12, and Fedora 44. After all three jobs pass, the Linux +job assembles their exact tested bytes, checksums, and manifest into one +retained release-candidate artifact. An ordinary Forge release leaves the +pointer unchanged and does not run Proxy CI. The Proxy pointer and `pyproject.toml` must contain the same version in a Proxy release pull request. Permission-preserving archives carry the selected byte, its SHA-256 identity, size, version, and the portable cold-start, extraction/layout, dependency/GLIBC, packaged-smoke, and lifecycle evidence. -`proxy-release.yml` must be manually dispatched from an existing exact -`refs/tags/vX.Y.Z` whose version matches `pyproject.toml` and whose GitHub -Release already exists. It rebuilds no selected byte after testing. The three -native outputs and all Linux compatibility results gate one immutable staging -job. One environment-gated publication job re-hashes that staging archive, -adds free GitHub build-provenance attestations for the three executables, and -uploads to the existing exact Release. The checksum file precedes the manifest; -`proxy-X.Y.Z.json` is uploaded last as the completeness marker. Publication -rejects existing Proxy names and rolls back only assets journaled by that run. -The Release's `target_commitish` is recorded for information, not used as tag -identity. +`proxy-release.yml` is manually dispatched from protected `main` with an +existing exact `vX.Y.Z` Forge tag, its GitHub Release, and the successful +candidate workflow run ID. It verifies that the tag has the same source tree as +the retained candidate, re-hashes that candidate, adds free GitHub +build-provenance attestations for the three executables, and uploads those exact +bytes without rebuilding or repeating lifecycle tests. The checksum file +precedes the manifest; `proxy-X.Y.Z.json` is uploaded last as the completeness +marker. Publication rejects existing Proxy names and rolls back only assets +journaled by that run. The Release's `target_commitish` is recorded for +information, not used as tag identity. ## Mould-owned human release handoff @@ -84,11 +85,13 @@ The combined procedure remains outside Forge and is performed in this order: 1. In the release pull request, bump `pyproject.toml` and `installer/proxy-stable.txt` to the same version. -2. Require all three Proxy release-candidate jobs to pass. +2. Require all three Proxy release-candidate jobs to pass and retain that run + ID. 3. Follow the existing Forge PyPI release recipe. -4. Dispatch the exact-tag Proxy workflow from that same Forge tag. -5. Require complete manifest-last publication and all three published exact - install checks to pass. No later pointer change is required. +4. Dispatch the Proxy publication workflow from protected `main` with the exact + Forge tag and candidate run ID. +5. Require complete manifest-last publication. No later pointer change is + required. An ordinary Forge/PyPI/GitHub release may omit Proxy artifacts by leaving the pointer unchanged. This implementation run does not dispatch workflows, create diff --git a/packaging/standalone/linux/Dockerfile b/packaging/standalone/linux/Dockerfile index 6f6e161..2d25251 100644 --- a/packaging/standalone/linux/Dockerfile +++ b/packaging/standalone/linux/Dockerfile @@ -5,12 +5,12 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends software-properties-common binutils \ && add-apt-repository ppa:deadsnakes/ppa \ && apt-get update \ - && apt-get install -y --no-install-recommends python3.12 python3.12-venv \ + && apt-get install -y --no-install-recommends python3.14 python3.14-venv \ && rm -rf /var/lib/apt/lists/* WORKDIR /forge COPY . /forge -RUN python3.12 -m venv /build-env \ +RUN python3.14 -m venv /build-env \ && /build-env/bin/python -m pip install --upgrade pip \ && /build-env/bin/python -m pip install '/forge[anthropic]' pyinstaller diff --git a/pyproject.toml b/pyproject.toml index 3702d17..4be8709 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "forge-guardrails" -version = "0.9.1" +version = "0.9.2" description = "A reliability layer for self-hosted LLM tool-calling. Guardrails, context management, and backend adapters for multi-step agentic workflows." requires-python = ">=3.12" license = "MIT" diff --git a/scripts/standalone/release.py b/scripts/standalone/release.py index dbf6ff9..1239cc3 100644 --- a/scripts/standalone/release.py +++ b/scripts/standalone/release.py @@ -11,6 +11,7 @@ import subprocess import tempfile import urllib.error +import urllib.parse import urllib.request from collections.abc import Callable, Iterable from pathlib import Path @@ -23,6 +24,7 @@ POINTER = ROOT / "installer" / "proxy-stable.txt" VERSION_RE = re.compile(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)") SHA256_RE = re.compile(r"[0-9a-f]{64}") +TREE_RE = re.compile(r"[0-9a-f]{40}") def project_version(pyproject: Path = ROOT / "pyproject.toml") -> str: @@ -40,6 +42,40 @@ def exact_tag(version: str) -> str: return f"v{version}" +def write_candidate_identity( + output: Path, + source_tree: str, + version: str | None = None, +) -> dict[str, str]: + version = version or project_version() + exact_tag(version) + if TREE_RE.fullmatch(source_tree) is None: + raise ValueError("source tree must be a 40-character Git object ID") + identity = {"version": version, "source_tree": source_tree} + output.write_text( + json.dumps(identity, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + return identity + + +def validate_candidate_identity( + path: Path, + expected_version: str, + expected_source_tree: str, +) -> dict[str, str]: + identity = json.loads(path.read_text(encoding="utf-8")) + if identity != { + "version": expected_version, + "source_tree": expected_source_tree, + }: + raise ValueError("candidate identity does not match the release tag") + exact_tag(identity["version"]) + if TREE_RE.fullmatch(identity["source_tree"]) is None: + raise ValueError("candidate source tree is not a Git object ID") + return identity + + def artifact_name(target: str) -> str: if target not in SUPPORTED_TARGETS: raise ValueError(f"unsupported target: {target}") @@ -246,7 +282,7 @@ def validate_pointer( class ReleaseClient(Protocol): def release(self, tag: str) -> dict[str, Any]: ... def assets(self, release_id: int) -> list[dict[str, Any]]: ... - def upload(self, tag: str, path: Path) -> int: ... + def upload(self, release_id: int, path: Path) -> int: ... def delete(self, asset_id: int) -> None: ... @@ -296,7 +332,7 @@ def publish( entry = next(item for item in manifest["artifacts"].values() if item["name"] == name) if path.stat().st_size != entry["size"] or sha256(path) != entry["sha256"]: raise ValueError(f"artifact identity changed before upload: {name}") - journal.append(client.upload(tag, path)) + journal.append(client.upload(release_id, path)) final_names = {asset["name"] for asset in client.assets(release_id)} if final_names & expected != expected: raise RuntimeError("published Proxy namespace is incomplete") @@ -338,13 +374,22 @@ def release(self, tag: str) -> dict[str, Any]: def assets(self, release_id: int) -> list[dict[str, Any]]: return self._json("api", f"repos/{self.repository}/releases/{release_id}/assets") - def upload(self, tag: str, path: Path) -> int: - subprocess.run( - ["gh", "release", "upload", tag, str(path), "--repo", self.repository], - check=True, + def upload(self, release_id: int, path: Path) -> int: + token = os.environ["GH_TOKEN"] + name = urllib.parse.quote(path.name, safe="") + request = urllib.request.Request( + "https://uploads.github.com/repos/" + f"{self.repository}/releases/{release_id}/assets?name={name}", + data=path.read_bytes(), + method="POST", + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "Content-Type": "application/octet-stream", + }, ) - release_id = int(self.release(tag)["id"]) - return int(next(asset["id"] for asset in self.assets(release_id) if asset["name"] == path.name)) + with urllib.request.urlopen(request, timeout=120) as response: + return int(json.load(response)["id"]) def delete(self, asset_id: int) -> None: subprocess.run( @@ -368,6 +413,13 @@ def main() -> None: assembly.add_argument("--output", type=Path, required=True) staged = commands.add_parser("verify-staging") staged.add_argument("directory", type=Path) + candidate = commands.add_parser("record-candidate") + candidate.add_argument("--source-tree", required=True) + candidate.add_argument("--output", type=Path, required=True) + candidate_check = commands.add_parser("verify-candidate") + candidate_check.add_argument("path", type=Path) + candidate_check.add_argument("--version", required=True) + candidate_check.add_argument("--source-tree", required=True) commands.add_parser("pointer") publication = commands.add_parser("publish") publication.add_argument("--repository", required=True) @@ -389,6 +441,12 @@ def main() -> None: print(assemble(args.input, args.output)) elif args.command == "verify-staging": print(json.dumps(validate_staging(args.directory), sort_keys=True)) + elif args.command == "record-candidate": + print(json.dumps(write_candidate_identity(args.output, args.source_tree), sort_keys=True)) + elif args.command == "verify-candidate": + print(json.dumps(validate_candidate_identity( + args.path, args.version, args.source_tree, + ), sort_keys=True)) elif args.command == "pointer": print(validate_pointer() or "no stable Proxy release") else: diff --git a/tests/unit/test_proxy_release.py b/tests/unit/test_proxy_release.py index f0ef0ea..fc4aa38 100644 --- a/tests/unit/test_proxy_release.py +++ b/tests/unit/test_proxy_release.py @@ -2,6 +2,7 @@ from __future__ import annotations +import io import json from pathlib import Path @@ -11,7 +12,8 @@ from scripts.standalone.inputs import SUPPORTED_TARGETS -VERSION = "0.9.1" +VERSION = "0.9.2" +SOURCE_TREE = "a" * 40 def selections(tmp_path: Path) -> list[Path]: @@ -35,6 +37,33 @@ def test_complete_assembly_is_canonical_and_revalidates(tmp_path: Path) -> None: ] +def test_candidate_identity_binds_version_and_source_tree(tmp_path: Path) -> None: + path = tmp_path / "candidate-identity.json" + assert release.write_candidate_identity(path, SOURCE_TREE, VERSION) == { + "version": VERSION, + "source_tree": SOURCE_TREE, + } + assert release.validate_candidate_identity(path, VERSION, SOURCE_TREE) == { + "version": VERSION, + "source_tree": SOURCE_TREE, + } + + +@pytest.mark.parametrize( + ("version", "source_tree"), + [("0.9.3", SOURCE_TREE), (VERSION, "b" * 40)], +) +def test_candidate_identity_rejects_a_different_tag_tree( + tmp_path: Path, + version: str, + source_tree: str, +) -> None: + path = tmp_path / "candidate-identity.json" + release.write_candidate_identity(path, SOURCE_TREE, VERSION) + with pytest.raises(ValueError, match="does not match"): + release.validate_candidate_identity(path, version, source_tree) + + @pytest.mark.parametrize("failure", ["missing", "duplicate", "version", "name", "size", "digest"]) def test_assembly_rejects_incomplete_or_changed_inputs(tmp_path: Path, failure: str) -> None: inputs = selections(tmp_path) @@ -46,7 +75,7 @@ def test_assembly_rejects_incomplete_or_changed_inputs(tmp_path: Path, failure: record_path = inputs[0] / "selection.json" record = json.loads(record_path.read_text()) if failure == "version": - record["version"] = "0.9.2" + record["version"] = "0.9.3" elif failure == "name": record["name"] = "wrong" elif failure == "size": @@ -134,7 +163,7 @@ def assets(self, _release_id: int) -> list[dict[str, object]]: self.asset_calls += 1 return list(self.current) - def upload(self, _tag: str, path: Path) -> int: + def upload(self, _release_id: int, path: Path) -> int: position = len(self.uploaded) if self.fail_upload == position: raise RuntimeError("upload failed") @@ -162,6 +191,36 @@ def test_publication_ignores_branch_valued_target_commitish_and_uploads_manifest assert client.current[0]["name"] == "forge-wheel.whl" +def test_github_upload_returns_the_created_asset_id_directly( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + artifact = tmp_path / "proxy artifact.bin" + artifact.write_bytes(b"exact bytes") + captured: dict[str, object] = {} + + def fake_urlopen(request: release.urllib.request.Request, timeout: int) -> io.BytesIO: + captured["request"] = request + captured["timeout"] = timeout + return io.BytesIO(b'{"id": 321}') + + monkeypatch.setenv("GH_TOKEN", "release-token") + monkeypatch.setattr(release.urllib.request, "urlopen", fake_urlopen) + + client = release.GhReleaseClient("owner/repo") + assert client.upload(7, artifact) == 321 + request = captured["request"] + assert isinstance(request, release.urllib.request.Request) + assert request.full_url == ( + "https://uploads.github.com/repos/owner/repo/releases/7/assets?" + "name=proxy%20artifact.bin" + ) + assert request.data == b"exact bytes" + assert request.get_header("Authorization") == "Bearer release-token" + assert request.get_header("Content-type") == "application/octet-stream" + assert captured["timeout"] == 120 + + @pytest.mark.parametrize("position", range(5)) def test_each_partial_upload_failure_removes_only_journaled_assets(tmp_path: Path, position: int) -> None: client = FakeClient(fail_upload=position) diff --git a/tests/unit/test_proxy_release_workflows.py b/tests/unit/test_proxy_release_workflows.py index 661ac9f..061ad0a 100644 --- a/tests/unit/test_proxy_release_workflows.py +++ b/tests/unit/test_proxy_release_workflows.py @@ -1,4 +1,4 @@ -"""Structural safety and graph checks for Proxy release workflows.""" +"""Structural contracts for Proxy candidate and publication workflows.""" from __future__ import annotations @@ -19,31 +19,43 @@ def load(name: str) -> tuple[dict, str]: return document, text -def test_candidate_matrix_is_read_only_and_preserves_same_linux_bytes() -> None: +def test_candidate_has_exactly_three_platform_jobs_and_retains_final_bytes() -> None: document, text = load("proxy-release-candidate.yml") trigger = document.get("on", document.get(True)) assert set(trigger) == {"pull_request"} assert trigger["pull_request"]["paths"] == ["installer/proxy-stable.txt"] assert document["permissions"] == {"contents": "read"} - assert set(document["jobs"]) == {"native"} + assert set(document["jobs"]) == {"windows", "macos", "linux"} + assert document["jobs"]["linux"]["needs"] == ["windows", "macos"] assert all("permissions" not in job for job in document["jobs"].values()) - assert { - row["target"] - for row in document["jobs"]["native"]["strategy"]["matrix"]["include"] - } == {"windows-x86_64", "linux-x86_64-gnu", "macos-arm64"} + + for target in ("windows-x86_64", "linux-x86_64-gnu", "macos-arm64"): + assert f"name: proxy-{target}" in text + assert r".\scripts\standalone\build_windows.ps1" in text + assert "sh ./scripts/standalone/build_macos.sh" in text + assert ( + "docker build --file packaging/standalone/linux/Dockerfile " + "--tag forge-proxy-linux-builder ." + ) in text + assert 'docker start --attach "$container_id"' in text + assert ( + 'docker cp "${container_id}:/forge/standalone-dist/." standalone-dist/' + in text + ) + assert "python -m scripts.standalone.build" not in text + assert text.count("python-version: '3.14'") == 3 + assert text.count("tests/integration/bootstrap_contract") == 3 + assert text.count("scripts.standalone.lifecycle_smoke") >= 6 assert "ubuntu:22.04" in text assert "debian:12" in text assert "fedora:44" in text - assert text.count("scripts.standalone.lifecycle_smoke") >= 2 - assert text.count("--target") >= 2 - assert "python3 ca-certificates curl" in text - assert text.count("python-version: '3.14'") == 1 - assert "tar -czf" in text and "release verify" in text - assert "tests/integration/bootstrap_contract" in text - assert "project_version" in text and "installer/proxy-stable.txt" in text assert "linux-runtime-evidence" in text + assert "scripts.standalone.release assemble" in text + assert "scripts.standalone.release verify-staging publication" in text + assert "scripts.standalone.release record-candidate" in text + assert "name: proxy-release-candidate" in text + assert "proxy-publication.tgz" in text assert "real_backends" not in text - assert "aggregate" not in document["jobs"] assert not (WORKFLOWS / "proxy-pointer.yml").exists() @@ -59,63 +71,63 @@ def test_general_ci_has_only_three_always_on_python_suites() -> None: ] -def test_exact_release_has_one_mutation_job_after_every_gate() -> None: +def test_linux_builder_keeps_ubuntu_2204_and_uses_python_314() -> None: + dockerfile = ( + ROOT / "packaging" / "standalone" / "linux" / "Dockerfile" + ).read_text(encoding="utf-8") + assert dockerfile.startswith("FROM ubuntu:22.04\n") + assert "python3.14 python3.14-venv" in dockerfile + assert "RUN python3.14 -m venv /build-env" in dockerfile + assert "python3.12" not in dockerfile + + +def test_publication_only_verifies_and_uploads_a_successful_candidate() -> None: document, text = load("proxy-release.yml") - jobs = document["jobs"] + trigger = document.get("on", document.get(True)) + assert set(trigger) == {"workflow_dispatch"} + assert set(trigger["workflow_dispatch"]["inputs"]) == {"tag", "candidate_run_id"} + assert document["permissions"] == {"actions": "read", "contents": "read"} + assert set(document["jobs"]) == {"identity", "publish"} + assert document["jobs"]["publish"]["needs"] == "identity" + writers = [ name - for name, job in jobs.items() + for name, job in document["jobs"].items() if job.get("permissions", {}).get("contents") == "write" ] assert writers == ["publish"] - assert jobs["publish"]["permissions"] == { + assert document["jobs"]["publish"]["permissions"] == { + "actions": "read", "contents": "write", "id-token": "write", "attestations": "write", } - assert set(jobs["staging"]["needs"]) == {"identity", "native", "linux_compat"} - assert set(jobs["publish"]["needs"]) == {"identity", "staging"} - assert set(jobs["exact_install"]["needs"]) == {"identity", "publish"} assert "environment: proxy-release" in text assert "actions/attest-build-provenance@v2" in text - assert "manifest last" in text.lower() + assert text.count("python-version: '3.14'") == 2 + assert text.count("name: proxy-release-candidate") == 2 + assert text.count("run-id: ${{ inputs.candidate_run_id }}") == 2 + assert "scripts.standalone.build" not in text + assert "scripts.standalone.lifecycle_smoke" not in text + assert "install.sh" not in text and "install.ps1" not in text -def test_exact_identity_and_install_matrices_cover_ruled_targets() -> None: - document, text = load("proxy-release.yml") - jobs = document["jobs"] - ruled = {"windows-x86_64", "linux-x86_64-gnu", "macos-arm64"} - assert { - row["target"] for row in jobs["native"]["strategy"]["matrix"]["include"] - } == ruled - assert { - row["target"] for row in jobs["exact_install"]["strategy"]["matrix"]["include"] - } == ruled - assert "refs/tags/$TAG" in text - assert 'git rev-parse "$TAG^{commit}"' in text - assert "target_commitish (informational only)" in text - assert "release verify-staging" in text - assert "install.sh --version" in text and "install.ps1 -Version" in text - assert text.count("--target") >= 2 - assert text.count("python-version: '3.14'") == 1 - - -def test_windows_exact_install_propagates_native_init_and_check_failures() -> None: +def test_publication_binds_candidate_tree_to_the_exact_release_tag() -> None: _document, text = load("proxy-release.yml") - windows = text.split( - " - name: Exact install, initialize, check, and uninstall on Windows", 1 - )[1].split( - " - name: Exact install, initialize, check, and uninstall on POSIX", 1 - )[0] - exit_check = "if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }" - assert ( - "& $proxy init --non-interactive --force --backend-url " - "'http://127.0.0.1:1'\n " + exit_check - ) in windows - assert "& $proxy check\n " + exit_check in windows - - -def test_release_graph_has_no_forbidden_release_or_pointer_operations() -> None: + assert 'git rev-parse "$TAG^{commit}"' in text + assert "git rev-parse 'HEAD^{tree}'" in text + assert "scripts.standalone.release verify-candidate" in text + assert 'jq -r .conclusion)" = "success"' in text + assert 'jq -r .event)" = "pull_request"' in text + assert ".github/workflows/proxy-release-candidate.yml" in text + assert "refs/tags/$TAG" not in text + assert 'test "$DISPATCH_REF" = "refs/heads/main"' in text + assert "DISPATCH_SHA" not in text + assert "--expected-commit '${{ needs.identity.outputs.commit }}'" in text + assert "--expected-commit '${{ github.sha }}'" not in text + + +def test_release_graph_has_no_build_tag_pointer_or_clobber_operations() -> None: _document, text = load("proxy-release.yml") lowered = text.lower() for forbidden in ( @@ -128,5 +140,5 @@ def test_release_graph_has_no_forbidden_release_or_pointer_operations() -> None: "gpg --sign", ): assert forbidden not in lowered - publish_text = text.split(" publish:", 1)[1].split(" exact_install:", 1)[0] - assert "scripts.standalone.build" not in publish_text + assert "scripts.standalone.release verify-staging publication" in text + assert "manifest last" in text.lower() From 1594a100171e0a8d28e0cf96d8a15b028f538f71 Mon Sep 17 00:00:00 2001 From: Antoine Zambelli Date: Mon, 17 Aug 2026 01:03:40 -0500 Subject: [PATCH 2/4] fix proxy candidate lifecycle checks --- .github/workflows/proxy-release-candidate.yml | 11 ++++- packaging/standalone/README.md | 19 ++++---- scripts/standalone/lifecycle_smoke.py | 26 +++++++++-- tests/unit/test_proxy_lifecycle_smoke.py | 44 ++++++++++++++++++- tests/unit/test_proxy_release_workflows.py | 22 ++++++++-- 5 files changed, 104 insertions(+), 18 deletions(-) diff --git a/.github/workflows/proxy-release-candidate.yml b/.github/workflows/proxy-release-candidate.yml index 911eb82..24eebc4 100644 --- a/.github/workflows/proxy-release-candidate.yml +++ b/.github/workflows/proxy-release-candidate.yml @@ -96,7 +96,6 @@ jobs: linux: name: Proxy linux-x86_64-gnu - needs: [windows, macos] runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 @@ -162,6 +161,16 @@ jobs: name: linux-runtime-evidence path: linux-evidence if-no-files-found: error + + aggregate: + name: Assemble tested Proxy candidate + needs: [windows, macos, linux] + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.14' - uses: actions/download-artifact@v4 with: pattern: proxy-* diff --git a/packaging/standalone/README.md b/packaging/standalone/README.md index ec20f19..c206f8a 100644 --- a/packaging/standalone/README.md +++ b/packaging/standalone/README.md @@ -53,15 +53,16 @@ publish release assets, tags, or remote state. Changing `installer/proxy-stable.txt` in a pull request declares that the Forge release is also a Proxy release and triggers `proxy-release-candidate.yml`. -The workflow exposes exactly three jobs: Windows x64, Linux x64, and macOS. -Each job runs its public bootstrap contracts, builds through the documented -platform release entrypoint, and exercises packaged smoke plus the isolated -install/init/check/same-version-repair/uninstall lifecycle. The Linux job also -extracts the Docker-built artifact and executes those same bytes sequentially -on Ubuntu 22.04, Debian 12, and Fedora 44. After all three jobs pass, the Linux -job assembles their exact tested bytes, checksums, and manifest into one -retained release-candidate artifact. An ordinary Forge release leaves the -pointer unchanged and does not run Proxy CI. +Three independent platform jobs build Windows x64, Linux x64, and macOS ARM64 +through their documented release entrypoints. Each exercises packaged smoke +plus the isolated install/init/check/reinstall/uninstall lifecycle. When an +older standalone Proxy artifact is retrievable, each job additionally tests a +forward update and exact-version reinstall; otherwise only those two +cross-version checks are skipped. The Linux job also extracts the Docker-built +artifact and executes those same bytes sequentially on Ubuntu 22.04, Debian 12, +and Fedora 44. A fourth aggregation-only job combines the three passing outputs +into one retained release-candidate artifact. An ordinary Forge release leaves +the pointer unchanged and does not run Proxy CI. The Proxy pointer and `pyproject.toml` must contain the same version in a Proxy release pull request. Permission-preserving archives carry the selected byte, diff --git a/scripts/standalone/lifecycle_smoke.py b/scripts/standalone/lifecycle_smoke.py index 7d676cc..f40ed00 100644 --- a/scripts/standalone/lifecycle_smoke.py +++ b/scripts/standalone/lifecycle_smoke.py @@ -238,6 +238,26 @@ def resolve_published_baseline( return ReleaseArtifact(path, pointer, entry["sha256"], target) +def retrievable_published_baseline( + target: str, + destination: Path, + *, + pointer_url: str = STABLE_POINTER_URL, + release_base_url: str = RELEASE_BASE_URL, + reader: Callable[..., bytes | None] = _read_url, +) -> ReleaseArtifact | None: + try: + return resolve_published_baseline( + target, + destination, + pointer_url=pointer_url, + release_base_url=release_base_url, + reader=reader, + ) + except Exception: + return None + + def release_manifest(artifact: ReleaseArtifact, *, sha256: str | None = None) -> bytes: document = { "version": artifact.version, @@ -468,7 +488,7 @@ def run_lifecycle( ) ) - baseline = resolve_published_baseline( + baseline = retrievable_published_baseline( target, isolation / "baseline", pointer_url=pointer_url, @@ -609,8 +629,8 @@ def run_lifecycle( else: final_active = candidate baseline_status = ( - "inaugural-release: update/recovery success cases not applicable; " - "missing-artifact failure exercised" + "cross-version checks skipped: no retrievable older Proxy artifact; " + "candidate lifecycle and update failure paths exercised" ) steps.append( diff --git a/tests/unit/test_proxy_lifecycle_smoke.py b/tests/unit/test_proxy_lifecycle_smoke.py index b31e61d..6e1ed23 100644 --- a/tests/unit/test_proxy_lifecycle_smoke.py +++ b/tests/unit/test_proxy_lifecycle_smoke.py @@ -65,7 +65,7 @@ def reader(url: str, *, missing_ok: bool = False) -> bytes | None: return None assert ( - lifecycle_smoke.resolve_published_baseline( + lifecycle_smoke.retrievable_published_baseline( "windows-x86_64", tmp_path, pointer_url="https://fixture.invalid/pointer", @@ -75,6 +75,48 @@ def reader(url: str, *, missing_ok: bool = False) -> bytes | None: ) +def test_unavailable_published_manifest_means_no_cross_version_checks( + tmp_path: Path, +) -> None: + def reader(url: str, *, missing_ok: bool = False) -> bytes | None: + if url == "https://fixture.invalid/pointer": + assert missing_ok is True + return b"1.2.3\n" + raise RuntimeError(f"download unavailable: {url}") + + assert ( + lifecycle_smoke.retrievable_published_baseline( + "windows-x86_64", + tmp_path, + pointer_url="https://fixture.invalid/pointer", + release_base_url="https://fixture.invalid/releases", + reader=reader, + ) + is None + ) + + +def test_invalid_published_manifest_means_no_cross_version_checks( + tmp_path: Path, +) -> None: + def reader(url: str, *, missing_ok: bool = False) -> bytes | None: + if url == "https://fixture.invalid/pointer": + assert missing_ok is True + return b"1.2.3\n" + return b"not a manifest" + + assert ( + lifecycle_smoke.retrievable_published_baseline( + "windows-x86_64", + tmp_path, + pointer_url="https://fixture.invalid/pointer", + release_base_url="https://fixture.invalid/releases", + reader=reader, + ) + is None + ) + + def test_published_baseline_is_manifest_verified(tmp_path: Path) -> None: target = "windows-x86_64" payload = b"published baseline bytes" diff --git a/tests/unit/test_proxy_release_workflows.py b/tests/unit/test_proxy_release_workflows.py index 061ad0a..e4b8497 100644 --- a/tests/unit/test_proxy_release_workflows.py +++ b/tests/unit/test_proxy_release_workflows.py @@ -19,14 +19,28 @@ def load(name: str) -> tuple[dict, str]: return document, text -def test_candidate_has_exactly_three_platform_jobs_and_retains_final_bytes() -> None: +def test_candidate_has_three_platform_jobs_and_one_aggregation_job() -> None: document, text = load("proxy-release-candidate.yml") trigger = document.get("on", document.get(True)) assert set(trigger) == {"pull_request"} assert trigger["pull_request"]["paths"] == ["installer/proxy-stable.txt"] assert document["permissions"] == {"contents": "read"} - assert set(document["jobs"]) == {"windows", "macos", "linux"} - assert document["jobs"]["linux"]["needs"] == ["windows", "macos"] + assert set(document["jobs"]) == {"windows", "macos", "linux", "aggregate"} + assert "needs" not in document["jobs"]["linux"] + assert document["jobs"]["aggregate"]["needs"] == [ + "windows", + "macos", + "linux", + ] + linux_text = str(document["jobs"]["linux"]) + aggregate_text = str(document["jobs"]["aggregate"]) + assert "scripts.standalone.release assemble" not in linux_text + assert "scripts.standalone.release record-candidate" not in linux_text + assert "actions/download-artifact" not in linux_text + assert "scripts.standalone.release assemble" in aggregate_text + assert "scripts.standalone.release record-candidate" in aggregate_text + assert "actions/download-artifact" in aggregate_text + assert "proxy-release-candidate" in aggregate_text assert all("permissions" not in job for job in document["jobs"].values()) for target in ("windows-x86_64", "linux-x86_64-gnu", "macos-arm64"): @@ -43,7 +57,7 @@ def test_candidate_has_exactly_three_platform_jobs_and_retains_final_bytes() -> in text ) assert "python -m scripts.standalone.build" not in text - assert text.count("python-version: '3.14'") == 3 + assert text.count("python-version: '3.14'") == 4 assert text.count("tests/integration/bootstrap_contract") == 3 assert text.count("scripts.standalone.lifecycle_smoke") >= 6 assert "ubuntu:22.04" in text From ac7194499dcec6a5255b6bb344f3a899909dfdd0 Mon Sep 17 00:00:00 2001 From: Antoine Zambelli Date: Mon, 17 Aug 2026 01:24:17 -0500 Subject: [PATCH 3/4] update standalone linux compatibility floor --- .github/workflows/proxy-release-candidate.yml | 22 +++++++++---------- .github/workflows/proxy-release.yml | 4 ++-- CHANGELOG.md | 4 ++-- docs/PROXY_INSTALLATION.md | 2 +- install.sh | 6 ++--- packaging/standalone/README.md | 10 ++++----- packaging/standalone/linux/Dockerfile | 4 ++-- scripts/standalone/evidence.py | 6 ++--- tests/integration/_bootstrap_support.py | 2 +- .../test_bootstrap_contract.py | 14 ++++++------ tests/unit/test_proxy_release_workflows.py | 11 +++++----- tests/unit/test_standalone_build.py | 8 +++---- 12 files changed, 47 insertions(+), 46 deletions(-) diff --git a/.github/workflows/proxy-release-candidate.yml b/.github/workflows/proxy-release-candidate.yml index 24eebc4..1e474d5 100644 --- a/.github/workflows/proxy-release-candidate.yml +++ b/.github/workflows/proxy-release-candidate.yml @@ -96,7 +96,7 @@ jobs: linux: name: Proxy linux-x86_64-gnu - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 @@ -140,21 +140,21 @@ jobs: name: proxy-linux-x86_64-gnu path: proxy-linux-x86_64-gnu.tgz if-no-files-found: error - - name: Exercise identical Linux bytes on Ubuntu 22.04 + - name: Exercise identical Linux bytes on Ubuntu 24.04 shell: bash run: | - mkdir -p linux-evidence/ubuntu-22.04 - docker run --rm -v "$PWD:/work" -w /work ubuntu:22.04 bash -lc 'apt-get update && apt-get install -y python3 ca-certificates curl && python3 -m scripts.standalone.release verify release-input/linux-x86_64-gnu && version=$(python3 -c "import json; print(json.load(open(\"release-input/linux-x86_64-gnu/selection.json\"))[\"version\"])") && digest=$(python3 -c "import json; print(json.load(open(\"release-input/linux-x86_64-gnu/selection.json\"))[\"sha256\"])") && cat /etc/os-release > /work/linux-evidence/ubuntu-22.04/os-release.txt && ldd --version > /work/linux-evidence/ubuntu-22.04/glibc.txt 2>&1 && sha256sum release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu > /work/linux-evidence/ubuntu-22.04/sha256.txt && python3 -m scripts.standalone.smoke release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu --form onefile --expected-version "$version" > /work/linux-evidence/ubuntu-22.04/packaged-smoke.json && python3 -m scripts.standalone.lifecycle_smoke release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu --version "$version" --sha256 "$digest" --target linux-x86_64-gnu --output /work/linux-evidence/ubuntu-22.04/lifecycle.json' - - name: Exercise identical Linux bytes on Debian 12 + mkdir -p linux-evidence/ubuntu-24.04 + docker run --rm -v "$PWD:/work" -w /work ubuntu:24.04 bash -lc 'apt-get update && apt-get install -y python3 ca-certificates curl && python3 -m scripts.standalone.release verify release-input/linux-x86_64-gnu && version=$(python3 -c "import json; print(json.load(open(\"release-input/linux-x86_64-gnu/selection.json\"))[\"version\"])") && digest=$(python3 -c "import json; print(json.load(open(\"release-input/linux-x86_64-gnu/selection.json\"))[\"sha256\"])") && cat /etc/os-release > /work/linux-evidence/ubuntu-24.04/os-release.txt && ldd --version > /work/linux-evidence/ubuntu-24.04/glibc.txt 2>&1 && sha256sum release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu > /work/linux-evidence/ubuntu-24.04/sha256.txt && python3 -m scripts.standalone.smoke release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu --form onefile --expected-version "$version" > /work/linux-evidence/ubuntu-24.04/packaged-smoke.json && python3 -m scripts.standalone.lifecycle_smoke release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu --version "$version" --sha256 "$digest" --target linux-x86_64-gnu --output /work/linux-evidence/ubuntu-24.04/lifecycle.json' + - name: Exercise identical Linux bytes on Debian 13 shell: bash run: | - mkdir -p linux-evidence/debian-12 - docker run --rm -v "$PWD:/work" -w /work debian:12 bash -lc 'apt-get update && apt-get install -y python3 ca-certificates curl && python3 -m scripts.standalone.release verify release-input/linux-x86_64-gnu && version=$(python3 -c "import json; print(json.load(open(\"release-input/linux-x86_64-gnu/selection.json\"))[\"version\"])") && digest=$(python3 -c "import json; print(json.load(open(\"release-input/linux-x86_64-gnu/selection.json\"))[\"sha256\"])") && cat /etc/os-release > /work/linux-evidence/debian-12/os-release.txt && ldd --version > /work/linux-evidence/debian-12/glibc.txt 2>&1 && sha256sum release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu > /work/linux-evidence/debian-12/sha256.txt && python3 -m scripts.standalone.smoke release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu --form onefile --expected-version "$version" > /work/linux-evidence/debian-12/packaged-smoke.json && python3 -m scripts.standalone.lifecycle_smoke release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu --version "$version" --sha256 "$digest" --target linux-x86_64-gnu --output /work/linux-evidence/debian-12/lifecycle.json' - - name: Exercise identical Linux bytes on Fedora 44 + mkdir -p linux-evidence/debian-13 + docker run --rm -v "$PWD:/work" -w /work debian:13 bash -lc 'apt-get update && apt-get install -y python3 ca-certificates curl && python3 -m scripts.standalone.release verify release-input/linux-x86_64-gnu && version=$(python3 -c "import json; print(json.load(open(\"release-input/linux-x86_64-gnu/selection.json\"))[\"version\"])") && digest=$(python3 -c "import json; print(json.load(open(\"release-input/linux-x86_64-gnu/selection.json\"))[\"sha256\"])") && cat /etc/os-release > /work/linux-evidence/debian-13/os-release.txt && ldd --version > /work/linux-evidence/debian-13/glibc.txt 2>&1 && sha256sum release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu > /work/linux-evidence/debian-13/sha256.txt && python3 -m scripts.standalone.smoke release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu --form onefile --expected-version "$version" > /work/linux-evidence/debian-13/packaged-smoke.json && python3 -m scripts.standalone.lifecycle_smoke release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu --version "$version" --sha256 "$digest" --target linux-x86_64-gnu --output /work/linux-evidence/debian-13/lifecycle.json' + - name: Exercise identical Linux bytes on Fedora 43 shell: bash run: | - mkdir -p linux-evidence/fedora-44 - docker run --rm -v "$PWD:/work" -w /work fedora:44 bash -lc 'dnf install -y python3 ca-certificates curl && python3 -m scripts.standalone.release verify release-input/linux-x86_64-gnu && version=$(python3 -c "import json; print(json.load(open(\"release-input/linux-x86_64-gnu/selection.json\"))[\"version\"])") && digest=$(python3 -c "import json; print(json.load(open(\"release-input/linux-x86_64-gnu/selection.json\"))[\"sha256\"])") && cat /etc/os-release > /work/linux-evidence/fedora-44/os-release.txt && ldd --version > /work/linux-evidence/fedora-44/glibc.txt 2>&1 && sha256sum release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu > /work/linux-evidence/fedora-44/sha256.txt && python3 -m scripts.standalone.smoke release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu --form onefile --expected-version "$version" > /work/linux-evidence/fedora-44/packaged-smoke.json && python3 -m scripts.standalone.lifecycle_smoke release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu --version "$version" --sha256 "$digest" --target linux-x86_64-gnu --output /work/linux-evidence/fedora-44/lifecycle.json' + mkdir -p linux-evidence/fedora-43 + docker run --rm -v "$PWD:/work" -w /work fedora:43 bash -lc 'dnf install -y python3 ca-certificates curl && python3 -m scripts.standalone.release verify release-input/linux-x86_64-gnu && version=$(python3 -c "import json; print(json.load(open(\"release-input/linux-x86_64-gnu/selection.json\"))[\"version\"])") && digest=$(python3 -c "import json; print(json.load(open(\"release-input/linux-x86_64-gnu/selection.json\"))[\"sha256\"])") && cat /etc/os-release > /work/linux-evidence/fedora-43/os-release.txt && ldd --version > /work/linux-evidence/fedora-43/glibc.txt 2>&1 && sha256sum release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu > /work/linux-evidence/fedora-43/sha256.txt && python3 -m scripts.standalone.smoke release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu --form onefile --expected-version "$version" > /work/linux-evidence/fedora-43/packaged-smoke.json && python3 -m scripts.standalone.lifecycle_smoke release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu --version "$version" --sha256 "$digest" --target linux-x86_64-gnu --output /work/linux-evidence/fedora-43/lifecycle.json' - name: Upload Linux runtime evidence uses: actions/upload-artifact@v4 with: @@ -165,7 +165,7 @@ jobs: aggregate: name: Assemble tested Proxy candidate needs: [windows, macos, linux] - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 diff --git a/.github/workflows/proxy-release.yml b/.github/workflows/proxy-release.yml index e38ac53..a17aad7 100644 --- a/.github/workflows/proxy-release.yml +++ b/.github/workflows/proxy-release.yml @@ -22,7 +22,7 @@ permissions: jobs: identity: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 outputs: version: ${{ steps.identity.outputs.version }} commit: ${{ steps.identity.outputs.commit }} @@ -74,7 +74,7 @@ jobs: publish: needs: identity - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 environment: proxy-release permissions: actions: read diff --git a/CHANGELOG.md b/CHANGELOG.md index 91e0608..8cc427f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,8 @@ guardrail behavior is unchanged. through their documented platform entrypoints, then retain the exact tested bytes as one release candidate. - **Linux artifacts use the declared compatibility build.** The published Linux - executable is produced by the Ubuntu 22.04/Python 3.14 Docker build and those - same bytes are exercised on Ubuntu 22.04, Debian 12, and Fedora. + executable is produced by the Ubuntu 24.04/Python 3.14 Docker build and those + same bytes are exercised on Ubuntu 24.04, Debian 13, and Fedora 43. - **Proxy publication no longer rebuilds release artifacts.** Post-merge publication verifies the retained candidate against the release tag and uploads it unchanged, with direct asset identities available for rollback. diff --git a/docs/PROXY_INSTALLATION.md b/docs/PROXY_INSTALLATION.md index 6efcfd2..4c61ca2 100644 --- a/docs/PROXY_INSTALLATION.md +++ b/docs/PROXY_INSTALLATION.md @@ -28,7 +28,7 @@ any particular stable or exact standalone release has been published. | Host | Native target | Release artifact | Prerequisites | |---|---|---|---| | Windows x64 | `windows-x86_64` | `forge-proxy-windows-x86_64.exe` | PowerShell | -| Linux x64 | `linux-x86_64-gnu` | `forge-proxy-linux-x86_64-gnu` | GNU libc 2.35 or newer, with `ldd`; `curl`; `mktemp`; `sha256sum` | +| Linux x64 | `linux-x86_64-gnu` | `forge-proxy-linux-x86_64-gnu` | GNU libc 2.39 or newer, with `ldd`; `curl`; `mktemp`; `sha256sum` | | macOS arm64 | `macos-arm64` | `forge-proxy-macos-arm64` | `curl`; `mktemp`; `shasum -a 256` | Other operating systems, architectures, and libc combinations are unsupported diff --git a/install.sh b/install.sh index e15d262..8bf435d 100755 --- a/install.sh +++ b/install.sh @@ -1,5 +1,5 @@ #!/bin/sh -# Forge Proxy bootstrap installer for Linux x64/glibc 2.35+ and macOS arm64. +# Forge Proxy bootstrap installer for Linux x64/glibc 2.39+ and macOS arm64. # One line: curl -fsSL https://raw.githubusercontent.com/antoinezambelli/forge/main/install.sh | sh # Save, inspect, execute: # curl -fsSLo install.sh https://raw.githubusercontent.com/antoinezambelli/forge/main/install.sh @@ -117,8 +117,8 @@ case "$system:$machine" in [ -n "$libc_version" ] || die 'unsupported Linux libc: glibc version is unknown' libc_major=${libc_version% *} libc_minor=${libc_version#* } - if [ "$libc_major" -lt 2 ] || { [ "$libc_major" -eq 2 ] && [ "$libc_minor" -lt 35 ]; }; then - die 'unsupported Linux libc: glibc 2.35 or newer is required' + if [ "$libc_major" -lt 2 ] || { [ "$libc_major" -eq 2 ] && [ "$libc_minor" -lt 39 ]; }; then + die 'unsupported Linux libc: glibc 2.39 or newer is required' fi target=linux-x86_64-gnu ;; diff --git a/packaging/standalone/README.md b/packaging/standalone/README.md index c206f8a..f163026 100644 --- a/packaging/standalone/README.md +++ b/packaging/standalone/README.md @@ -21,9 +21,9 @@ removed and checks version, help, health, OpenAI-shaped forwarding, Anthropic-shaped SDK forwarding, `CTRL_BREAK_EVENT` shutdown, listener closure, and onefile extraction cleanup. -## Linux x64 / glibc 2.35 +## Linux x64 / glibc 2.39 -Build natively in the Ubuntu 22.04 image (Docker output can be copied from the +Build natively in the Ubuntu 24.04 image (Docker output can be copied from the container's `/forge/standalone-dist` directory): ```sh @@ -33,7 +33,7 @@ docker run --name forge-proxy-linux-build forge-proxy-linux The completed artifact inspection checks every ELF object in the onedir bundle, the onefile launcher, and every ELF object recorded in its collection inventory, -failing if any referenced GLIBC symbol exceeds 2.35. +failing if any referenced GLIBC symbol exceeds 2.39. ## macOS arm64 @@ -59,8 +59,8 @@ plus the isolated install/init/check/reinstall/uninstall lifecycle. When an older standalone Proxy artifact is retrievable, each job additionally tests a forward update and exact-version reinstall; otherwise only those two cross-version checks are skipped. The Linux job also extracts the Docker-built -artifact and executes those same bytes sequentially on Ubuntu 22.04, Debian 12, -and Fedora 44. A fourth aggregation-only job combines the three passing outputs +artifact and executes those same bytes sequentially on Ubuntu 24.04, Debian 13, +and Fedora 43. A fourth aggregation-only job combines the three passing outputs into one retained release-candidate artifact. An ordinary Forge release leaves the pointer unchanged and does not run Proxy CI. diff --git a/packaging/standalone/linux/Dockerfile b/packaging/standalone/linux/Dockerfile index 2d25251..a99f199 100644 --- a/packaging/standalone/linux/Dockerfile +++ b/packaging/standalone/linux/Dockerfile @@ -1,8 +1,8 @@ -FROM ubuntu:22.04 +FROM ubuntu:24.04 ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update \ - && apt-get install -y --no-install-recommends software-properties-common binutils \ + && apt-get install -y --no-install-recommends software-properties-common binutils gpg-agent \ && add-apt-repository ppa:deadsnakes/ppa \ && apt-get update \ && apt-get install -y --no-install-recommends python3.14 python3.14-venv \ diff --git a/scripts/standalone/evidence.py b/scripts/standalone/evidence.py index 58ad08c..662c3ab 100644 --- a/scripts/standalone/evidence.py +++ b/scripts/standalone/evidence.py @@ -177,7 +177,7 @@ def inspect_glibc( paths: Iterable[Path], runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, ) -> dict[str, Any]: - """Inspect every supplied ELF object and enforce the Ubuntu 22.04 ceiling.""" + """Inspect every supplied ELF object and enforce the Ubuntu 24.04 ceiling.""" maximum = (0, 0) objects: list[str] = [] @@ -194,10 +194,10 @@ def inspect_glibc( versions = [(int(a), int(b)) for a, b in _GLIBC.findall(result.stdout)] if versions: maximum = max(maximum, *versions) - if any(version > (2, 35) for version in versions): + if any(version > (2, 39) for version in versions): rendered = max(versions) raise ValueError( - f"{path} references GLIBC_{rendered[0]}.{rendered[1]} above 2.35" + f"{path} references GLIBC_{rendered[0]}.{rendered[1]} above 2.39" ) return { "verified": True, diff --git a/tests/integration/_bootstrap_support.py b/tests/integration/_bootstrap_support.py index 76ffdde..d650b81 100644 --- a/tests/integration/_bootstrap_support.py +++ b/tests/integration/_bootstrap_support.py @@ -154,7 +154,7 @@ def run_shell( *, system: str | None = None, machine: str | None = None, - ldd_output: str = "ldd (Ubuntu GLIBC 2.35-0ubuntu3.8) 2.35", + ldd_output: str = "ldd (Ubuntu GLIBC 2.39-0ubuntu8) 2.39", status: int = 0, ) -> subprocess.CompletedProcess[str]: if not BASH.is_file(): diff --git a/tests/integration/bootstrap_contract/test_bootstrap_contract.py b/tests/integration/bootstrap_contract/test_bootstrap_contract.py index d0f558d..5d80877 100644 --- a/tests/integration/bootstrap_contract/test_bootstrap_contract.py +++ b/tests/integration/bootstrap_contract/test_bootstrap_contract.py @@ -233,9 +233,9 @@ def test_explicit_version_download_failure_is_not_a_missing_stable_release( { "system": "Linux", "machine": "x86_64", - "ldd_output": "ldd (GNU libc) 2.34", + "ldd_output": "ldd (GNU libc) 2.38", }, - "2.35 or newer", + "2.39 or newer", False, ), ( @@ -249,9 +249,9 @@ def test_explicit_version_download_failure_is_not_a_missing_stable_release( { "system": "Linux", "machine": "x86_64", - "ldd_output": "ldd (GNU libc) 2.34", + "ldd_output": "ldd (GNU libc) 2.38", }, - "2.35 or newer", + "2.39 or newer", True, ), ], @@ -281,8 +281,8 @@ def test_unsupported_hosts_make_zero_requests( @pytest.mark.parametrize( "banner", [ - "ldd (Ubuntu GLIBC 2.35-0ubuntu3.8) 2.35", - "ldd (Debian GLIBC 2.36-9+deb12u4) 2.36", + "ldd (Ubuntu GLIBC 2.39-0ubuntu8) 2.39", + "ldd (Debian GLIBC 2.41-12+deb13u1) 2.41", "ldd (GNU libc) 2.100", ], ) @@ -411,7 +411,7 @@ def test_piped_posix_bootstrap_hands_off_without_consuming_user_input( "_FORGE_PROXY_BOOTSTRAP_TESTING": "1", "_FORGE_PROXY_BOOTSTRAP_SYSTEM": platform.system(), "_FORGE_PROXY_BOOTSTRAP_MACHINE": platform.machine(), - "_FORGE_PROXY_BOOTSTRAP_LDD_OUTPUT": "ldd (GNU libc) 2.35", + "_FORGE_PROXY_BOOTSTRAP_LDD_OUTPUT": "ldd (GNU libc) 2.39", "_FORGE_PROXY_BOOTSTRAP_TEMP_ROOT": bash_path(temp_root), "FORGE_BOOTSTRAP_HANDOFF_LOG": bash_path(handoff), "FORGE_BOOTSTRAP_HANDOFF_STATUS": "0", diff --git a/tests/unit/test_proxy_release_workflows.py b/tests/unit/test_proxy_release_workflows.py index e4b8497..246fa0a 100644 --- a/tests/unit/test_proxy_release_workflows.py +++ b/tests/unit/test_proxy_release_workflows.py @@ -60,9 +60,9 @@ def test_candidate_has_three_platform_jobs_and_one_aggregation_job() -> None: assert text.count("python-version: '3.14'") == 4 assert text.count("tests/integration/bootstrap_contract") == 3 assert text.count("scripts.standalone.lifecycle_smoke") >= 6 - assert "ubuntu:22.04" in text - assert "debian:12" in text - assert "fedora:44" in text + assert "ubuntu:24.04" in text + assert "debian:13" in text + assert "fedora:43" in text assert "linux-runtime-evidence" in text assert "scripts.standalone.release assemble" in text assert "scripts.standalone.release verify-staging publication" in text @@ -85,11 +85,12 @@ def test_general_ci_has_only_three_always_on_python_suites() -> None: ] -def test_linux_builder_keeps_ubuntu_2204_and_uses_python_314() -> None: +def test_linux_builder_uses_ubuntu_2404_and_python_314() -> None: dockerfile = ( ROOT / "packaging" / "standalone" / "linux" / "Dockerfile" ).read_text(encoding="utf-8") - assert dockerfile.startswith("FROM ubuntu:22.04\n") + assert dockerfile.startswith("FROM ubuntu:24.04\n") + assert "gpg-agent" in dockerfile assert "python3.14 python3.14-venv" in dockerfile assert "RUN python3.14 -m venv /build-env" in dockerfile assert "python3.12" not in dockerfile diff --git a/tests/unit/test_standalone_build.py b/tests/unit/test_standalone_build.py index 1d63dbe..778c332 100644 --- a/tests/unit/test_standalone_build.py +++ b/tests/unit/test_standalone_build.py @@ -191,10 +191,10 @@ def fake_readelf( args: list[str], **_kwargs: object, ) -> subprocess.CompletedProcess[str]: - version = "GLIBC_2.35" if Path(args[-1]) == launcher else "GLIBC_2.36" + version = "GLIBC_2.39" if Path(args[-1]) == launcher else "GLIBC_2.40" return subprocess.CompletedProcess(args, 0, stdout=version, stderr="") - with pytest.raises(ValueError, match="pydantic_core.*GLIBC_2.36"): + with pytest.raises(ValueError, match="pydantic_core.*GLIBC_2.40"): evidence.inspect_glibc([launcher, nested], runner=fake_readelf) @@ -214,11 +214,11 @@ def fake_readelf( args: list[str], **_kwargs: object, ) -> subprocess.CompletedProcess[str]: - version = "GLIBC_2.36" if Path(args[-1]) == launcher else "GLIBC_2.35" + version = "GLIBC_2.40" if Path(args[-1]) == launcher else "GLIBC_2.39" return subprocess.CompletedProcess(args, 0, stdout=version, stderr="") paths = evidence.onefile_elf_inventory(package_toc, launcher) - with pytest.raises(ValueError, match="forge-proxy.*GLIBC_2.36"): + with pytest.raises(ValueError, match="forge-proxy.*GLIBC_2.40"): evidence.inspect_glibc(paths, runner=fake_readelf) From ce50d8839fc0d2a28a2c7837573a22ba42fd54b3 Mon Sep 17 00:00:00 2001 From: Antoine Zambelli Date: Mon, 17 Aug 2026 02:06:47 -0500 Subject: [PATCH 4/4] Fix Python shared library in Linux standalone build --- packaging/standalone/linux/Dockerfile | 2 +- tests/unit/test_proxy_release_workflows.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packaging/standalone/linux/Dockerfile b/packaging/standalone/linux/Dockerfile index a99f199..b438577 100644 --- a/packaging/standalone/linux/Dockerfile +++ b/packaging/standalone/linux/Dockerfile @@ -5,7 +5,7 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends software-properties-common binutils gpg-agent \ && add-apt-repository ppa:deadsnakes/ppa \ && apt-get update \ - && apt-get install -y --no-install-recommends python3.14 python3.14-venv \ + && apt-get install -y --no-install-recommends python3.14 python3.14-venv libpython3.14 \ && rm -rf /var/lib/apt/lists/* WORKDIR /forge diff --git a/tests/unit/test_proxy_release_workflows.py b/tests/unit/test_proxy_release_workflows.py index 246fa0a..7328f2d 100644 --- a/tests/unit/test_proxy_release_workflows.py +++ b/tests/unit/test_proxy_release_workflows.py @@ -91,7 +91,7 @@ def test_linux_builder_uses_ubuntu_2404_and_python_314() -> None: ).read_text(encoding="utf-8") assert dockerfile.startswith("FROM ubuntu:24.04\n") assert "gpg-agent" in dockerfile - assert "python3.14 python3.14-venv" in dockerfile + assert "python3.14 python3.14-venv libpython3.14" in dockerfile assert "RUN python3.14 -m venv /build-env" in dockerfile assert "python3.12" not in dockerfile