diff --git a/.github/build-constraints.txt b/.github/build-constraints.txt new file mode 100644 index 0000000..61fd623 --- /dev/null +++ b/.github/build-constraints.txt @@ -0,0 +1,2 @@ +# CI builds on x86_64 Linux. Only accept the published wheel for that platform. +maturin==1.15.0 --hash=sha256:653020a63525bb224e5ab0adf02e17a2e08bc86dbea7fc1399c9a56d7529b99e diff --git a/.github/install-rust.sh b/.github/install-rust.sh new file mode 100644 index 0000000..7cb0f72 --- /dev/null +++ b/.github/install-rust.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +version=$1 +case "$version" in + 1.97.0) sha256=1cf17e4905b841d4c8e3f76467ac148d55fb3f54bf213c86f0d287a36471d904 ;; + 1.99.1) sha256=231fcae47ee950621374725b5fc3b5441d2fbda2955ac45dd7dc816d8876cfe7 ;; + *) echo "No pinned Rust distribution for $version" >&2; exit 1 ;; +esac + +# The standalone distribution includes cargo, clippy and rustfmt under one digest. +target=x86_64-unknown-linux-gnu +dist="rust-$version-$target" +cd "$RUNNER_TEMP" +curl --fail --silent --show-error --location --retry 3 "https://static.rust-lang.org/dist/$dist.tar.xz" -o "$dist.tar.xz" +echo "$sha256 $dist.tar.xz" | sha256sum --check +tar -xf "$dist.tar.xz" +"$dist/install.sh" --prefix="$RUNNER_TEMP/rust" --disable-ldconfig \ + --components="rustc,cargo,rust-std-$target,rustfmt-preview,clippy-preview" +echo "$RUNNER_TEMP/rust/bin" >> "$GITHUB_PATH" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 501eef1..ebfdbf3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,30 +3,36 @@ name: CI on: push: pull_request: + workflow_call: permissions: contents: read +env: + # Explicit installs use the Python archive hashes embedded in the pinned uv binary. + UV_PYTHON_DOWNLOADS: manual + UV_MANAGED_PYTHON: 'true' + jobs: build: runs-on: ubuntu-latest steps: - - &checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - &setup-uv - uses: &setup-uv-action astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 - with: - version: &uv-version '0.12.23' - - uses: &setup-python-action actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: - python-version: '3.14' - - uses: &rust-toolchain dtolnay/rust-toolchain@7e38f4b43b4db5c8dd498af069a4f6196df1d067 - with: - toolchain: '1.97.0' + version: '0.13.6' + checksum: f29c3e94ffabd7e6f3ba521dba169a9b7ec12100bf3257087f0f24f6123f3645 + # A restored dependency cache can bypass archive verification on a fresh runner. + enable-cache: false + - run: uv python install 3.14.10 + - run: bash .github/install-rust.sh 1.97.0 # Build abi3 once so both interpreters exercise the same binary. - name: Build the source distribution and stable ABI wheel + env: + UV_BUILD_CONSTRAINT: ${{ github.workspace }}/.github/build-constraints.txt + UV_REQUIRE_HASHES: 'true' run: | dist="$RUNNER_TEMP/dist" uv build --sdist --no-sources --out-dir "$dist" --python 3.14 @@ -42,25 +48,26 @@ jobs: test: needs: build runs-on: ubuntu-latest - strategy: &toolchains + strategy: matrix: include: - - python: '3.14' + - python: '3.14.10' rust: '1.97.0' - - python: '3.15' - rust: stable + - python: '3.15.1' + rust: '1.99.1' steps: - - *checkout - - *setup-uv - - &setup-python - uses: *setup-python-action + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - python-version: ${{ matrix.python }} - - uses: *rust-toolchain + persist-credentials: false + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: - toolchain: ${{ matrix.rust }} + version: '0.13.6' + checksum: f29c3e94ffabd7e6f3ba521dba169a9b7ec12100bf3257087f0f24f6123f3645 + enable-cache: false + - run: uv python install ${{ matrix.python }} + - run: bash .github/install-rust.sh ${{ matrix.rust }} - name: Install locked test dependencies - run: uv sync --locked --no-install-project --python ${{ matrix.python }} + run: uv sync --locked --no-build --no-install-project --python ${{ matrix.python }} - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: sdist-and-abi3 @@ -69,6 +76,8 @@ jobs: # Exercise selector limits with abort in the native wheels and the default strategy in abi3. env: CARGO_PROFILE_RELEASE_PANIC: abort + UV_BUILD_CONSTRAINT: ${{ github.workspace }}/.github/build-constraints.txt + UV_REQUIRE_HASHES: 'true' run: >- uv build "$RUNNER_TEMP"/dist/*.tar.gz --wheel --no-sources --out-dir "$RUNNER_TEMP/dist" --python .venv/bin/python @@ -76,11 +85,9 @@ jobs: # Both wheels have the same version. Force each install and keep uv from # syncing the editable project back into the test environment. - name: Test the native and stable ABI wheels - env: - PYTHON_VERSION: ${{ matrix.python }} run: | dist="$RUNNER_TEMP/dist" - cp="cp${PYTHON_VERSION//./}" + cp=$(.venv/bin/python -c 'import sys; print("cp%d%d" % sys.version_info[:2])') for tag in "$cp-$cp" cp314-abi3; do uv pip install --python .venv/bin/python --no-deps --strict --reinstall "$dist"/*-"$tag"-*.whl uv run --no-sync python -I -m pytest --import-mode=importlib @@ -88,17 +95,26 @@ jobs: lint: runs-on: ubuntu-latest - strategy: *toolchains + strategy: + matrix: + include: + - python: '3.14.10' + rust: '1.97.0' + - python: '3.15.1' + rust: '1.99.1' steps: - - *checkout - - *setup-uv - - *setup-python - - uses: *rust-toolchain + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: - toolchain: ${{ matrix.rust }} - components: rustfmt, clippy + version: '0.13.6' + checksum: f29c3e94ffabd7e6f3ba521dba169a9b7ec12100bf3257087f0f24f6123f3645 + enable-cache: false + - run: uv python install ${{ matrix.python }} + - run: bash .github/install-rust.sh ${{ matrix.rust }} - name: Install locked lint dependencies - run: uv sync --locked --no-install-project --group benchmark --python ${{ matrix.python }} + run: uv sync --locked --no-build --no-install-project --group benchmark --python ${{ matrix.python }} - run: uv run --no-sync ruff check . - run: uv run --no-sync ruff format --check . - run: uv run --no-sync ty check @@ -108,14 +124,17 @@ jobs: zizmor: runs-on: ubuntu-latest steps: - - *checkout - - uses: *setup-uv-action + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: - version: *uv-version - # Leave the shared uv cache to jobs that install project dependencies. + version: '0.13.6' + checksum: f29c3e94ffabd7e6f3ba521dba169a9b7ec12100bf3257087f0f24f6123f3645 enable-cache: false + - run: uv python install 3.14.10 - name: Install the locked zizmor wheel - run: uv sync --locked --only-group zizmor --no-build + run: uv sync --locked --only-group zizmor --no-build --python 3.14.10 - name: Audit GitHub Actions env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..67cea63 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,84 @@ +name: Publish release + +on: + # The pypi job checks the dispatch, run identity, and main ancestry before consuming the artifact. + workflow_run: # zizmor: ignore[dangerous-triggers] + workflows: [Start release] + types: [completed] + branches: [main] + +permissions: {} + +concurrency: + group: publish-release + cancel-in-progress: false + +jobs: + pypi: + # workflow_run has publishing privileges. Only consume a successful manual run in this repository. + if: >- + github.repository == 'astral-experiments/astral-html-python' && + github.event.workflow_run.head_repository.full_name == github.repository && + github.event.workflow_run.path == '.github/workflows/start-release.yml' && + github.event.workflow_run.event == 'workflow_dispatch' && + github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/astral-html/ + permissions: + actions: read + contents: read + id-token: write + steps: + - name: Verify the source commit is on main + env: + GH_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ github.event.workflow_run.head_sha }} + # workflow_run reports a short branch name, which a same-named tag could also have. + run: | + main_sha=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/main" --jq .object.sha) + gh api "repos/$GITHUB_REPOSITORY/compare/$RELEASE_SHA...$main_sha" --jq .status | grep -Ex 'ahead|identical' + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: release-${{ github.event.workflow_run.run_attempt }} + path: release + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + version: '0.13.6' + checksum: f29c3e94ffabd7e6f3ba521dba169a9b7ec12100bf3257087f0f24f6123f3645 + enable-cache: false + - run: uv publish --trusted-publishing always --check-url https://pypi.org/simple release/*.tar.gz + + github: + needs: pypi + runs-on: ubuntu-latest + permissions: + actions: read + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # workflow_run's GITHUB_SHA can be newer than the commit CI tested. + ref: ${{ github.event.workflow_run.head_sha }} + persist-credentials: false + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: release-${{ github.event.workflow_run.run_attempt }} + path: release + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + - name: Tag the published commit and release its changelog entry + env: + GH_TOKEN: ${{ github.token }} + run: | + tag=$(cat release/tag) + gh auth setup-git + git push origin "HEAD:refs/tags/$tag" + if gh release view "$tag" --json isDraft --jq '.isDraft == false' | grep -qx true; then + exit 0 + fi + gh release create "$tag" --verify-tag --title "$tag" --notes-file release/notes.md \ + --prerelease="$(cat release/prerelease)" diff --git a/.github/workflows/start-release.yml b/.github/workflows/start-release.yml new file mode 100644 index 0000000..e510190 --- /dev/null +++ b/.github/workflows/start-release.yml @@ -0,0 +1,76 @@ +name: Start release + +on: + workflow_dispatch: + inputs: + version: + description: Version to publish from main, without the leading v + required: true + type: string + +permissions: + contents: read + +env: + # Explicit installs use the Python archive hashes embedded in the pinned uv binary. + UV_PYTHON_DOWNLOADS: manual + UV_MANAGED_PYTHON: 'true' + +jobs: + ci: + if: github.repository == 'astral-experiments/astral-html-python' && github.ref == 'refs/heads/main' + uses: $/.github/workflows/ci.yml + + prepare: + needs: ci + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: sdist-and-abi3 + path: dist + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + version: '0.13.6' + checksum: f29c3e94ffabd7e6f3ba521dba169a9b7ec12100bf3257087f0f24f6123f3645 + enable-cache: false + - run: uv python install 3.14.10 + - name: Select the tested sdist and its changelog entry + env: + VERSION: ${{ inputs.version }} + run: | + sdists=(dist/*.tar.gz) + if [[ ${#sdists[@]} != 1 || ${sdists[0]} != "dist/astral_html-$VERSION.tar.gz" ]]; then + echo "::error::Requested version $VERSION does not match the built sdist" + exit 1 + fi + if git show-ref --verify --quiet "refs/tags/v$VERSION"; then + test "$(git rev-parse "refs/tags/v$VERSION^{commit}")" = "$GITHUB_SHA" + fi + mkdir release + awk -v heading="## $VERSION" ' + /^## / { selected = ($0 == heading); next } + selected { print } + ' CHANGELOG.md > release/notes.md + if ! grep -q '[^[:space:]]' release/notes.md; then + echo "::error::Missing changelog entry for $VERSION" + exit 1 + fi + mv "${sdists[0]}" release/ + printf 'v%s\n' "$VERSION" > release/tag + uv run --locked --no-build --only-group dev --python 3.14.10 python - <<'PY' > release/prerelease + import os + from packaging.version import Version + + print(str(Version(os.environ["VERSION"]).is_prerelease).lower()) + PY + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + # A rerun may rebuild the sdist. Bind publication to this attempt's bytes. + name: release-${{ github.run_attempt }} + path: release/ + if-no-files-found: error diff --git a/AGENTS.md b/AGENTS.md index 151a821..ebb2e67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,4 +2,5 @@ * The Readme is concise and only contains deep information. Do not add random information there. Keep source-build instructions in the development guide so the quick start opens with Python usage. * Consider if you need really new sections in the documentation, or if you can avoid it. * When the reason for a fix or improvement isn't clear from the code, add or update a comment beside the relevant logic. Explain the constraint it satisfies or the failure it prevents. -* If we undo something previously done, document that, so we don't do it again. \ No newline at end of file +* If we undo something previously done, document that, so we don't do it again. +* CI may only download actions, tools, and dependencies pinned by hash. Pin actions to full commit SHAs and verify downloaded artifacts against trusted, pinned digests before use. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8d6d8dc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + +## 0.0.1 + +- Parse HTML from Python and query elements with CSS selectors. +- Read element attributes, extract descendant text, and iterate over mixed text and child elements in source order. diff --git a/docs/development.md b/docs/development.md index cc72e9c..485f883 100644 --- a/docs/development.md +++ b/docs/development.md @@ -42,5 +42,6 @@ done The `-I` flag prevents Python from adding the checkout to the import path. -Set the package version in `pyproject.toml` and `Cargo.toml`. -[Cargo.toml](../Cargo.toml) ยท [CI workflow](../.github/workflows/ci.yml) +For a release, update the version in `pyproject.toml` and `Cargo.toml`, refresh +both lockfiles, and add a matching entry to [CHANGELOG.md](../CHANGELOG.md). +After merging, run **Start release** on `main` with that version.