Skip to content

fix(infra/snapshot): des points de reprise externes, et un « required… #58

fix(infra/snapshot): des points de reprise externes, et un « required…

fix(infra/snapshot): des points de reprise externes, et un « required… #58

Workflow file for this run

# Publish dsoxlab to PyPI when a version tag (vX.Y.Z) is pushed.
#
# Security model:
# - PyPI Trusted Publishing (OIDC). No API token or long-lived secret: the
# `publish` job mints a short-lived id-token that PyPI trusts. Configure the
# trusted publisher on PyPI (project: dsoxlab, workflow: release.yml,
# environment: pypi) before the first release.
# - Privilege separation: `build` compiles the artifacts with read-only access
# and runs project code; `publish` runs no project code, only downloads the
# built artifacts and hands them to PyPI with `id-token: write`.
# - The `pypi` GitHub Environment gates the publish step (add required
# reviewers / tag protection there for a manual approval before upload).
# - Every action is pinned to a full commit SHA; the default token has no
# permissions; no build cache in the publishing path (cache-poisoning
# hardening).
name: Release
on:
push:
tags:
- 'v*'
permissions: {}
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
name: Build distribution
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
id-token: write # mint the OIDC token used to sign the provenance
attestations: write # record the SLSA build provenance for the artifacts
steps:
- name: Harden the runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
with:
version: "0.11.26"
enable-cache: false # no cache in the publishing path (cache-poisoning hardening)
# The tag must match the version inside pyproject.toml. Twice now, a tag
# was pushed at a commit whose version had already moved on: v0.1.22 built
# the previous version, and v0.1.25 built 0.1.26 and published it under
# the wrong tag, so PyPI never received a 0.1.25 at all. Nothing in the
# pipeline objected, because the build reads pyproject and the tag is only
# used for the release notes. Fail loudly instead.
- name: Tag must match the packaged version
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
packaged=$(grep -m1 '^version = ' pyproject.toml | cut -d'"' -f2)
if [ "${TAG#v}" != "$packaged" ]; then
echo "::error::Tag $TAG does not match pyproject version $packaged." \
"Tag the commit that carries the matching version bump," \
"or bump pyproject to ${TAG#v} before tagging."
exit 1
fi
echo "Tag $TAG matches the packaged version $packaged."
- name: Build sdist and wheel
run: uv build
- name: Check distribution metadata
run: uvx twine check dist/*
# SLSA build provenance for the wheel and sdist, verifiable with
# `gh attestation verify <artifact> --repo stephrobert/dsoxlab` or
# slsa-verifier. Complements the PEP 740 attestations PyPI records.
#
# The two artifacts are listed explicitly rather than globbed with
# `dist/*`: `uv build` also drops a one-byte `dist/.gitignore`, and this
# action's glob matches dotfiles (unlike the shell glob that feeds
# `gh release create` below). v0.1.11 therefore attested `.gitignore`
# alongside the distributions — harmless, but an attestation should name
# exactly what is published, nothing more.
- name: Attest build provenance
id: attest
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: |
dist/*.whl
dist/*.tar.gz
# The attestation above is recorded on GitHub's attestation API, which is
# what `gh attestation verify` reads. It is NOT what OpenSSF Scorecard's
# Signed-Releases control looks at: that check wants a `*.intoto.jsonl`
# file attached as a release asset — a distinct artifact. Copy the bundle
# out under the expected name so `github_release` can attach it.
#
# The bundle path goes through `env:` rather than being interpolated into
# the script body, so no step output is ever expanded into shell code.
- name: Stage the provenance bundle as a release asset
env:
BUNDLE_PATH: ${{ steps.attest.outputs.bundle-path }}
run: |
set -euo pipefail
cp "$BUNDLE_PATH" provenance.intoto.jsonl
echo "Provenance bundle staged ($(wc -c < provenance.intoto.jsonl) bytes)"
- name: Upload distribution artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dist
path: dist/
if-no-files-found: error
retention-days: 1
- name: Upload provenance artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: provenance
path: provenance.intoto.jsonl
if-no-files-found: error
retention-days: 1
publish:
name: Publish to PyPI
needs: build
runs-on: ubuntu-24.04
timeout-minutes: 10
environment:
name: pypi
url: https://pypi.org/p/dsoxlab
permissions:
id-token: write # OIDC token for Trusted Publishing — the only scope needed
steps:
- name: Harden the runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Download distribution artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: dist
path: dist/
- name: Publish to PyPI (Trusted Publishing)
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
attestations: true
print-hash: true
github_release:
name: Create GitHub Release
# Only after PyPI succeeds — never publish a GitHub Release for a version
# that failed to reach PyPI.
needs: [build, publish]
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: write # create the Release and upload the built artifacts
steps:
- name: Harden the runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Download distribution artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: dist
path: dist/
- name: Download provenance artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: provenance
path: .
# Extract the CHANGELOG section for this tag. The tag is passed through an
# env var (never interpolated into the script body) to avoid injection.
- name: Extract changelog notes
env:
TAG: ${{ github.ref_name }}
run: |
version="${TAG#v}"
python3 - "$version" > release-notes.md <<'PY'
import pathlib, re, sys
version = sys.argv[1]
text = pathlib.Path("CHANGELOG.md").read_text(encoding="utf-8")
m = re.search(
r"^## \[" + re.escape(version) + r"\].*?(?=^## \[|\Z)",
text, re.S | re.M,
)
body = m.group(0).strip() if m else f"Release {version}"
lines = body.splitlines()
if lines and lines[0].startswith("## "):
lines = lines[1:] # drop the version heading (title carries it)
print("\n".join(lines).strip() or f"Release {version}")
PY
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
REPO: ${{ github.repository }}
run: |
gh release create "$TAG" \
--repo "$REPO" \
--title "$TAG" \
--notes-file release-notes.md \
dist/* provenance.intoto.jsonl