diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3b4cb2ca..1147022e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,699 +1,700 @@ -name: Publish to PyPI - -on: - push: - tags: - - "v*.*.*" - workflow_dispatch: - inputs: - release_tag: - description: "Existing tag to repair as a GitHub Release" - required: false - type: string - -permissions: - contents: read - -jobs: - build: - name: Build distributions - runs-on: ubuntu-latest - if: >- - github.event_name == 'push' || - inputs.release_tag == '' - env: - PIP_CONSTRAINT: ${{ github.workspace }}/.github/release-constraints.txt - PIP_BUILD_CONSTRAINT: ${{ github.workspace }}/.github/release-constraints.txt - - steps: - - name: Check out source - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.11" - - - name: Install release gate and the production dependency set (without SQLCipher) - run: >- - python -m pip install --upgrade - pip setuptools wheel build twine pip-audit ".[all,test]" - - - name: Require tag and package version to match - if: github.event_name == 'push' - shell: bash - run: | - expected="${GITHUB_REF_NAME#v}" - actual="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')" - test "$GITHUB_REF_NAME" = "v$actual" - test "$expected" = "$actual" - - - name: Require release tag commit to be on protected main - if: github.event_name == 'push' - shell: bash - run: | - git fetch --no-tags origin main:refs/remotes/origin/main - git merge-base --is-ancestor "$GITHUB_SHA" origin/main - - - name: Full release gate - run: | - python scripts/check_commercial_manifest.py - python scripts/externalize_dashboard_assets.py - ruff check . - pyright - python -c "import fastapi, httpx, mcp, multipart, pydantic, uvicorn" - ENGRAPHIS_INDEX_ROOTS="${GITHUB_WORKSPACE}:${RUNNER_TEMP}" python -m pytest -o addopts="" tests/ -q -rs --basetemp="${RUNNER_TEMP}/engraphis-pytest" - ENGRAPHIS_INDEX_ROOTS="${GITHUB_WORKSPACE}:${RUNNER_TEMP}" python -m pytest -o addopts="" tests/test_public_research_boundary.py -q --basetemp="${RUNNER_TEMP}/engraphis-pytest" - ENGRAPHIS_INDEX_ROOTS="${GITHUB_WORKSPACE}:${RUNNER_TEMP}" python -m pytest -o addopts="" tests/test_compact_recall.py tests/test_eval_performance.py -q --basetemp="${RUNNER_TEMP}/engraphis-pytest" - ENGRAPHIS_INDEX_ROOTS="${GITHUB_WORKSPACE}:${RUNNER_TEMP}" python -m pytest -o addopts="" tests/test_eval_harness.py tests/test_benchmark_evidence.py -q --basetemp="${RUNNER_TEMP}/engraphis-pytest" - python -m eval.harness --dataset eval/datasets/sample.jsonl --k 5 - python -m eval.harness --dataset eval/datasets/codemem.jsonl --k 5 - python -m eval.ablation - python -m eval.reinforcement - python -m eval.adversarial_memory_security - python -m pip_audit --local --skip-editable - - - name: Build source and universal wheel distributions - shell: bash - run: | - set -euo pipefail - export SOURCE_DATE_EPOCH="$(git show -s --format=%ct "$GITHUB_SHA")" - python -m build --outdir dist - python scripts/normalize_sdist.py dist/*.tar.gz - python -m build --outdir dist-repeat - python scripts/normalize_sdist.py dist-repeat/*.tar.gz - diff <(cd dist && sha256sum * | sort) <(cd dist-repeat && sha256sum * | sort) - python scripts/verify_distribution_contents.py dist/* - - name: Validate distributions - run: python -m twine check dist/* - - - name: Smoke installed wheel and source distribution - shell: bash - run: | - set -euo pipefail - dist_dir="$PWD/dist" - index=0 - for artifact in "$dist_dir"/*.whl "$dist_dir"/*.tar.gz; do - index=$((index + 1)) - venv="$RUNNER_TEMP/engraphis-artifact-smoke-$index" - python -m venv --system-site-packages "$venv" - "$venv/bin/python" -m pip install --no-deps "$artifact" - ( - cd "$RUNNER_TEMP" - "$venv/bin/python" - <<'PY' - import pathlib - import sys - - import engraphis - from engraphis.core.engine import MemoryEngine - - package = pathlib.Path(engraphis.__file__).resolve() - assert pathlib.Path(sys.prefix).resolve() in package.parents, package - engine = MemoryEngine.create(":memory:") - workspace_id = engine.store.get_or_create_workspace("artifact-smoke") - memory_id = engine.remember( - "The artifact smoke marker is indigo.", - workspace_id=workspace_id, - resolve_conflicts=False, - ) - result = engine.recall("artifact smoke marker", workspace_id=workspace_id, k=3) - assert any(chunk["id"] == memory_id for chunk in result.chunks) - engine.store.close() - PY - "$venv/bin/python" -m scripts.smoke_entry_points --timeout 20 - ) - done - - - name: Store distributions - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: python-package-distributions - path: dist/ - - python-matrix: - name: Python ${{ matrix.python-version }} release gate - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] - if: >- - github.event_name == 'push' || - inputs.release_tag == '' - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: ${{ matrix.python-version }} - - name: Install version-appropriate gate - shell: bash - run: | - python -m pip install --upgrade pip - if [ "${{ matrix.python-version }}" = "3.9" ]; then - python -m pip install numpy "pytest<9" ruff - else - python -m pip install -e ".[test]" - fi - - name: Unit, lint, and retrieval gates - run: | - ruff check . - if [ "${{ matrix.python-version }}" != "3.9" ]; then - python -c "import fastapi, httpx, mcp, multipart, pydantic, uvicorn" - fi - ENGRAPHIS_INDEX_ROOTS="${GITHUB_WORKSPACE}:${RUNNER_TEMP}" python -m pytest -o addopts="" tests/ -q -rs --basetemp="${RUNNER_TEMP}/engraphis-pytest" - python -m eval.harness --dataset eval/datasets/sample.jsonl --k 5 - python -m eval.harness --dataset eval/datasets/codemem.jsonl --k 5 - python -m eval.ablation - python -m eval.reinforcement - python -m eval.adversarial_memory_security - - artifact-core-py39: - name: Python 3.9 installed release artifacts - needs: build - if: >- - github.event_name == 'push' || - inputs.release_tag == '' - runs-on: ubuntu-latest - steps: - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.9" - - name: Download exact release distributions - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - name: python-package-distributions - path: dist/ - - name: Install, verify, and smoke wheel and source distribution - shell: bash - run: | - set -euo pipefail - index=0 - for artifact in dist/*.whl dist/*.tar.gz; do - index=$((index + 1)) - venv="$RUNNER_TEMP/engraphis-release-py39-artifact-$index" - python -m venv "$venv" - "$venv/bin/python" -m pip install --disable-pip-version-check "$artifact" - "$venv/bin/python" -m pip check - ( - cd "$RUNNER_TEMP" - "$venv/bin/python" - <<'PY' - import pathlib - import sys - - import engraphis - from engraphis.core.engine import MemoryEngine - - package = pathlib.Path(engraphis.__file__).resolve() - assert pathlib.Path(sys.prefix).resolve() in package.parents, package - engine = MemoryEngine.create(":memory:") - workspace_id = engine.store.get_or_create_workspace("release-py39-artifact") - memory_id = engine.remember( - "The release Python 3.9 artifact marker is indigo.", - workspace_id=workspace_id, - resolve_conflicts=False, - ) - result = engine.recall("release Python 3.9 artifact marker", workspace_id=workspace_id, k=3) - assert any(chunk["id"] == memory_id for chunk in result.chunks) - engine.store.close() - PY - "$venv/bin/engraphis" --help - "$venv/bin/engraphis" --version - "$venv/bin/engraphis-cli" --help - ) - done - - encryption: - name: Encryption driver release gate (Python ${{ matrix.python-version }}) - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] - if: >- - github.event_name == 'push' || - inputs.release_tag == '' - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: ${{ matrix.python-version }} - - name: Install encryption integration gate - run: | - python -m pip install --upgrade pip - pip install -e ".[test,encryption]" - - name: Encryption at-rest integration tests - run: | - python -c "import sqlcipher3; print(sqlcipher3.__file__)" - ENGRAPHIS_INDEX_ROOTS="${GITHUB_WORKSPACE}:${RUNNER_TEMP}" python -m pytest -o addopts="" tests/test_encrypted_store.py -q -rs --basetemp="${RUNNER_TEMP}/engraphis-pytest" - - browser-accessibility: - name: Browser accessibility release gate - runs-on: ubuntu-latest - if: >- - github.event_name == 'push' || - inputs.release_tag == '' - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.11" - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: "24" - - name: Install browser gate - run: | - python -m pip install --upgrade pip - python -m pip install -e ".[test]" "uvicorn[standard]>=0.29" - npm ci - npx playwright install --with-deps chromium - - name: Playwright desktop/mobile, keyboard, CSP, console, and axe checks - run: npm run test:e2e - - pi-extension: - name: Pi extension release gate - runs-on: ubuntu-latest - if: >- - github.event_name == 'push' || - inputs.release_tag == '' - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.11" - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: "24" - cache: npm - cache-dependency-path: integrations/pi/npm-shrinkwrap.json - - name: Install the tagged Smart MCP server - run: | - python -m pip install --upgrade pip - python -m pip install -e ".[test]" - - name: Verify the publishable Pi package and live bridge - working-directory: integrations/pi - env: - ENGRAPHIS_PI_TEST_COMMAND: engraphis-mcp - run: | - npm ci --ignore-scripts - npm run verify - npm run test:integration - npm audit --omit=dev - - docker-smoke: - name: Production image release gate - runs-on: ubuntu-latest - if: >- - github.event_name == 'push' || - inputs.release_tag == '' - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - name: Validate Compose configuration - run: docker compose config --quiet - - name: Reject unauthenticated LAN Compose overlay - run: | - if env -u ENGRAPHIS_API_TOKEN docker compose -f docker-compose.yml -f docker-compose.lan.yml config --quiet; then - echo "LAN overlay must require ENGRAPHIS_API_TOKEN" - exit 1 - fi - - name: Validate token-protected LAN Compose overlay - env: - ENGRAPHIS_API_TOKEN: ci-lan-overlay-token - run: docker compose -f docker-compose.yml -f docker-compose.lan.yml config --quiet - - name: Build production image - run: docker build -t engraphis:release . - - name: Verify production image OCR runtime - run: >- - docker run --rm --entrypoint sh engraphis:release -c - 'python -c "import PIL, pytesseract" && command -v tesseract >/dev/null && - tesseract --version | head -n 1' - - name: Audit production image dependencies - # The runtime image deliberately has no pip. Audit its exact installed - # distributions from the runner instead of reintroducing a build tool to the - # production image only for this check. - shell: bash - run: | - audit_dir="$(mktemp -d)" - container="engraphis-release-audit-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" - cleanup() { - docker rm -f "$container" >/dev/null 2>&1 || true - rm -rf "$audit_dir" - } - trap cleanup EXIT - python -m pip install --disable-pip-version-check --no-cache-dir pip-audit - docker create --name "$container" engraphis:release >/dev/null - docker cp "$container":/usr/local/lib/python3.11/site-packages/. "$audit_dir" - python -m pip_audit --path "$audit_dir" - - name: Run customer-mode readiness smoke - shell: bash - run: | - docker run -d --name engraphis-release -p 8700:8700 \ - -e ENGRAPHIS_EMBED_MODEL= \ - -e ENGRAPHIS_LOOP_INTERVAL=0 \ - -e ENGRAPHIS_HOST=0.0.0.0 \ - engraphis:release - for i in $(seq 1 60); do - if curl -fsS http://127.0.0.1:8700/api/ready; then - exit 0 - fi - sleep 1 - done - docker logs engraphis-release - exit 1 - - name: Teardown - if: always() - run: docker rm -f engraphis-release || true - - code-security: - name: CodeQL ${{ matrix.language }} release gate - if: >- - github.event_name == 'push' || - inputs.release_tag == '' - runs-on: ubuntu-latest - permissions: - contents: read - env: - CODEQL_ACTION_DIFF_INFORMED_QUERIES: "false" - strategy: - fail-fast: false - matrix: - language: ["python", "javascript-typescript"] - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.11" - - name: Initialize CodeQL - uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 - with: - languages: ${{ matrix.language }} - build-mode: none - - name: Analyze complete source tree - id: analyze - uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 - with: - output: codeql-results - upload: never - - name: Require clean CodeQL results - run: python scripts/check_codeql_sarif.py "${{ steps.analyze.outputs.sarif-output }}" - - release-evidence: - name: Generate public release evidence - needs: [build, python-matrix, artifact-core-py39, encryption, browser-accessibility, pi-extension, docker-smoke, code-security] - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.11" - - name: Install SBOM generator and project dependencies - run: >- - python -m pip install --upgrade "pip>=26.1.2" "setuptools>=83" - cyclonedx-bom==7.3.0 ".[all,test]" - - name: Download distributions - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - name: python-package-distributions - path: dist/ - - name: Generate evidence and reproducible SBOM after all release gates - shell: bash - run: | - mkdir release-evidence - sbom="release-evidence/engraphis-${GITHUB_REF_NAME#v}.cdx.json" - cyclonedx-py environment --output-reproducible --of JSON --pyproject pyproject.toml -o "$sbom" - python scripts/release_evidence.py --dist dist --commit "$GITHUB_SHA" \ - --tag "$GITHUB_REF_NAME" \ - --sbom "$sbom" \ - --verified-check ruff \ - --verified-check pyright-core-backends \ - --verified-check codeql \ - --verified-check pytest \ - --verified-check reproducible-distributions \ - --verified-check installed-artifact-smoke \ - --verified-check installed-artifact-smoke-py39 \ - --verified-check privacy-boundary \ - --verified-check token-efficiency \ - --verified-check benchmark-schema-evidence \ - --verified-check encryption-at-rest \ - --verified-check browser-e2e \ - --verified-check pi-extension \ - --verified-check dependency-audit \ - --verified-check container-smoke \ - --verified-check retrieval-sample \ - --verified-check retrieval-codemem \ - --verified-check retrieval-ablation \ - --verified-check reinforcement-state-transition \ - --verified-check adversarial-memory-security \ - --output release-evidence/release-evidence.json - - name: Store public release evidence - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: public-release-evidence - path: release-evidence/ - - publish: - name: Publish to PyPI - needs: release-evidence - # Manual dispatch is intentionally build/check-only. Publication requires a pushed - # semver tag, whose value was matched to pyproject.toml in the build job above. - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') - runs-on: ubuntu-latest - permissions: - id-token: write - contents: read - - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.11" - - name: Download distributions - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - name: python-package-distributions - path: dist/ - - - name: Verify any previously published subset - shell: bash - run: >- - python scripts/verify_release_artifacts.py --dist dist - --version "${GITHUB_REF_NAME#v}" --allow-subset - - # The trusted publisher may write a receipt beside the distributions. Preserve - # the exact set that passed validation so the post-publish check cannot be - # affected by that implementation detail. - - name: Freeze verified distribution set - shell: bash - run: | - mkdir verified-dist - cp dist/*.whl dist/*.tar.gz verified-dist/ - - - name: Publish distributions to PyPI - uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 - with: - skip-existing: true - - - name: Require the exact complete PyPI file set - shell: bash - run: >- - python scripts/verify_release_artifacts.py --dist verified-dist - --version "${GITHUB_REF_NAME#v}" --retries 18 --delay 10 - - github-release: - name: Publish GitHub Release - needs: publish - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') - runs-on: ubuntu-latest - permissions: - contents: write - - steps: - - name: Download distributions - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - name: python-package-distributions - path: dist/ - - - name: Download public release evidence - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - name: public-release-evidence - path: release-evidence/ - - - name: Create GitHub Release - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - shell: bash - run: | - if gh release view "$GITHUB_REF_NAME" --repo "$GH_REPO" >/dev/null 2>&1; then - # A previous partial attempt may have created the release before every - # canonical package asset uploaded. Reconcile same-named assets from the - # exact aggregate that passed the publish gate. - gh release upload "$GITHUB_REF_NAME" dist/* release-evidence/release-evidence.json release-evidence/*.cdx.json \ - --repo "$GH_REPO" \ - --clobber - else - gh release create "$GITHUB_REF_NAME" dist/* release-evidence/release-evidence.json release-evidence/*.cdx.json \ - --repo "$GH_REPO" \ - --verify-tag \ - --generate-notes \ - --title "Engraphis ${GITHUB_REF_NAME#v}" \ - --latest - fi - - github-release-repair: - name: Repair GitHub Release - if: >- - github.event_name == 'workflow_dispatch' && - github.ref == 'refs/heads/main' && - inputs.release_tag != '' - runs-on: ubuntu-latest - permissions: - actions: read - contents: write - id-token: write - - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.11" - - name: Download published distributions - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - RELEASE_TAG: ${{ inputs.release_tag }} - shell: bash - run: | - set -euo pipefail - [[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] - tag_ref="$(gh api "repos/${GH_REPO}/git/ref/tags/${RELEASE_TAG}")" - object_type="$(jq -r '.object.type' <<<"$tag_ref")" - tag_sha="$(jq -r '.object.sha' <<<"$tag_ref")" - # Annotated tags point at tag objects rather than commits. Peel a bounded - # chain explicitly so a same-named branch can never supply the repair SHA. - for _ in {1..8}; do - if [ "$object_type" = "commit" ]; then - break - fi - test "$object_type" = "tag" - tag_object="$(gh api "repos/${GH_REPO}/git/tags/${tag_sha}")" - object_type="$(jq -r '.object.type' <<<"$tag_object")" - tag_sha="$(jq -r '.object.sha' <<<"$tag_object")" - done - test "$object_type" = "commit" - runs="$(gh run list \ - --repo "$GH_REPO" \ - --workflow release.yml \ - --branch "$RELEASE_TAG" \ - --event push \ - --limit 20 \ - --json databaseId,headBranch,headSha,event,createdAt)" - run_id="$(jq -r \ - --arg tag "$RELEASE_TAG" \ - --arg sha "$tag_sha" \ - 'sort_by(.createdAt) | map(select(.headBranch == $tag and - .headSha == $sha and - .event == "push"))[0].databaseId // empty' \ - <<<"$runs")" - test -n "$run_id" - jobs="$(gh run view "$run_id" --repo "$GH_REPO" --json jobs)" - test "$(jq '[.jobs[] | select(.name == "Build distributions" and - .conclusion == "success")] | length' \ - <<<"$jobs")" -eq 1 - test "$(jq '[.jobs[] | select(.name == "Publish to PyPI" and - (.conclusion == "success" or - .conclusion == "failure"))] | length' \ - <<<"$jobs")" -eq 1 - test "$(jq '[.jobs[] | select(.name == "Generate public release evidence" and - .conclusion == "success")] | length' \ - <<<"$jobs")" -eq 1 - gh run download "$run_id" \ - --repo "$GH_REPO" \ - --name python-package-distributions \ - --dir dist - - gh run download "$run_id" \ - --repo "$GH_REPO" \ - --name public-release-evidence \ - --dir release-evidence - python - "$RELEASE_TAG" "$tag_sha" <<'PY' - import hashlib - import json - import sys - from pathlib import Path - - tag, commit = sys.argv[1:] - with open("release-evidence/release-evidence.json", encoding="utf-8") as handle: - evidence = json.load(handle) - assert evidence.get("format") == "engraphis-release-evidence/2" - assert evidence.get("package", {}).get("version") == tag.removeprefix("v") - assert evidence.get("tag") == tag - assert evidence.get("commit") == commit - assert evidence.get("provenance", {}).get("source") == {"tag": tag, "commit": commit} - expected = { - item["filename"]: item["sha256"] - for item in evidence.get("artifacts", []) - } - actual = { - path.name: hashlib.sha256(path.read_bytes()).hexdigest() - for path in Path("dist").iterdir() - if path.is_file() and (path.name.endswith(".whl") or path.name.endswith(".tar.gz")) - } - assert expected == actual - PY - - - name: Verify any previously published subset - env: - RELEASE_TAG: ${{ inputs.release_tag }} - shell: bash - run: >- - python scripts/verify_release_artifacts.py --dist dist - --version "${RELEASE_TAG#v}" --allow-subset - - # gh-action-pypi-publish can leave its receipt in dist. Keep the approved - # artifact set separate for the exact immutable-PyPI verification below. - - name: Freeze verified distribution set - shell: bash - run: | - mkdir verified-dist - cp dist/*.whl dist/*.tar.gz verified-dist/ - - - name: Publish only missing verified distributions - uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 - with: - skip-existing: true - - - name: Require the exact complete PyPI file set - env: - RELEASE_TAG: ${{ inputs.release_tag }} - shell: bash - run: >- - python scripts/verify_release_artifacts.py --dist verified-dist - --version "${RELEASE_TAG#v}" --retries 18 --delay 10 - - - name: Repair GitHub Release - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - RELEASE_TAG: ${{ inputs.release_tag }} - shell: bash - run: | - if gh release view "$RELEASE_TAG" --repo "$GH_REPO" >/dev/null 2>&1; then - gh release upload "$RELEASE_TAG" verified-dist/* release-evidence/release-evidence.json release-evidence/*.cdx.json \ - --repo "$GH_REPO" \ - --clobber - else - gh release create "$RELEASE_TAG" verified-dist/* release-evidence/release-evidence.json release-evidence/*.cdx.json \ - --repo "$GH_REPO" \ - --verify-tag \ - --generate-notes \ - --title "Engraphis ${RELEASE_TAG#v}" \ - --latest - fi +name: Publish to PyPI + +on: + push: + tags: + - "v*.*" + - "v*.*.*" + workflow_dispatch: + inputs: + release_tag: + description: "Existing tag to repair as a GitHub Release" + required: false + type: string + +permissions: + contents: read + +jobs: + build: + name: Build distributions + runs-on: ubuntu-latest + if: >- + github.event_name == 'push' || + inputs.release_tag == '' + env: + PIP_CONSTRAINT: ${{ github.workspace }}/.github/release-constraints.txt + PIP_BUILD_CONSTRAINT: ${{ github.workspace }}/.github/release-constraints.txt + + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.11" + + - name: Install release gate and the production dependency set (without SQLCipher) + run: >- + python -m pip install --upgrade + pip setuptools wheel build twine pip-audit ".[all,test]" + + - name: Require tag and package version to match + if: github.event_name == 'push' + shell: bash + run: | + expected="${GITHUB_REF_NAME#v}" + actual="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')" + test "$GITHUB_REF_NAME" = "v$actual" + test "$expected" = "$actual" + + - name: Require release tag commit to be on protected main + if: github.event_name == 'push' + shell: bash + run: | + git fetch --no-tags origin main:refs/remotes/origin/main + git merge-base --is-ancestor "$GITHUB_SHA" origin/main + + - name: Full release gate + run: | + python scripts/check_commercial_manifest.py + python scripts/externalize_dashboard_assets.py + ruff check . + pyright + python -c "import fastapi, httpx, mcp, multipart, pydantic, uvicorn" + ENGRAPHIS_INDEX_ROOTS="${GITHUB_WORKSPACE}:${RUNNER_TEMP}" python -m pytest -o addopts="" tests/ -q -rs --basetemp="${RUNNER_TEMP}/engraphis-pytest" + ENGRAPHIS_INDEX_ROOTS="${GITHUB_WORKSPACE}:${RUNNER_TEMP}" python -m pytest -o addopts="" tests/test_public_research_boundary.py -q --basetemp="${RUNNER_TEMP}/engraphis-pytest" + ENGRAPHIS_INDEX_ROOTS="${GITHUB_WORKSPACE}:${RUNNER_TEMP}" python -m pytest -o addopts="" tests/test_compact_recall.py tests/test_eval_performance.py -q --basetemp="${RUNNER_TEMP}/engraphis-pytest" + ENGRAPHIS_INDEX_ROOTS="${GITHUB_WORKSPACE}:${RUNNER_TEMP}" python -m pytest -o addopts="" tests/test_eval_harness.py tests/test_benchmark_evidence.py -q --basetemp="${RUNNER_TEMP}/engraphis-pytest" + python -m eval.harness --dataset eval/datasets/sample.jsonl --k 5 + python -m eval.harness --dataset eval/datasets/codemem.jsonl --k 5 + python -m eval.ablation + python -m eval.reinforcement + python -m eval.adversarial_memory_security + python -m pip_audit --local --skip-editable + + - name: Build source and universal wheel distributions + shell: bash + run: | + set -euo pipefail + export SOURCE_DATE_EPOCH="$(git show -s --format=%ct "$GITHUB_SHA")" + python -m build --outdir dist + python scripts/normalize_sdist.py dist/*.tar.gz + python -m build --outdir dist-repeat + python scripts/normalize_sdist.py dist-repeat/*.tar.gz + diff <(cd dist && sha256sum * | sort) <(cd dist-repeat && sha256sum * | sort) + python scripts/verify_distribution_contents.py dist/* + - name: Validate distributions + run: python -m twine check dist/* + + - name: Smoke installed wheel and source distribution + shell: bash + run: | + set -euo pipefail + dist_dir="$PWD/dist" + index=0 + for artifact in "$dist_dir"/*.whl "$dist_dir"/*.tar.gz; do + index=$((index + 1)) + venv="$RUNNER_TEMP/engraphis-artifact-smoke-$index" + python -m venv --system-site-packages "$venv" + "$venv/bin/python" -m pip install --no-deps "$artifact" + ( + cd "$RUNNER_TEMP" + "$venv/bin/python" - <<'PY' + import pathlib + import sys + + import engraphis + from engraphis.core.engine import MemoryEngine + + package = pathlib.Path(engraphis.__file__).resolve() + assert pathlib.Path(sys.prefix).resolve() in package.parents, package + engine = MemoryEngine.create(":memory:") + workspace_id = engine.store.get_or_create_workspace("artifact-smoke") + memory_id = engine.remember( + "The artifact smoke marker is indigo.", + workspace_id=workspace_id, + resolve_conflicts=False, + ) + result = engine.recall("artifact smoke marker", workspace_id=workspace_id, k=3) + assert any(chunk["id"] == memory_id for chunk in result.chunks) + engine.store.close() + PY + "$venv/bin/python" -m scripts.smoke_entry_points --timeout 20 + ) + done + + - name: Store distributions + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: python-package-distributions + path: dist/ + + python-matrix: + name: Python ${{ matrix.python-version }} release gate + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + if: >- + github.event_name == 'push' || + inputs.release_tag == '' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ matrix.python-version }} + - name: Install version-appropriate gate + shell: bash + run: | + python -m pip install --upgrade pip + if [ "${{ matrix.python-version }}" = "3.9" ]; then + python -m pip install numpy "pytest<9" ruff + else + python -m pip install -e ".[test]" + fi + - name: Unit, lint, and retrieval gates + run: | + ruff check . + if [ "${{ matrix.python-version }}" != "3.9" ]; then + python -c "import fastapi, httpx, mcp, multipart, pydantic, uvicorn" + fi + ENGRAPHIS_INDEX_ROOTS="${GITHUB_WORKSPACE}:${RUNNER_TEMP}" python -m pytest -o addopts="" tests/ -q -rs --basetemp="${RUNNER_TEMP}/engraphis-pytest" + python -m eval.harness --dataset eval/datasets/sample.jsonl --k 5 + python -m eval.harness --dataset eval/datasets/codemem.jsonl --k 5 + python -m eval.ablation + python -m eval.reinforcement + python -m eval.adversarial_memory_security + + artifact-core-py39: + name: Python 3.9 installed release artifacts + needs: build + if: >- + github.event_name == 'push' || + inputs.release_tag == '' + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.9" + - name: Download exact release distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: python-package-distributions + path: dist/ + - name: Install, verify, and smoke wheel and source distribution + shell: bash + run: | + set -euo pipefail + index=0 + for artifact in dist/*.whl dist/*.tar.gz; do + index=$((index + 1)) + venv="$RUNNER_TEMP/engraphis-release-py39-artifact-$index" + python -m venv "$venv" + "$venv/bin/python" -m pip install --disable-pip-version-check "$artifact" + "$venv/bin/python" -m pip check + ( + cd "$RUNNER_TEMP" + "$venv/bin/python" - <<'PY' + import pathlib + import sys + + import engraphis + from engraphis.core.engine import MemoryEngine + + package = pathlib.Path(engraphis.__file__).resolve() + assert pathlib.Path(sys.prefix).resolve() in package.parents, package + engine = MemoryEngine.create(":memory:") + workspace_id = engine.store.get_or_create_workspace("release-py39-artifact") + memory_id = engine.remember( + "The release Python 3.9 artifact marker is indigo.", + workspace_id=workspace_id, + resolve_conflicts=False, + ) + result = engine.recall("release Python 3.9 artifact marker", workspace_id=workspace_id, k=3) + assert any(chunk["id"] == memory_id for chunk in result.chunks) + engine.store.close() + PY + "$venv/bin/engraphis" --help + "$venv/bin/engraphis" --version + "$venv/bin/engraphis-cli" --help + ) + done + + encryption: + name: Encryption driver release gate (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + if: >- + github.event_name == 'push' || + inputs.release_tag == '' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ matrix.python-version }} + - name: Install encryption integration gate + run: | + python -m pip install --upgrade pip + pip install -e ".[test,encryption]" + - name: Encryption at-rest integration tests + run: | + python -c "import sqlcipher3; print(sqlcipher3.__file__)" + ENGRAPHIS_INDEX_ROOTS="${GITHUB_WORKSPACE}:${RUNNER_TEMP}" python -m pytest -o addopts="" tests/test_encrypted_store.py -q -rs --basetemp="${RUNNER_TEMP}/engraphis-pytest" + + browser-accessibility: + name: Browser accessibility release gate + runs-on: ubuntu-latest + if: >- + github.event_name == 'push' || + inputs.release_tag == '' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.11" + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + - name: Install browser gate + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[test]" "uvicorn[standard]>=0.29" + npm ci + npx playwright install --with-deps chromium + - name: Playwright desktop/mobile, keyboard, CSP, console, and axe checks + run: npm run test:e2e + + pi-extension: + name: Pi extension release gate + runs-on: ubuntu-latest + if: >- + github.event_name == 'push' || + inputs.release_tag == '' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.11" + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: npm + cache-dependency-path: integrations/pi/npm-shrinkwrap.json + - name: Install the tagged Smart MCP server + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[test]" + - name: Verify the publishable Pi package and live bridge + working-directory: integrations/pi + env: + ENGRAPHIS_PI_TEST_COMMAND: engraphis-mcp + run: | + npm ci --ignore-scripts + npm run verify + npm run test:integration + npm audit --omit=dev + + docker-smoke: + name: Production image release gate + runs-on: ubuntu-latest + if: >- + github.event_name == 'push' || + inputs.release_tag == '' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Validate Compose configuration + run: docker compose config --quiet + - name: Reject unauthenticated LAN Compose overlay + run: | + if env -u ENGRAPHIS_API_TOKEN docker compose -f docker-compose.yml -f docker-compose.lan.yml config --quiet; then + echo "LAN overlay must require ENGRAPHIS_API_TOKEN" + exit 1 + fi + - name: Validate token-protected LAN Compose overlay + env: + ENGRAPHIS_API_TOKEN: ci-lan-overlay-token + run: docker compose -f docker-compose.yml -f docker-compose.lan.yml config --quiet + - name: Build production image + run: docker build -t engraphis:release . + - name: Verify production image OCR runtime + run: >- + docker run --rm --entrypoint sh engraphis:release -c + 'python -c "import PIL, pytesseract" && command -v tesseract >/dev/null && + tesseract --version | head -n 1' + - name: Audit production image dependencies + # The runtime image deliberately has no pip. Audit its exact installed + # distributions from the runner instead of reintroducing a build tool to the + # production image only for this check. + shell: bash + run: | + audit_dir="$(mktemp -d)" + container="engraphis-release-audit-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + cleanup() { + docker rm -f "$container" >/dev/null 2>&1 || true + rm -rf "$audit_dir" + } + trap cleanup EXIT + python -m pip install --disable-pip-version-check --no-cache-dir pip-audit + docker create --name "$container" engraphis:release >/dev/null + docker cp "$container":/usr/local/lib/python3.11/site-packages/. "$audit_dir" + python -m pip_audit --path "$audit_dir" + - name: Run customer-mode readiness smoke + shell: bash + run: | + docker run -d --name engraphis-release -p 8700:8700 \ + -e ENGRAPHIS_EMBED_MODEL= \ + -e ENGRAPHIS_LOOP_INTERVAL=0 \ + -e ENGRAPHIS_HOST=0.0.0.0 \ + engraphis:release + for i in $(seq 1 60); do + if curl -fsS http://127.0.0.1:8700/api/ready; then + exit 0 + fi + sleep 1 + done + docker logs engraphis-release + exit 1 + - name: Teardown + if: always() + run: docker rm -f engraphis-release || true + + code-security: + name: CodeQL ${{ matrix.language }} release gate + if: >- + github.event_name == 'push' || + inputs.release_tag == '' + runs-on: ubuntu-latest + permissions: + contents: read + env: + CODEQL_ACTION_DIFF_INFORMED_QUERIES: "false" + strategy: + fail-fast: false + matrix: + language: ["python", "javascript-typescript"] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.11" + - name: Initialize CodeQL + uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 + with: + languages: ${{ matrix.language }} + build-mode: none + - name: Analyze complete source tree + id: analyze + uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 + with: + output: codeql-results + upload: never + - name: Require clean CodeQL results + run: python scripts/check_codeql_sarif.py "${{ steps.analyze.outputs.sarif-output }}" + + release-evidence: + name: Generate public release evidence + needs: [build, python-matrix, artifact-core-py39, encryption, browser-accessibility, pi-extension, docker-smoke, code-security] + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.11" + - name: Install SBOM generator and project dependencies + run: >- + python -m pip install --upgrade "pip>=26.1.2" "setuptools>=83" + cyclonedx-bom==7.3.0 ".[all,test]" + - name: Download distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: python-package-distributions + path: dist/ + - name: Generate evidence and reproducible SBOM after all release gates + shell: bash + run: | + mkdir release-evidence + sbom="release-evidence/engraphis-${GITHUB_REF_NAME#v}.cdx.json" + cyclonedx-py environment --output-reproducible --of JSON --pyproject pyproject.toml -o "$sbom" + python scripts/release_evidence.py --dist dist --commit "$GITHUB_SHA" \ + --tag "$GITHUB_REF_NAME" \ + --sbom "$sbom" \ + --verified-check ruff \ + --verified-check pyright-core-backends \ + --verified-check codeql \ + --verified-check pytest \ + --verified-check reproducible-distributions \ + --verified-check installed-artifact-smoke \ + --verified-check installed-artifact-smoke-py39 \ + --verified-check privacy-boundary \ + --verified-check token-efficiency \ + --verified-check benchmark-schema-evidence \ + --verified-check encryption-at-rest \ + --verified-check browser-e2e \ + --verified-check pi-extension \ + --verified-check dependency-audit \ + --verified-check container-smoke \ + --verified-check retrieval-sample \ + --verified-check retrieval-codemem \ + --verified-check retrieval-ablation \ + --verified-check reinforcement-state-transition \ + --verified-check adversarial-memory-security \ + --output release-evidence/release-evidence.json + - name: Store public release evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: public-release-evidence + path: release-evidence/ + + publish: + name: Publish to PyPI + needs: release-evidence + # Manual dispatch is intentionally build/check-only. Publication requires a pushed + # semver tag, whose value was matched to pyproject.toml in the build job above. + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.11" + - name: Download distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: python-package-distributions + path: dist/ + + - name: Verify any previously published subset + shell: bash + run: >- + python scripts/verify_release_artifacts.py --dist dist + --version "${GITHUB_REF_NAME#v}" --allow-subset + + # The trusted publisher may write a receipt beside the distributions. Preserve + # the exact set that passed validation so the post-publish check cannot be + # affected by that implementation detail. + - name: Freeze verified distribution set + shell: bash + run: | + mkdir verified-dist + cp dist/*.whl dist/*.tar.gz verified-dist/ + + - name: Publish distributions to PyPI + uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 + with: + skip-existing: true + + - name: Require the exact complete PyPI file set + shell: bash + run: >- + python scripts/verify_release_artifacts.py --dist verified-dist + --version "${GITHUB_REF_NAME#v}" --retries 18 --delay 10 + + github-release: + name: Publish GitHub Release + needs: publish + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Download distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: python-package-distributions + path: dist/ + + - name: Download public release evidence + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: public-release-evidence + path: release-evidence/ + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + shell: bash + run: | + if gh release view "$GITHUB_REF_NAME" --repo "$GH_REPO" >/dev/null 2>&1; then + # A previous partial attempt may have created the release before every + # canonical package asset uploaded. Reconcile same-named assets from the + # exact aggregate that passed the publish gate. + gh release upload "$GITHUB_REF_NAME" dist/* release-evidence/release-evidence.json release-evidence/*.cdx.json \ + --repo "$GH_REPO" \ + --clobber + else + gh release create "$GITHUB_REF_NAME" dist/* release-evidence/release-evidence.json release-evidence/*.cdx.json \ + --repo "$GH_REPO" \ + --verify-tag \ + --generate-notes \ + --title "Engraphis ${GITHUB_REF_NAME#v}" \ + --latest + fi + + github-release-repair: + name: Repair GitHub Release + if: >- + github.event_name == 'workflow_dispatch' && + github.ref == 'refs/heads/main' && + inputs.release_tag != '' + runs-on: ubuntu-latest + permissions: + actions: read + contents: write + id-token: write + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.11" + - name: Download published distributions + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ inputs.release_tag }} + shell: bash + run: | + set -euo pipefail + [[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+(\.[0-9]+)?$ ]] + tag_ref="$(gh api "repos/${GH_REPO}/git/ref/tags/${RELEASE_TAG}")" + object_type="$(jq -r '.object.type' <<<"$tag_ref")" + tag_sha="$(jq -r '.object.sha' <<<"$tag_ref")" + # Annotated tags point at tag objects rather than commits. Peel a bounded + # chain explicitly so a same-named branch can never supply the repair SHA. + for _ in {1..8}; do + if [ "$object_type" = "commit" ]; then + break + fi + test "$object_type" = "tag" + tag_object="$(gh api "repos/${GH_REPO}/git/tags/${tag_sha}")" + object_type="$(jq -r '.object.type' <<<"$tag_object")" + tag_sha="$(jq -r '.object.sha' <<<"$tag_object")" + done + test "$object_type" = "commit" + runs="$(gh run list \ + --repo "$GH_REPO" \ + --workflow release.yml \ + --branch "$RELEASE_TAG" \ + --event push \ + --limit 20 \ + --json databaseId,headBranch,headSha,event,createdAt)" + run_id="$(jq -r \ + --arg tag "$RELEASE_TAG" \ + --arg sha "$tag_sha" \ + 'sort_by(.createdAt) | map(select(.headBranch == $tag and + .headSha == $sha and + .event == "push"))[0].databaseId // empty' \ + <<<"$runs")" + test -n "$run_id" + jobs="$(gh run view "$run_id" --repo "$GH_REPO" --json jobs)" + test "$(jq '[.jobs[] | select(.name == "Build distributions" and + .conclusion == "success")] | length' \ + <<<"$jobs")" -eq 1 + test "$(jq '[.jobs[] | select(.name == "Publish to PyPI" and + (.conclusion == "success" or + .conclusion == "failure"))] | length' \ + <<<"$jobs")" -eq 1 + test "$(jq '[.jobs[] | select(.name == "Generate public release evidence" and + .conclusion == "success")] | length' \ + <<<"$jobs")" -eq 1 + gh run download "$run_id" \ + --repo "$GH_REPO" \ + --name python-package-distributions \ + --dir dist + + gh run download "$run_id" \ + --repo "$GH_REPO" \ + --name public-release-evidence \ + --dir release-evidence + python - "$RELEASE_TAG" "$tag_sha" <<'PY' + import hashlib + import json + import sys + from pathlib import Path + + tag, commit = sys.argv[1:] + with open("release-evidence/release-evidence.json", encoding="utf-8") as handle: + evidence = json.load(handle) + assert evidence.get("format") == "engraphis-release-evidence/2" + assert evidence.get("package", {}).get("version") == tag.removeprefix("v") + assert evidence.get("tag") == tag + assert evidence.get("commit") == commit + assert evidence.get("provenance", {}).get("source") == {"tag": tag, "commit": commit} + expected = { + item["filename"]: item["sha256"] + for item in evidence.get("artifacts", []) + } + actual = { + path.name: hashlib.sha256(path.read_bytes()).hexdigest() + for path in Path("dist").iterdir() + if path.is_file() and (path.name.endswith(".whl") or path.name.endswith(".tar.gz")) + } + assert expected == actual + PY + + - name: Verify any previously published subset + env: + RELEASE_TAG: ${{ inputs.release_tag }} + shell: bash + run: >- + python scripts/verify_release_artifacts.py --dist dist + --version "${RELEASE_TAG#v}" --allow-subset + + # gh-action-pypi-publish can leave its receipt in dist. Keep the approved + # artifact set separate for the exact immutable-PyPI verification below. + - name: Freeze verified distribution set + shell: bash + run: | + mkdir verified-dist + cp dist/*.whl dist/*.tar.gz verified-dist/ + + - name: Publish only missing verified distributions + uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 + with: + skip-existing: true + + - name: Require the exact complete PyPI file set + env: + RELEASE_TAG: ${{ inputs.release_tag }} + shell: bash + run: >- + python scripts/verify_release_artifacts.py --dist verified-dist + --version "${RELEASE_TAG#v}" --retries 18 --delay 10 + + - name: Repair GitHub Release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ inputs.release_tag }} + shell: bash + run: | + if gh release view "$RELEASE_TAG" --repo "$GH_REPO" >/dev/null 2>&1; then + gh release upload "$RELEASE_TAG" verified-dist/* release-evidence/release-evidence.json release-evidence/*.cdx.json \ + --repo "$GH_REPO" \ + --clobber + else + gh release create "$RELEASE_TAG" verified-dist/* release-evidence/release-evidence.json release-evidence/*.cdx.json \ + --repo "$GH_REPO" \ + --verify-tag \ + --generate-notes \ + --title "Engraphis ${RELEASE_TAG#v}" \ + --latest + fi diff --git a/tests/test_release_infrastructure.py b/tests/test_release_infrastructure.py index ae64020b..29e3af45 100644 --- a/tests/test_release_infrastructure.py +++ b/tests/test_release_infrastructure.py @@ -381,7 +381,7 @@ def test_release_repair_requires_tag_sha_successful_build_publish_and_pypi_ident "github-release-repair:", 1 )[1] - assert '[[ "$RELEASE_TAG" =~ ^v[0-9]+\\.[0-9]+\\.[0-9]+$ ]]' in repair + assert '[[ "$RELEASE_TAG" =~ ^v[0-9]+\\.[0-9]+(\\.[0-9]+)?$ ]]' in repair assert "github.ref == 'refs/heads/main'" in repair assert '"repos/${GH_REPO}/git/ref/tags/${RELEASE_TAG}"' in repair assert '"repos/${GH_REPO}/git/tags/${tag_sha}"' in repair