diff --git a/.dockerignore b/.dockerignore index 1f24ea68..f91b9ac1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,6 +12,8 @@ __pycache__/ .coverage .mypy_cache/ dist/ +!dist/ +!dist/** build/ node_modules/ *.log diff --git a/.github/workflows/container-edge.yml b/.github/workflows/container-edge.yml index 7920b55c..fe7255c1 100644 --- a/.github/workflows/container-edge.yml +++ b/.github/workflows/container-edge.yml @@ -15,6 +15,8 @@ on: - 'packaging/container/**' - 'pyproject.toml' - 'uv.lock' + - 'packaging/pypi/public-packages.txt' + - '.dockerignore' - '.github/workflows/container-edge.yml' permissions: @@ -42,13 +44,6 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v3 - - name: Authenticate to GHCR - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Compute image name (lowercase owner) id: image run: | @@ -56,34 +51,50 @@ jobs: image="ghcr.io/$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')/darnit" echo "image=$image" >> "$GITHUB_OUTPUT" - # The release Dockerfile expects VERSION to be a real PyPI release. - # For edge builds we use a sentinel version that triggers the - # Dockerfile's source-install branch (see Dockerfile for handling). - # For now, edge uses the most recent published version on TestPyPI - # as a stand-in. If no published version exists yet, the build is - # skipped with a clear log line. - - name: Pick edge install version + - name: Set up Python + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v5 + with: + python-version: "3.12" + + - name: Install uv + uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 + with: + version: "0.5.0" + enable-cache: false + + # The Dockerfile installs the darnit wheels from dist/ in the build + # context, in the layout release.yml produces. Build them here from the + # working tree, so :edge ships the code on main and does not depend on + # a published release. + - name: Build wheels from the working tree id: edge_version run: | set -euo pipefail - # Try TestPyPI first (pre-releases land here); fall back to PyPI. - # If neither has a darnit-mcp version yet, this workflow is a no-op - # — :edge can't exist until at least one release happened. - for url in https://test.pypi.org/pypi/darnit-mcp/json https://pypi.org/pypi/darnit-mcp/json; do - if response=$(curl -fsSL "$url" 2>/dev/null); then - version=$(echo "$response" | jq -r '.info.version // empty') - if [ -n "$version" ]; then - echo "Using darnit-mcp ${version} from ${url}" - echo "version=$version" >> "$GITHUB_OUTPUT" - exit 0 - fi - fi - done - echo "::warning::No darnit-mcp version found on PyPI or TestPyPI yet — :edge build skipped" - echo "version=" >> "$GITHUB_OUTPUT" + while IFS= read -r pkg; do + [ -z "$pkg" ] && continue + echo "::group::Building $pkg" + uv build --package "$pkg" --out-dir "dist/$pkg" + echo "::endgroup::" + done < packaging/pypi/public-packages.txt + version=$(awk -F'"' '/^version[[:space:]]*=/{print $2; exit}' pyproject.toml) + if [ -z "$version" ]; then + echo "::error::Could not read the version from pyproject.toml" + exit 1 + fi + echo "Building :edge from the working tree at version ${version}" + echo "version=$version" >> "$GITHUB_OUTPUT" + + # Log in only after the wheels are built: uv build runs the project's + # build backend, and a bad build-dependency release must not find a + # registry credential on this runner (review on #519). + - name: Authenticate to GHCR + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push :edge - if: steps.edge_version.outputs.version != '' uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v6 with: context: . diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index 95f9c36d..00000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,98 +0,0 @@ -name: Publish to PyPI - -# NOTE: This workflow is a placeholder and not yet active. -# To enable: uncomment the release trigger below and configure -# PyPI Trusted Publishing at https://pypi.org/manage/account/publishing/ - -on: - workflow_dispatch: # Manual trigger only for now - # release: - # types: [published] - -# Restrict permissions to minimum required at workflow level -permissions: - contents: read - -env: - PYTHON_VERSION: "3.12" - UV_VERSION: "0.5.0" - -jobs: - build: - name: Build packages - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Set up Python - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 - with: - python-version: ${{ env.PYTHON_VERSION }} - - - name: Install uv - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 - with: - version: ${{ env.UV_VERSION }} - - - name: Build packages - run: | - uv build --package darnit-core - uv build --package darnit-baseline - - - name: Upload build artifacts - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v4 - with: - name: dist - path: dist/ - - publish-darnit-core: - name: Publish darnit-core - needs: build - runs-on: ubuntu-latest - environment: pypi - permissions: - id-token: write # OIDC for trusted publishing and Sigstore signing - contents: read - attestations: write # Required for Sigstore attestations - - steps: - - name: Download build artifacts - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v4 - with: - name: dist - path: dist/ - - - name: Publish darnit-core to PyPI - uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # release/v1 - with: - packages-dir: dist/ - skip-existing: true - attestations: true # Sign with Sigstore - - publish-darnit-baseline: - name: Publish darnit-baseline - needs: publish-darnit-core - runs-on: ubuntu-latest - environment: pypi - permissions: - id-token: write # OIDC for trusted publishing and Sigstore signing - contents: read - attestations: write # Required for Sigstore attestations - - steps: - - name: Download build artifacts - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v4 - with: - name: dist - path: dist/ - - - name: Publish darnit-baseline to PyPI - uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # release/v1 - with: - packages-dir: dist/ - skip-existing: true - attestations: true # Sign with Sigstore diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml index 6c6eb5a0..4224d169 100644 --- a/.github/workflows/release-smoke.yml +++ b/.github/workflows/release-smoke.yml @@ -76,7 +76,7 @@ jobs: strategy: fail-fast: false matrix: - package: [darnit, darnit-baseline, darnit-gittuf, darnit-mcp] + package: [darnit-core, darnit-baseline, darnit-gittuf, darnit-reproducibility, darnit-mcp] steps: - name: Checkout (for public-packages.txt sanity check) uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 @@ -98,11 +98,11 @@ jobs: with: python-version: "3.12" - - name: Install sigstore CLI (T016) + - name: Install pypi-attestations (T016) run: | set -euo pipefail python -m pip install --upgrade pip - pip install 'sigstore>=3.0.0,<4' + pip install 'pypi-attestations>=0.0.30,<1' - name: Install ${{ matrix.package }} from ${{ needs.parse-trigger.outputs.is_prerelease == 'true' && 'TestPyPI' || 'PyPI' }} env: @@ -148,6 +148,9 @@ jobs: "darnit-gittuf") "$venv/bin/python" -c "import darnit_gittuf; print('darnit_gittuf imports OK')" ;; + "darnit-reproducibility") + "$venv/bin/python" -c "import darnit_reproducibility; print('darnit_reproducibility imports OK')" + ;; *) echo "::error::No smoke recipe defined for package '$PKG'" exit 1 @@ -197,31 +200,31 @@ jobs: exit 1 fi - # The provenance response contains attestation_bundles[].attestations[]. - # Extract the first attestation as a sigstore-readable bundle file. - python <<'PY' - import json, sys - with open('/tmp/provenance.json') as f: - data = json.load(f) - bundles = data.get('attestation_bundles') or [] - if not bundles: - print('::error::No attestation bundles in provenance response') - sys.exit(1) - attestations = bundles[0].get('attestations') or [] - if not attestations: - print('::error::Attestation bundle is empty') - sys.exit(1) - with open('/tmp/attestation.sigstore.json', 'w') as out: - json.dump(attestations[0], out) - print('Extracted attestation to /tmp/attestation.sigstore.json') - PY - - python -m sigstore verify identity \ - --bundle /tmp/attestation.sigstore.json \ - --cert-identity-regexp "^https://github\.com/${{ github.repository }}/\.github/workflows/release\.yml@" \ - --cert-oidc-issuer https://token.actions.githubusercontent.com \ + # pypi-attestations takes the signing workflow from the provenance + # file's own `publisher` block, so `--repository` alone would accept + # any workflow of this repository. Pin the publisher first: every + # bundle must come from release.yml, run in the `release` environment. + publishers=$(jq -c '[.attestation_bundles[].publisher]' /tmp/provenance.json) + echo "Publishers in provenance: $publishers" + if ! jq -e --arg repo "${{ github.repository }}" ' + (.attestation_bundles | length) > 0 and + all(.attestation_bundles[].publisher; + .kind == "GitHub" and .repository == $repo + and .workflow == "release.yml" and .environment == "release") + ' /tmp/provenance.json > /dev/null; then + echo "::error::Provenance for $PKG $VERSION names a publisher other than release.yml in the release environment of ${{ github.repository }}: $publishers" + exit 1 + fi + + # Then verify the PEP 740 attestations in the provenance object against + # the wheel and the publishing repository. sigstore 3.x has no + # `verify identity --cert-identity-regexp`, and the provenance object is + # not a Sigstore bundle; pypi-attestations reads it as published. + pypi-attestations verify pypi \ + --repository "https://github.com/${{ github.repository }}" \ + --provenance-file /tmp/provenance.json \ "$wheel" - echo "✓ Sigstore identity verified for $PKG $VERSION" + echo "✓ PEP 740 attestation verified for $PKG $VERSION (release.yml, release environment)" # T028: pull the published image, run --version, then cosign-verify the # image's signing identity against the canonical release.yml workflow URI. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eeb17919..de4e01e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,15 +8,14 @@ name: Release # What this workflow does today: # 1. preflight - tag/version parity, clean tree, tests, lint, sync # 2. build - per-package wheels + sdists to a shared artifact -# 3. publish-* - upload each public package to PyPI via API token +# 3. publish-* - upload each public package to PyPI/TestPyPI via Trusted Publishing # 4. container - build + push + cosign-sign the multi-arch container image # 5. release - create the GitHub Release with install instructions # 6. finalize - summary line to the run's step summary # -# Auth model: PyPI publishes use the `PYPI_API_TOKEN` repo secret (account- -# scoped). Trusted-publisher setup is nicer long-term but requires per-project -# UI config in PyPI, which was blocking. Container signing uses cosign keyless -# OIDC as before; that path does not depend on PyPI auth. +# Auth model: PyPI publishes use PyPI Trusted Publishing (GitHub OIDC), so no +# long-lived PyPI API token is required. Container signing uses cosign keyless +# OIDC as before. # # Version format: `vX.Y.Z` or `vX.Y.ZrcN` (no dash before rc; matches PEP 440 # canonical form and the preflight parse-tag regex). Pre-release tags are @@ -63,7 +62,7 @@ jobs: - name: Checkout at tag uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 with: - ref: ${{ env.TAG_NAME }} + ref: refs/tags/${{ env.TAG_NAME }} persist-credentials: false fetch-depth: 0 @@ -148,7 +147,7 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: Install uv - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v4 + uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 with: version: ${{ env.UV_VERSION }} # Caching is intentionally disabled in the release pipeline. @@ -185,7 +184,7 @@ jobs: - name: Checkout at tag uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 with: - ref: ${{ env.TAG_NAME }} + ref: refs/tags/${{ env.TAG_NAME }} persist-credentials: false - name: Set up Python @@ -194,7 +193,7 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: Install uv - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v4 + uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 with: version: ${{ env.UV_VERSION }} enable-cache: false @@ -218,21 +217,21 @@ jobs: if-no-files-found: error retention-days: 7 - # Publish jobs use `password: ${{ secrets.PYPI_API_TOKEN }}` (account-scoped - # token). Migrate to project-scoped tokens once the projects exist on PyPI. - # # Sequenced via `needs:` because darnit-baseline, darnit-gittuf, # darnit-reproducibility, and darnit-mcp declare `darnit-core>=...` runtime - # deps; darnit-mcp additionally depends on the other four. Publishing them - # before the deps are on the index briefly produces unresolvable wheels. + # deps; darnit-mcp additionally depends on baseline, gittuf, and + # reproducibility. Publishing them before the deps are on the index briefly + # produces unresolvable wheels. publish-darnit-core: - name: Publish darnit-core to PyPI + name: Publish darnit-core needs: [preflight, build] runs-on: ubuntu-latest + environment: release timeout-minutes: 10 permissions: contents: read + id-token: write steps: - name: Download dist artifacts uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 @@ -241,19 +240,22 @@ jobs: path: dist/ - name: Publish darnit-core - uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # release/v1 + uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 with: packages-dir: dist/darnit-core/ - password: ${{ secrets.PYPI_API_TOKEN }} + repository-url: ${{ needs.preflight.outputs.is_prerelease == 'true' && 'https://test.pypi.org/legacy/' || 'https://upload.pypi.org/legacy/' }} skip-existing: true + attestations: true publish-darnit-baseline: - name: Publish darnit-baseline to PyPI + name: Publish darnit-baseline needs: [preflight, build, publish-darnit-core] runs-on: ubuntu-latest + environment: release timeout-minutes: 10 permissions: contents: read + id-token: write steps: - name: Download dist artifacts uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 @@ -262,19 +264,22 @@ jobs: path: dist/ - name: Publish darnit-baseline - uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # release/v1 + uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 with: packages-dir: dist/darnit-baseline/ - password: ${{ secrets.PYPI_API_TOKEN }} + repository-url: ${{ needs.preflight.outputs.is_prerelease == 'true' && 'https://test.pypi.org/legacy/' || 'https://upload.pypi.org/legacy/' }} skip-existing: true + attestations: true publish-darnit-gittuf: - name: Publish darnit-gittuf to PyPI + name: Publish darnit-gittuf needs: [preflight, build, publish-darnit-core] runs-on: ubuntu-latest + environment: release timeout-minutes: 10 permissions: contents: read + id-token: write steps: - name: Download dist artifacts uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 @@ -283,19 +288,22 @@ jobs: path: dist/ - name: Publish darnit-gittuf - uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # release/v1 + uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 with: packages-dir: dist/darnit-gittuf/ - password: ${{ secrets.PYPI_API_TOKEN }} + repository-url: ${{ needs.preflight.outputs.is_prerelease == 'true' && 'https://test.pypi.org/legacy/' || 'https://upload.pypi.org/legacy/' }} skip-existing: true + attestations: true publish-darnit-reproducibility: - name: Publish darnit-reproducibility to PyPI + name: Publish darnit-reproducibility needs: [preflight, build, publish-darnit-core] runs-on: ubuntu-latest + environment: release timeout-minutes: 10 permissions: contents: read + id-token: write steps: - name: Download dist artifacts uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 @@ -304,14 +312,15 @@ jobs: path: dist/ - name: Publish darnit-reproducibility - uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # release/v1 + uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 with: packages-dir: dist/darnit-reproducibility/ - password: ${{ secrets.PYPI_API_TOKEN }} + repository-url: ${{ needs.preflight.outputs.is_prerelease == 'true' && 'https://test.pypi.org/legacy/' || 'https://upload.pypi.org/legacy/' }} skip-existing: true + attestations: true publish-darnit-mcp: - name: Publish darnit-mcp to PyPI + name: Publish darnit-mcp needs: - preflight - build @@ -319,9 +328,11 @@ jobs: - publish-darnit-gittuf - publish-darnit-reproducibility runs-on: ubuntu-latest + environment: release timeout-minutes: 10 permissions: contents: read + id-token: write steps: - name: Download dist artifacts uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 @@ -330,20 +341,22 @@ jobs: path: dist/ - name: Publish darnit-mcp - uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # release/v1 + uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 with: packages-dir: dist/darnit-mcp/ - password: ${{ secrets.PYPI_API_TOKEN }} + repository-url: ${{ needs.preflight.outputs.is_prerelease == 'true' && 'https://test.pypi.org/legacy/' || 'https://upload.pypi.org/legacy/' }} skip-existing: true + attestations: true - # Build + push + cosign-sign the multi-arch container image. Depends on - # publish-darnit-mcp because the Dockerfile does `pip install - # darnit-mcp==` at build time - the package must be live before - # the image build can resolve it. + # Build + push + cosign-sign the multi-arch container image. Depends on the + # PyPI publish jobs so the image only ships after the public package set is + # uploaded. The Dockerfile installs Darnit packages from this run's build + # artifacts and resolves only external dependencies from PyPI. container_build_push: name: Build, push, and sign container image needs: - preflight + - build - publish-darnit-mcp runs-on: ubuntu-latest timeout-minutes: 20 @@ -357,7 +370,7 @@ jobs: - name: Checkout at tag uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 with: - ref: ${{ env.TAG_NAME }} + ref: refs/tags/${{ env.TAG_NAME }} persist-credentials: false - name: Set up QEMU (multi-arch emulation for arm64 build on amd64 runner) @@ -375,6 +388,12 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Download dist artifacts + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: dist + path: dist/ + - name: Compute image tags id: tags env: @@ -398,26 +417,6 @@ jobs: } >> "$GITHUB_OUTPUT" echo "Tagging: $tags" - - name: Wait for darnit-mcp on PyPI - env: - VERSION: ${{ needs.preflight.outputs.version }} - run: | - set -euo pipefail - # PyPI's CDN typically caches the index for a few minutes after a - # new upload. Poll up to 2 minutes so the image build is not racing - # publication. - url="https://pypi.org/pypi/darnit-mcp/${VERSION}/json" - for attempt in $(seq 1 24); do - if curl -fsSL "$url" >/dev/null 2>&1; then - echo "darnit-mcp ${VERSION} is visible on PyPI (attempt $attempt)" - exit 0 - fi - echo "Waiting for darnit-mcp ${VERSION} on PyPI (attempt $attempt/24)..." - sleep 5 - done - echo "::error::darnit-mcp ${VERSION} did not appear on PyPI within 2 minutes" - exit 1 - - name: Build and push multi-arch image id: build_push uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v6 @@ -469,9 +468,11 @@ jobs: if [ "$IS_PRERELEASE" = "true" ]; then flags="--prerelease" title="darnit ${VERSION} (pre-release)" + pypi_install="pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ --pre darnit-mcp==${VERSION}" else flags="--latest" title="darnit ${VERSION}" + pypi_install="pip install darnit-mcp==${VERSION}" fi body_file=$(mktemp) cat > "$body_file" < \ - --cert-identity-regexp '^https://github\.com/kusari-oss/darnit/' \ - --cert-oidc-issuer https://token.actions.githubusercontent.com \ - +# PyPI (PEP 740 attestations) +pypi-attestations verify pypi \ + --repository https://github.com/darnitdevorg/darnit \ + --provenance-file \ + # Container / binary (cosign) cosign verify[-blob] \ - --certificate-identity-regexp '^https://github\.com/kusari-oss/darnit/' \ + --certificate-identity-regexp '^https://github\.com/darnitdevorg/darnit/' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ ``` @@ -62,7 +61,7 @@ If any of these fail with "identity mismatch", **do not trust the artifact** — Occasionally a release will succeed on some channels and fail on others (PyPI succeeded, container build failed, etc.). When that happens: - The successful channels stay published — they were signed correctly. -- A `release-failure` issue appears on the [upstream repo](https://github.com/kusari-oss/darnit/labels/release-failure) naming the failed channel. +- A `release-failure` issue appears on the [upstream repo](https://github.com/darnitdevorg/darnit/labels/release-failure) naming the failed channel. - The release notes include a per-channel timing table; channels that failed or exceeded the SC-007 budget are flagged. If you're trying to use a channel that's behind, check the latest release notes and the `release-failure` label. diff --git a/docs/install/container.md b/docs/install/container.md index f099f2af..75f75582 100644 --- a/docs/install/container.md +++ b/docs/install/container.md @@ -5,7 +5,7 @@ This is the recommended install path for **CI/CD pipelines** and any environment ## Run a one-shot audit ```bash -docker run --rm -v "$PWD:/repo" ghcr.io/kusari-oss/darnit:v0.1.0 audit +docker run --rm -v "$PWD:/repo" ghcr.io/darnitdevorg/darnit:v0.1.0 audit ``` For `podman` users, substitute `podman` for `docker` — same flags. @@ -42,17 +42,17 @@ The entrypoint dispatches the first argument: ## Verify the image signature -Every release tag is signed with cosign keyless OIDC. The signing identity binds the image to the `release.yml` workflow in `kusari-oss/darnit`. +Every release tag is signed with cosign keyless OIDC. The signing identity binds the image to the `release.yml` workflow in `darnitdevorg/darnit`. ```bash -cosign verify ghcr.io/kusari-oss/darnit:v0.1.0 \ - --certificate-identity-regexp '^https://github\.com/kusari-oss/darnit/\.github/workflows/release\.yml@' \ +cosign verify ghcr.io/darnitdevorg/darnit:v0.1.0 \ + --certificate-identity-regexp '^https://github\.com/darnitdevorg/darnit/\.github/workflows/release\.yml@' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com ``` A passing verification proves: - The image bytes match what was signed. -- The signer was the `release.yml` workflow in `kusari-oss/darnit`. +- The signer was the `release.yml` workflow in `darnitdevorg/darnit`. - The OIDC issuer was GitHub Actions. The `:edge` rolling build is **not** signed. @@ -62,16 +62,16 @@ The `:edge` rolling build is **not** signed. Each signed image has an SPDX-JSON SBOM attached as a cosign attestation: ```bash -cosign download attestation ghcr.io/kusari-oss/darnit:v0.1.0 \ +cosign download attestation ghcr.io/darnitdevorg/darnit:v0.1.0 \ | jq -r '.payload' | base64 -d | jq '.predicate' > darnit-sbom.spdx.json ``` Verify the SBOM's signing identity at the same time: ```bash -cosign verify-attestation ghcr.io/kusari-oss/darnit:v0.1.0 \ +cosign verify-attestation ghcr.io/darnitdevorg/darnit:v0.1.0 \ --type spdx \ - --certificate-identity-regexp '^https://github\.com/kusari-oss/darnit/\.github/workflows/release\.yml@' \ + --certificate-identity-regexp '^https://github\.com/darnitdevorg/darnit/\.github/workflows/release\.yml@' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com ``` @@ -98,7 +98,7 @@ jobs: docker run --rm \ -v "$PWD:/repo" \ -v "${{ github.workspace }}/.audit:/audit-out" \ - ghcr.io/kusari-oss/darnit:v0.1.0 \ + ghcr.io/darnitdevorg/darnit:v0.1.0 \ audit --output /audit-out/report.md ``` @@ -106,7 +106,7 @@ jobs: ```yaml audit: - image: ghcr.io/kusari-oss/darnit:v0.1.0 + image: ghcr.io/darnitdevorg/darnit:v0.1.0 script: - darnit audit ``` @@ -125,7 +125,7 @@ The compressed image targets **300 MiB**. Each release records the measured size | Symptom | Cause | |---|---| -| `Error response from daemon: manifest unknown` | The tag does not exist. Check `gh release list --repo kusari-oss/darnit` for valid versions. | +| `Error response from daemon: manifest unknown` | The tag does not exist. Check `gh release list --repo darnitdevorg/darnit` for valid versions. | | `exec format error` on Apple Silicon | The image was pulled for the wrong architecture. Use `docker pull --platform linux/arm64 ...` or let Docker pick automatically. | | `cosign verify` fails with "no matching signatures" | You're trying to verify an `:edge` image (unsigned) or the tag was published before this signing scheme. | | The audit complains about missing `git`/`gh` | The image bundles both — re-pull, since the bundled binaries might have been overridden by a previous `--volume` mount. | diff --git a/docs/install/pypi.md b/docs/install/pypi.md index 0d1f0a34..ab4b75c9 100644 --- a/docs/install/pypi.md +++ b/docs/install/pypi.md @@ -38,27 +38,17 @@ pip install \ The `--extra-index-url` is required so dependencies of darnit that exist only on PyPI (e.g. `tree-sitter`, `mcp`, `pydantic`) can still be resolved. -## Verify the Sigstore attestation +## Verify the PEP 740 attestation -Every release attaches a [PEP 740](https://peps.python.org/pep-0740/) Sigstore attestation. The signing identity is the GitHub Actions workflow that produced the wheel; you can verify the chain back to the canonical repository without trusting anything in between. +Releases published with Trusted Publishing (`release.yml`; every release from the first one made that way) attach a [PEP 740](https://peps.python.org/pep-0740/) attestation to every wheel. The signing identity is the GitHub Actions workflow that produced the wheel; you can verify the chain back to the canonical repository without trusting anything in between. The 0.1.x releases were uploaded with an API token and carry no attestation: for those, `pypi-attestations` reports that no provenance was found, which is expected, not a failure of the package. -### One-step verification with pip +pip has no `--verify-attestations` option (checked against `pip install --help` on pip 24.0 and 26.2.1); verification is a separate step with `pypi-attestations`. -If your pip is 25.0 or newer, `--verify-attestations` does the whole thing automatically: - -```bash -pip install --verify-attestations darnit-mcp==0.1.0 -``` - -pip refuses to install if the attestation is missing or fails to verify against PyPI's public certs. - -### Manual verification with `sigstore` - -If you need to verify outside an install context (security scanning, attestation archives, etc.): +### Verification with `pypi-attestations` ```bash # Install the verifier -pip install 'sigstore>=3.0.0' +pip install 'pypi-attestations>=0.0.30' # Download the wheel pip download --no-deps darnit-mcp==0.1.0 @@ -68,27 +58,17 @@ curl -fsSL \ "https://pypi.org/integrity/darnit-mcp/0.1.0/darnit_mcp-0.1.0-py3-none-any.whl/provenance" \ -o provenance.json -# Extract the first attestation as a sigstore-readable bundle -python -c " -import json -with open('provenance.json') as f: - data = json.load(f) -with open('attestation.sigstore.json', 'w') as out: - json.dump(data['attestation_bundles'][0]['attestations'][0], out) -" - -# Verify against the canonical kusari-oss/darnit identity -python -m sigstore verify identity \ - --bundle attestation.sigstore.json \ - --cert-identity-regexp '^https://github\.com/kusari-oss/darnit/\.github/workflows/release\.yml@' \ - --cert-oidc-issuer https://token.actions.githubusercontent.com \ +# Verify the attestations against the wheel and the publishing repository +pypi-attestations verify pypi \ + --repository https://github.com/darnitdevorg/darnit \ + --provenance-file provenance.json \ darnit_mcp-0.1.0-py3-none-any.whl ``` A passing verification proves: - The wheel bytes match exactly what was signed. -- The signer was the `release.yml` workflow in `kusari-oss/darnit`. +- The attestation was produced by the `release.yml` workflow of `darnitdevorg/darnit`, from its `release` environment (the tool reads both from the provenance file's `publisher` block; check them, as the release smoke does, before trusting the verification). - The OIDC issuer was GitHub Actions (not some other identity provider). For TestPyPI pre-releases, substitute `test.pypi.org` for `pypi.org` in the provenance URL. @@ -118,5 +98,6 @@ For most users, `uv tool install darnit-mcp` (or `pip install darnit-mcp` in a v |---|---| | `ERROR: Package requires a different Python` | Host Python is older than 3.11. Install Python 3.11+ or use [pipx](https://pipx.pypa.io/) with an explicit `--python` flag. | | `Could not find a version that satisfies the requirement` (for a pre-release) | Missing `--pre` flag or wrong `--index-url`. | -| Sigstore verification fails with "no attestation bundles" | The release was published before PEP 740 attestations existed, or the attestation hasn't propagated yet (rare; retry in a few minutes). | -| Sigstore verification fails with "identity mismatch" | The wheel was not signed by `kusari-oss/darnit`'s release workflow. **Do not trust this artifact.** Report it via the project's security policy. | +| `curl` gets HTTP 404 from the provenance URL, or `pypi-attestations` reports that provenance for the file was not found | The release carries no PEP 740 attestation: every 0.1.x release (uploaded with an API token), or a release published minutes ago that has not propagated yet (retry). Not a sign of tampering. | +| `pypi-attestations verify` fails on the publisher: repository, workflow or environment differs from `darnitdevorg/darnit`, `release.yml`, `release` | The wheel was not signed by this project's release workflow. **Do not trust this artifact.** Report it via the project's security policy. | +| `pypi-attestations verify` fails on the digest or signature | The wheel bytes differ from what was signed. **Do not trust this artifact.** Download again from PyPI; if it still fails, report it. | diff --git a/packages/darnit-baseline/pyproject.toml b/packages/darnit-baseline/pyproject.toml index 04e0944f..58175f44 100644 --- a/packages/darnit-baseline/pyproject.toml +++ b/packages/darnit-baseline/pyproject.toml @@ -35,9 +35,9 @@ dependencies = [ ] [project.urls] -Homepage = "https://github.com/kusari-oss/darnit" -Repository = "https://github.com/kusari-oss/darnit" -Issues = "https://github.com/kusari-oss/darnit/issues" +Homepage = "https://github.com/darnitdevorg/darnit" +Repository = "https://github.com/darnitdevorg/darnit" +Issues = "https://github.com/darnitdevorg/darnit/issues" [project.entry-points."darnit.implementations"] openssf-baseline = "darnit_baseline:register" diff --git a/packages/darnit-csl/pyproject.toml b/packages/darnit-csl/pyproject.toml index abded910..f6c3c4f2 100644 --- a/packages/darnit-csl/pyproject.toml +++ b/packages/darnit-csl/pyproject.toml @@ -24,9 +24,9 @@ dependencies = [ ] [project.urls] -Homepage = "https://github.com/kusari-oss/darnit" -Repository = "https://github.com/kusari-oss/darnit" -Issues = "https://github.com/kusari-oss/darnit/issues" +Homepage = "https://github.com/darnitdevorg/darnit" +Repository = "https://github.com/darnitdevorg/darnit" +Issues = "https://github.com/darnitdevorg/darnit/issues" # Discoverability: the implementation entry point. [project.entry-points."darnit.implementations"] diff --git a/packages/darnit-gittuf/pyproject.toml b/packages/darnit-gittuf/pyproject.toml index fa822fad..6677d486 100644 --- a/packages/darnit-gittuf/pyproject.toml +++ b/packages/darnit-gittuf/pyproject.toml @@ -25,9 +25,9 @@ dependencies = [ ] [project.urls] -Homepage = "https://github.com/kusari-oss/darnit" -Repository = "https://github.com/kusari-oss/darnit" -Issues = "https://github.com/kusari-oss/darnit/issues" +Homepage = "https://github.com/darnitdevorg/darnit" +Repository = "https://github.com/darnitdevorg/darnit" +Issues = "https://github.com/darnitdevorg/darnit/issues" [project.entry-points."darnit.implementations"] gittuf = "darnit_gittuf:register" @@ -42,4 +42,4 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/darnit_gittuf"] # gittuf.toml lives at src/darnit_gittuf/gittuf.toml (feature 021) and is -# packaged automatically by the entry above; no force-include needed. \ No newline at end of file +# packaged automatically by the entry above; no force-include needed. diff --git a/packages/darnit-reproducibility/pyproject.toml b/packages/darnit-reproducibility/pyproject.toml index 53f11355..f4689be0 100644 --- a/packages/darnit-reproducibility/pyproject.toml +++ b/packages/darnit-reproducibility/pyproject.toml @@ -4,6 +4,22 @@ version = "0.1.1" description = "Scientific reproducibility checks plugin for darnit" readme = "README.md" requires-python = ">=3.11" +license = "Apache-2.0" +authors = [ + { name = "Kusari", email = "info@kusari.dev" }, +] +keywords = ["security", "compliance", "reproducibility", "research", "artifacts"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Security", + "Topic :: Software Development :: Quality Assurance", +] dependencies = [ "darnit-core>=0.1.1", # PEP 440 requirement parsing for the requirements.txt pin classifier. @@ -14,6 +30,11 @@ dependencies = [ "packaging>=23.0", ] +[project.urls] +Homepage = "https://github.com/darnitdevorg/darnit" +Repository = "https://github.com/darnitdevorg/darnit" +Issues = "https://github.com/darnitdevorg/darnit/issues" + [project.entry-points."darnit.implementations"] reproducibility = "darnit_reproducibility:register" diff --git a/packages/darnit/pyproject.toml b/packages/darnit/pyproject.toml index 069523ca..568ceaf4 100644 --- a/packages/darnit/pyproject.toml +++ b/packages/darnit/pyproject.toml @@ -46,9 +46,9 @@ dependencies = [ ] [project.urls] -Homepage = "https://github.com/kusari-oss/darnit" -Repository = "https://github.com/kusari-oss/darnit" -Issues = "https://github.com/kusari-oss/darnit/issues" +Homepage = "https://github.com/darnitdevorg/darnit" +Repository = "https://github.com/darnitdevorg/darnit" +Issues = "https://github.com/darnitdevorg/darnit/issues" [project.scripts] darnit = "darnit.cli:main" diff --git a/packaging/README.md b/packaging/README.md index 8d263956..be4e2a08 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -14,7 +14,7 @@ Maintainer-facing documentation for the darnit release pipeline. End-user instal The tag-driven pipeline in `release.yml` currently ships **PyPI + container** only. Binary, Homebrew, and Claude Code plugin channels are designed but not wired up; they will be re-introduced per waybill's incremental pattern as separate PRs. -PyPI publishing uses an **account-scoped API token** (`PYPI_API_TOKEN` repo secret) rather than trusted publishers. Trusted publishers are the better long-term posture (no long-lived secrets, per-project scoping) but the per-project PyPI UI setup was blocking; tokens ship the pipeline today and can be migrated later. See "External setup" below for the token flow. +PyPI publishing uses **Trusted Publishing** (GitHub OIDC) for both PyPI and TestPyPI. Stable tags publish to PyPI; release-candidate tags publish to TestPyPI. No long-lived PyPI API token is required. Every `publish-*` job runs in the `release` environment, which requires approval from a maintainer (mlieberman85 or Marc-cn) and deploys only from `v*` tags or `main`. Sections below marked *(deferred)* describe channels the workflow does not currently drive. @@ -38,32 +38,48 @@ Authoritative list: [`packaging/pypi/public-packages.txt`](pypi/public-packages. ## External setup (one-time) -Before the first release works end-to-end, a maintainer with admin access must: +Before the next release works end-to-end, a maintainer with admin access must: -### PyPI API token (current) +### PyPI Trusted Publishing -The workflow authenticates every `publish-*` job with the `PYPI_API_TOKEN` repo secret. Setup, one time: +Configure Trusted Publishing for each public package, on PyPI and on TestPyPI (rc tags publish there). The publisher is the same on both: -1. Under a PyPI account with 2FA enabled (Account settings -> Two-factor authentication), create a new API token: - - https://pypi.org/manage/account/token/ - - Name: `darnit-github-actions` (or similar) - - Scope: **Entire account (all projects)**. Project-scoped tokens require the projects to already exist; the account-scoped token is only needed for first-time publish, then narrow down. -2. Copy the full token (starts with `pypi-`). -3. Add as a **repository secret** (not environment secret) on `darnitdevorg/darnit`: - ```bash - gh secret set PYPI_API_TOKEN --repo darnitdevorg/darnit - ``` -4. After the first release lands and the five projects exist on PyPI, replace with per-project tokens for scope reduction (optional; not required for correctness). +- Owner: `darnitdevorg` +- Repository: `darnit` +- Workflow: `release.yml` +- Environment: `release` + +Where the project already exists (for example on PyPI, after an earlier token-based release): open the project's settings, then Publishing, and add the GitHub publisher above. Where it does not exist yet (every package on TestPyPI, and any new package on PyPI): add a **pending publisher** from the account page (Your account -> Publishing) with the project name and the same four fields. The first publish from the workflow then creates the project and the pending publisher becomes its Trusted Publisher. Nothing is uploaded by hand, and no account-scoped API token is created at any point. + +Repeat for every package in [`packaging/pypi/public-packages.txt`](pypi/public-packages.txt), currently: + +- `darnit-core` +- `darnit-baseline` +- `darnit-gittuf` +- `darnit-reproducibility` +- `darnit-mcp` + +`darnit-csl` is a workspace package but is not published yet (how modules are published and bundled is being settled in #680). + +### `release` environment + +The publish jobs run in the `release` environment (Settings -> Environments). Its protection: + +- **Required reviewers:** `mlieberman85` and `Marc-cn`. One of them approves the publish jobs of each run. Self-review is prevented, so the maintainer who started the release cannot approve it, and administrators cannot bypass the rule. +- **Deployment branches and tags:** only `v*` tags and the `main` branch. `main` is used by the `workflow_dispatch` re-release path. + +The Trusted Publishers above name this environment, so PyPI and TestPyPI reject an upload from any job outside it. -If the token is bad, the first `publish-*` job in `release.yml` fails immediately with a 403 and no packages are published. Fix the secret and re-trigger via `workflow_dispatch` on the same tag; no need to bump the version. +### Cleanup once Trusted Publishing works -### PyPI Trusted Publishing *(deferred)* +The account-scoped token from the v0.1.x releases is still live as the `PYPI_API_TOKEN` repository secret. No workflow reads it any more. After the first release that publishes through Trusted Publishing: -Better long-term posture; skipped for v0.1.0 because per-project UI setup was blocking. To migrate later, per public package: on the project's "Publishing" page add a Trusted Publisher with Owner: `darnitdevorg`, Repository: `darnit`, Workflow: `release.yml`, Environment: `release`. Then swap `password: ${{ secrets.PYPI_API_TOKEN }}` in each `publish-*` job for the trusted-publisher config, add `id-token: write` permission, and re-add `environment: release`. +1. Delete the repository secret: `gh secret delete PYPI_API_TOKEN --repo darnitdevorg/darnit`. +2. Revoke the token itself at https://pypi.org/manage/account/token/ under the account that created it. -### TestPyPI *(deferred)* +### TestPyPI -Not currently used. rc tags publish directly to real PyPI as GitHub prereleases. +Configure the same Trusted Publisher entries on TestPyPI. Release-candidate tags (`vX.Y.ZrcN`) publish to TestPyPI. The container build installs Darnit packages from the workflow's built wheel artifacts and resolves external dependencies from PyPI. ### Homebrew tap *(deferred)* @@ -74,9 +90,9 @@ Not currently used. rc tags publish directly to real PyPI as GitHub prereleases. The dispatch step in `release.yml` uses bearer auth (`Authorization: Bearer ${HOMEBREW_TAP_TOKEN}`), which works identically for both options. Swap between them at any time without touching the workflow. -3. Store the credential as the `HOMEBREW_TAP_TOKEN` secret on the `release` environment of `kusari-oss/darnit`: +3. Store the credential as the `HOMEBREW_TAP_TOKEN` secret on the `release` environment of `darnitdevorg/darnit`: ```bash - gh secret set HOMEBREW_TAP_TOKEN --repo kusari-oss/darnit --env release + gh secret set HOMEBREW_TAP_TOKEN --repo darnitdevorg/darnit --env release ``` ## Doing a release @@ -121,7 +137,7 @@ The `release.yml` workflow triggers automatically. **No `--force` or amend** — ### Monitor -1. Open the [Actions tab](https://github.com/kusari-oss/darnit/actions). The `Release` workflow should appear within a few seconds of tag push. +1. Open the [Actions tab](https://github.com/darnitdevorg/darnit/actions). The `Release` workflow should appear within a few seconds of tag push. 2. Watch `preflight` first. If it fails: - Most common: version mismatch between tag and `pyproject.toml`. Delete the tag (`git push upstream --delete vX.Y.Z`), fix the bump commit, push a new tag. - Other gates (lint/tests/sync/doc-gen) should have been caught in pre-flight above. If they fire here, you skipped step 4. @@ -136,7 +152,7 @@ The `release.yml` workflow triggers automatically. **No `--force` or amend** — - Verify the user-facing surfaces: ```bash pip install darnit-mcp==X.Y.Z - docker pull ghcr.io/kusari-oss/darnit:vX.Y.Z + docker pull ghcr.io/darnitdevorg/darnit:vX.Y.Z # stable only: brew tap kusari-oss/tap && brew install darnit ``` diff --git a/packaging/RECOVERY.md b/packaging/RECOVERY.md index e174f98c..f5a29dbe 100644 --- a/packaging/RECOVERY.md +++ b/packaging/RECOVERY.md @@ -1,6 +1,6 @@ # Release Recovery Procedures -Per-channel repair procedures for partial-failure recovery. The release workflow does **not** support re-running on an already-published tag (preflight rejects it per FR-002). Recovery is always channel-by-channel. +Per-channel repair procedures for partial-failure recovery. A push of an already-released tag is rejected by preflight (FR-002); a failed run is repeated with `gh run rerun --failed` or `workflow_dispatch` on the same tag, and channels that already succeeded are skipped or left alone, as each section says. > If a `release-failure` issue references this file, find the channel section below and follow the procedure. @@ -19,21 +19,20 @@ If recovery is impossible (e.g., a wheel uploaded under the wrong version), the ## PyPI -The four per-package publish jobs (`publish-darnit`, `publish-darnit-baseline`, `publish-darnit-gittuf`, `publish-darnit-mcp`) are sequenced via `needs:` and `darnit` always publishes first. A failure can leave some packages uploaded and others not. +The per-package publish jobs (`publish-darnit-core`, `publish-darnit-baseline`, `publish-darnit-gittuf`, `publish-darnit-reproducibility`, `publish-darnit-mcp`; the list is [`packaging/pypi/public-packages.txt`](pypi/public-packages.txt)) are sequenced via `needs:`, `darnit-core` first. Each publishes with PyPI Trusted Publishing from the `release` environment and `skip-existing: true`, so a job that runs again for a version already on the index skips the upload instead of failing. There is no manual publish path: nothing outside `release.yml` holds a credential for PyPI. ### Failure mode 1 — One or more packages uploaded; one failed mid-flight **Symptom**: `release.yml` shows green for some `publish-*` jobs and red for others. PyPI/TestPyPI shows the green ones live. -**Important**: PyPI versions are **immutable**. You cannot re-upload version `X.Y.Z` after one byte of it is on the index, even via the `release.yml` re-run path (which preflight rejects anyway). +**Important**: PyPI versions are **immutable**. A file that is on the index cannot be replaced; `skip-existing` only lets the workflow pass over it. **Procedure**: 1. Determine which packages uploaded successfully. The `release.yml` job summary names each failed publish; PyPI's project page is the source of truth (`https://pypi.org/project///`). -2. **For the failed packages**: - - If the failure was transient (network, OIDC token, rate limit) and re-running the publish step succeeds: download the dist artifact from the failed workflow run via `gh run download --name dist`. Manually invoke the publish action against the package's `dist//` subdirectory, using the same OIDC environment. PyPI is **not** idempotent — if even one wheel for the version is up, the whole upload is rejected. - - If the failure was permanent (bad metadata, missing classifier, name collision): **roll forward to a fresh version**. PyPI version numbers cannot be reused; bump to a patch (`X.Y.Z+1`) and re-tag. The already-uploaded packages from the failed run remain on PyPI at the original version but will be unused — yank them via `https://pypi.org/manage/project//release//`. -3. **For the succeeded packages**: leave them. Yanking is only necessary if the bad release was already advertised. +2. **If the failure was transient** (network, OIDC token exchange, rate limit, a Trusted Publisher not yet configured for that package): fix the cause if there is one, then run the workflow again for the same tag. Either re-run the failed jobs of the same run (`gh run rerun --failed`), which reuses the wheels that run built, or start a fresh run with `workflow_dispatch` and the tag as input, which rebuilds them from the tag. Both go through the `release` environment approval again. Already-published packages are skipped by `skip-existing`; the ones that failed are published. The tag push path is not usable for this: preflight rejects a tag that already has a GitHub Release, and the `release` job only runs once every publish job succeeded, so a run that failed in a publish job has no Release yet. +3. **If the failure was permanent** (bad metadata, missing classifier, name collision): **roll forward to a fresh version**. PyPI version numbers cannot be reused; bump to a patch (`X.Y.Z+1`) and re-tag. The already-uploaded packages from the failed run remain on PyPI at the original version but will be unused — yank them via `https://pypi.org/manage/project//release//`. +4. **For the succeeded packages**: leave them. Yanking is only necessary if the bad release was already advertised. ### Failure mode 2 — Sigstore attestation upload failed diff --git a/packaging/container/Dockerfile b/packaging/container/Dockerfile index d747df90..e1a88769 100644 --- a/packaging/container/Dockerfile +++ b/packaging/container/Dockerfile @@ -11,9 +11,9 @@ # venv + the runtime CLIs darnit's controls need (git, gh) — no build # toolchain, no apt cache, no pip cache. # -# Build arg: VERSION — the exact darnit-mcp release version to install. -# The release workflow passes the tagged version; for local testing, -# override with --build-arg VERSION=. +# Build args: +# VERSION — the exact darnit-mcp release version to install. +# dist/ — release.yml copies this run's built wheels into the Docker context. # Base image is pinned to the major-minor tag rather than a digest. Pinning # to a digest is stronger but requires per-release digest churn; we accept @@ -33,12 +33,23 @@ RUN test -n "$VERSION" || (echo "ERROR: --build-arg VERSION is required" >&2 && # darnit packages are pure Python or arrive with prebuilt wheels. WORKDIR /opt +COPY dist /tmp/dist -# Install darnit-mcp from PyPI at the pinned version into a venv. The -# venv gets copied wholesale into the runtime stage. +# Install the Darnit wheels produced by this release run into a venv. External +# dependencies still resolve from PyPI, but Darnit packages never come from +# TestPyPI during rc image builds. RUN python -m venv /opt/venv \ && /opt/venv/bin/pip install --no-cache-dir --upgrade pip \ - && /opt/venv/bin/pip install --no-cache-dir "darnit-mcp==${VERSION}" + && for pkg in darnit-core darnit-baseline darnit-gittuf darnit-reproducibility darnit-mcp; do \ + test -f /tmp/dist/${pkg}/*-${VERSION}-*.whl || (echo "ERROR: missing ${pkg} wheel for ${VERSION}" >&2 && exit 1); \ + done \ + && /opt/venv/bin/pip install --no-cache-dir \ + --index-url https://pypi.org/simple/ \ + /tmp/dist/darnit-core/*-${VERSION}-*.whl \ + /tmp/dist/darnit-baseline/*-${VERSION}-*.whl \ + /tmp/dist/darnit-gittuf/*-${VERSION}-*.whl \ + /tmp/dist/darnit-reproducibility/*-${VERSION}-*.whl \ + /tmp/dist/darnit-mcp/*-${VERSION}-*.whl # ---- Stage 2: runtime ------------------------------------------------------ FROM python:3.12-slim-bookworm @@ -47,11 +58,11 @@ ARG VERSION LABEL org.opencontainers.image.title="darnit" LABEL org.opencontainers.image.description="AI-powered compliance auditing framework" -LABEL org.opencontainers.image.source="https://github.com/kusari-oss/darnit" +LABEL org.opencontainers.image.source="https://github.com/darnitdevorg/darnit" LABEL org.opencontainers.image.licenses="Apache-2.0" LABEL org.opencontainers.image.vendor="Kusari" -LABEL org.opencontainers.image.url="https://github.com/kusari-oss/darnit" -LABEL org.opencontainers.image.documentation="https://github.com/kusari-oss/darnit/blob/main/docs/install/container.md" +LABEL org.opencontainers.image.url="https://github.com/darnitdevorg/darnit" +LABEL org.opencontainers.image.documentation="https://github.com/darnitdevorg/darnit/blob/main/docs/install/container.md" # Install runtime CLIs the audit controls invoke (git for repo inspection; # gh for GitHub API calls). Pin to apt's stable channel; the GitHub CLI diff --git a/packaging/container/README.md b/packaging/container/README.md index 22988fe5..1f9fbe23 100644 --- a/packaging/container/README.md +++ b/packaging/container/README.md @@ -1,6 +1,6 @@ # darnit container image -Official darnit container image published to `ghcr.io/kusari-oss/darnit` on every release tag. +Official darnit container image published to `ghcr.io/darnitdevorg/darnit` on every release tag. > End-user install documentation: [`docs/install/container.md`](../../docs/install/container.md). This README sits next to the Dockerfile and is the source for the image's overview on GHCR. @@ -8,29 +8,29 @@ Official darnit container image published to `ghcr.io/kusari-oss/darnit` on ever ```bash # Pin to a specific release -docker pull ghcr.io/kusari-oss/darnit:v0.1.0 +docker pull ghcr.io/darnitdevorg/darnit:v0.1.0 # Latest stable -docker pull ghcr.io/kusari-oss/darnit:latest +docker pull ghcr.io/darnitdevorg/darnit:latest # Pre-release (not promoted to :latest) -docker pull ghcr.io/kusari-oss/darnit:v0.1.0rc1 +docker pull ghcr.io/darnitdevorg/darnit:v0.1.0rc1 # Rolling build of main (unsigned) -docker pull ghcr.io/kusari-oss/darnit:edge +docker pull ghcr.io/darnitdevorg/darnit:edge ``` ## Run ```bash # Audit the current directory -docker run --rm -v "$PWD:/repo" ghcr.io/kusari-oss/darnit:latest audit +docker run --rm -v "$PWD:/repo" ghcr.io/darnitdevorg/darnit:latest audit # Run as the MCP server (stdio) -docker run --rm -i ghcr.io/kusari-oss/darnit:latest mcp +docker run --rm -i ghcr.io/darnitdevorg/darnit:latest mcp # Print the bundled version -docker run --rm ghcr.io/kusari-oss/darnit:latest --version +docker run --rm ghcr.io/darnitdevorg/darnit:latest --version ``` The image's `WORKDIR` is `/repo`. The entrypoint dispatches the first arg to `darnit` (for `audit`/`remediate`/`list-controls`/`plan`/`profiles`/`validate`/`--version`/`--help`), to `darnit serve` for `mcp`, or executes the user's command directly otherwise. @@ -38,7 +38,7 @@ The image's `WORKDIR` is `/repo`. The entrypoint dispatches the first arg to `da ## Image contents - **Base**: `python:3.12-slim-bookworm` -- **darnit**: installed from PyPI via `pip install darnit-mcp==` (the version is recorded as the `org.opencontainers.image.version` OCI label) +- **darnit**: installed from the release workflow's built wheels for the pinned `darnit-mcp` version (the version is recorded as the `org.opencontainers.image.version` OCI label) - **CLIs**: `git`, `gh` (GitHub CLI from `cli.github.com`) - **Runtime user**: `darnit` (uid 10001), non-root @@ -61,8 +61,8 @@ The release image is multi-arch: Every release tag (stable and pre-release) is signed with cosign keyless OIDC. ```bash -cosign verify ghcr.io/kusari-oss/darnit:v0.1.0 \ - --certificate-identity-regexp '^https://github\.com/kusari-oss/darnit/\.github/workflows/release\.yml@' \ +cosign verify ghcr.io/darnitdevorg/darnit:v0.1.0 \ + --certificate-identity-regexp '^https://github\.com/darnitdevorg/darnit/\.github/workflows/release\.yml@' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com ``` @@ -73,7 +73,7 @@ The `:edge` rolling build is **not** signed — it's for development only. Each signed image has an SPDX-JSON SBOM attached via cosign attestation: ```bash -cosign download attestation ghcr.io/kusari-oss/darnit:v0.1.0 \ +cosign download attestation ghcr.io/darnitdevorg/darnit:v0.1.0 \ | jq -r '.payload' | base64 -d | jq '.predicate' > darnit-sbom.spdx.json ``` @@ -83,13 +83,20 @@ Compressed-size target: 300 MB (soft). The release workflow records the size in ## Building locally -The image is normally built by the release pipeline, but you can build it locally for testing: +The image is normally built by the release pipeline, but you can build it locally for testing. The Dockerfile installs the darnit wheels from `dist/` in the build context, so build them first: ```bash -# Build for the current platform only. VERSION must be a real darnit-mcp -# release on PyPI (or TestPyPI if you adjust the install command). +# 1. Build the wheels into dist//, the layout the Dockerfile expects. +while IFS= read -r pkg; do + [ -z "$pkg" ] && continue + uv build --package "$pkg" --out-dir "dist/$pkg" +done < packaging/pypi/public-packages.txt + +# 2. Build the image for the current platform. VERSION must be the version +# of the wheels you just built. +VERSION=$(awk -F'"' '/^version[[:space:]]*=/{print $2; exit}' pyproject.toml) docker build \ - --build-arg VERSION=0.1.0 \ + --build-arg VERSION="$VERSION" \ -t darnit-local \ -f packaging/container/Dockerfile \ . @@ -97,7 +104,7 @@ docker build \ docker run --rm -v "$PWD:/repo" darnit-local audit ``` -The `--build-arg VERSION` is required — the Dockerfile refuses to build without an explicit pinned version. +The `--build-arg VERSION` is required. The Dockerfile refuses to build without it, and fails if `dist/` has no wheel of that version for one of the darnit packages. Only the dependencies are downloaded, from PyPI. ## Recovery from a bad image diff --git a/specs/012-packaging-distribution/contracts/pypi-publish-contract.md b/specs/012-packaging-distribution/contracts/pypi-publish-contract.md index 16b84b2c..d39f6e24 100644 --- a/specs/012-packaging-distribution/contracts/pypi-publish-contract.md +++ b/specs/012-packaging-distribution/contracts/pypi-publish-contract.md @@ -60,7 +60,7 @@ pip install --index-url $INDEX --pre $PKG==$VERSION darnit --version # for darnit-mcp; "$PKG --help" or import smoke for others ``` -`pip install --verify-attestations $PKG==$VERSION` runs additionally for stable releases on `pypi.org`, where the attestation API is available. +For releases published with Trusted Publishing, the smoke additionally fetches the wheel's PEP 740 provenance from the index's integrity API, checks that every `publisher` is `release.yml` in the `release` environment of this repository, and runs `pypi-attestations verify pypi --repository --provenance-file `. (pip has no `--verify-attestations` option; verification is a separate tool.) ## Pre-flight assertions (per package) diff --git a/specs/012-packaging-distribution/research.md b/specs/012-packaging-distribution/research.md index 913d433c..45f4f60d 100644 --- a/specs/012-packaging-distribution/research.md +++ b/specs/012-packaging-distribution/research.md @@ -49,7 +49,7 @@ A future hardened-image variant (Chainguard- or distroless-based) is tracked as **Rationale**: - All three mechanisms share the same OIDC identity (the GitHub Actions workflow's identity), so one signing identity covers every artifact across every channel. - No long-lived publishing tokens or signing keys live anywhere — satisfies spec FR-007. -- Sigstore attestations on PyPI are now the default expectation for serious open-source Python projects and integrate with `pip install --verify-attestations` (PEP 740). +- Sigstore attestations on PyPI (PEP 740) are now the default expectation for serious open-source Python projects; users verify them with `pypi-attestations verify pypi` against the downloaded wheel (pip itself has no verification option). - cosign + GHCR provides verifiable image signatures consumable by every major policy engine (Kyverno, Connaisseur, Sigstore Policy Controller). - The same cosign workflow signs detached blobs for binary downloads. Users verify with `cosign verify-blob`.