diff --git a/.github/workflows/publish-agentpin-0.3.1.yml b/.github/workflows/publish-agentpin-0.3.1.yml new file mode 100644 index 0000000..617d4aa --- /dev/null +++ b/.github/workflows/publish-agentpin-0.3.1.yml @@ -0,0 +1,155 @@ +name: Publish AgentPin 0.3.1 security backport + +# Deliberately single-use: no caller-controlled source, package, or version. +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: publish-agentpin-0.3.1 + cancel-in-progress: false + +env: + RELEASE_COMMIT: 780144e9cf173a8295574a0a6bf7e443d6ca7ac8 + RUSTUP_TOOLCHAIN: 1.99.0 + CARGO_PROFILE_DEV_DEBUG: "0" + CARGO_PROFILE_TEST_DEBUG: "0" + CARGO_INCREMENTAL: "0" + +jobs: + validate: + if: >- + github.repository == 'ThirdKeyAI/AgentPin' && + github.ref == 'refs/heads/main' && + github.actor == 'jaschadub' && + github.triggering_actor == 'jaschadub' + runs-on: ubuntu-latest + timeout-minutes: 20 + outputs: + lock_hash: ${{ steps.lock.outputs.sha256 }} + artifact_id: ${{ steps.upload.outputs.artifact-id }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ThirdKeyAI/AgentPin + ref: 780144e9cf173a8295574a0a6bf7e443d6ca7ac8 + persist-credentials: false + - name: Install pinned Rust + run: rustup toolchain install 1.99.0 --profile minimal + - name: Verify source and package identity + run: | + test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT" + cargo metadata --no-deps --format-version 1 | + jq -e '.packages[] | select(.name == "agentpin") | .version == "0.3.1"' + - name: Resolve, test, and verify package without publication credentials + run: | + cargo generate-lockfile + cargo test --workspace --all-features --locked + cargo package --package agentpin@0.3.1 --locked + cargo install cargo-audit --version 0.22.2 --locked + cargo audit --deny warnings + git diff --exit-code + - name: Record tested lockfile + id: lock + run: | + digest="$(sha256sum Cargo.lock | cut -d ' ' -f 1)" + echo "sha256=$digest" >> "$GITHUB_OUTPUT" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + id: upload + with: + name: agentpin-0.3.1-lockfile + path: Cargo.lock + if-no-files-found: error + retention-days: 1 + + publish: + needs: validate + if: >- + github.repository == 'ThirdKeyAI/AgentPin' && + github.ref == 'refs/heads/main' && + github.actor == 'jaschadub' && + github.triggering_actor == 'jaschadub' + # Fresh runner: no dependency build scripts/tests execute with access to + # publication credentials. Transfer lockfile data only, never build caches. + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ThirdKeyAI/AgentPin + ref: 780144e9cf173a8295574a0a6bf7e443d6ca7ac8 + persist-credentials: false + - name: Install pinned Rust + run: rustup toolchain install 1.99.0 --profile minimal + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ needs.validate.outputs.artifact_id }} + path: ${{ runner.temp }}/agentpin-lock-artifact + merge-multiple: true + digest-mismatch: error + skip-decompress: true + - name: Accept only the tested regular lockfile from the untrusted archive + env: + LOCK_SHA256: ${{ needs.validate.outputs.lock_hash }} + LOCK_ARTIFACT_DIR: ${{ runner.temp }}/agentpin-lock-artifact + run: | + python3 - <<'PY' + import hashlib + import os + from pathlib import Path + import re + import stat + import zipfile + + def reject(message): + raise SystemExit(message) + + expected = os.environ["LOCK_SHA256"] + if re.fullmatch(r"[0-9a-f]{64}", expected) is None: + reject("Invalid lockfile digest") + entries = list(Path(os.environ["LOCK_ARTIFACT_DIR"]).iterdir()) + if len(entries) != 1 or not stat.S_ISREG(entries[0].lstat().st_mode): + reject("Expected exactly one regular archive") + if entries[0].stat().st_size > 2 * 1024 * 1024: + reject("Archive exceeds size limit") + with zipfile.ZipFile(entries[0]) as archive: + members = archive.infolist() + if len(members) != 1: + reject("Archive must contain only Cargo.lock") + member = members[0] + file_type = stat.S_IFMT(member.external_attr >> 16) + if member.filename != "Cargo.lock" or member.is_dir(): + reject("Unexpected archive path") + if file_type not in (0, stat.S_IFREG): + reject("Lockfile must be a regular file") + if member.file_size > 1024 * 1024: + reject("Lockfile exceeds size limit") + if member.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED): + reject("Unsupported archive compression") + with archive.open(member) as stream: + data = stream.read(1024 * 1024 + 1) + if len(data) != member.file_size or len(data) > 1024 * 1024: + reject("Invalid lockfile size") + if hashlib.sha256(data).hexdigest() != expected: + reject("Lockfile digest mismatch") + # Never extract archive paths or configuration into the clean checkout. + with open("Cargo.lock", "xb") as destination: + destination.write(data) + PY + - name: Check exact source, package, and tested lockfile + env: + LOCK_SHA256: ${{ needs.validate.outputs.lock_hash }} + run: | + test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT" + [[ "$LOCK_SHA256" =~ ^[0-9a-f]{64}$ ]] + printf '%s Cargo.lock\n' "$LOCK_SHA256" | sha256sum --check + cargo metadata --locked --no-deps --format-version 1 | + jq -e '.packages[] | select(.name == "agentpin") | .version == "0.3.1"' + git diff --exit-code + - name: Publish only the verified AgentPin 0.3.1 crate + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + # Package verification ran in the separate, unprivileged job. + run: cargo publish --package agentpin@0.3.1 --registry crates-io --locked --no-verify