Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
255 changes: 163 additions & 92 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -466,8 +466,8 @@ jobs:
releases/**/*.exe.sha256
if-no-files-found: error

publish-pypi:
name: Publish to PyPI
validate-built-artifacts:
name: Validate built release artifacts
runs-on: ubuntu-latest
needs:
- build-python
Expand All @@ -487,93 +487,6 @@ jobs:
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}

- name: Report recovery PyPI policy
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.publish_pypi != 'true' }}
run: |
echo "INFO: PyPI publish disabled for recovery run"

- name: Verify PyPI namespace ownership
if: ${{ github.event_name == 'push' || github.event.inputs.publish_pypi == 'true' }}
run: |
set -e
name=$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml","rb"))["project"]["name"])')
if [ "$name" != "ecli-editor" ]; then
echo "::error::pyproject.toml [project].name is '$name', expected 'ecli-editor'."
echo "::error::Reserved PyPI namespace is 'ecli-editor'. Either update pyproject.toml or reserve the new name first."
exit 1
fi
lookup=$(python3 -m pip index versions ecli-editor 2>&1 || true)
if ! echo "$lookup" | grep -q "Available versions"; then
echo "::error::PyPI project 'ecli-editor' lookup failed. Network issue or namespace deletion?"
echo "$lookup"
exit 1
fi
echo "::notice::PyPI namespace ecli-editor confirmed. Current versions:"
echo "$lookup"

- name: Download Python distributions
if: ${{ github.event_name == 'push' || github.event.inputs.publish_pypi == 'true' }}
uses: actions/download-artifact@v4
with:
name: python-dist
path: dist

- name: Normalize and verify PyPI distributions
if: ${{ github.event_name == 'push' || github.event.inputs.publish_pypi == 'true' }}
run: |
set -euo pipefail
echo "Downloaded Python distribution artifact layout:"
find dist -maxdepth 3 -type f -print | sort
mkdir -p dist-flat
find dist -maxdepth 3 -type f \( -name '*.whl' -o -name '*.tar.gz' \) -exec cp {} dist-flat/ \;
rm -rf dist
mkdir -p dist
shopt -s nullglob
dists=(dist-flat/*)
if (( ${#dists[@]} == 0 )); then
echo "::error::No wheel or sdist files found in downloaded python-dist artifact."
exit 1
fi
mv "${dists[@]}" dist/
echo "Flattened PyPI distribution layout:"
find dist -maxdepth 1 -type f -print | sort
python3 -m pip install --upgrade twine
python3 -m twine check dist/*

- name: Publish to PyPI
if: ${{ github.event_name == 'push' || github.event.inputs.publish_pypi == 'true' }}
# Pinned from pypa/gh-action-pypi-publish release/v1 on 2026-05-10.
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b
with:
password: ${{ secrets.PYPI_API_TOKEN }}
attestations: false

publish-github-release:
name: Publish GitHub release
runs-on: ubuntu-latest
needs:
- build-python
- build-linux
- build-freebsd
- build-macos
- build-windows
if: |
always() &&
(github.event_name == 'push' || github.event.inputs.publish_github_release != 'false') &&
needs.build-python.result == 'success' &&
needs.build-linux.result == 'success' &&
needs.build-freebsd.result == 'success' &&
needs.build-macos.result == 'success' &&
needs.build-windows.result == 'success'
permissions:
contents: write
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
Expand All @@ -586,20 +499,27 @@ jobs:
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "tag=v${version}" >> "$GITHUB_OUTPUT"

- name: Install final artifact validation tooling
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y --no-install-recommends cpio rpm
python3 -m pip install --upgrade twine

- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: release-inputs
merge-multiple: true

- name: Assemble and verify exact 21 GitHub Release assets
- name: Assemble release assets and adjacent checksum evidence
run: |
set -euo pipefail
shopt -s globstar nullglob
version="${{ steps.release_meta.outputs.version }}"
release_dir="releases/${version}"
rm -rf "$release_dir"
mkdir -p "$release_dir/.checksums"
mkdir -p "$release_dir"

copy_one() {
local pattern="$1"
Expand Down Expand Up @@ -685,15 +605,166 @@ jobs:

for asset in "$release_dir"/*; do
test -f "$asset" || continue
(cd "$release_dir" && sha256sum "$(basename "$asset")" > ".checksums/$(basename "$asset").sha256")
(cd "$release_dir" && sha256sum "$(basename "$asset")" > "$(basename "$asset").sha256")
done

- name: Validate final built artifacts
run: |
set -euo pipefail
make validate-built-artifacts MACOS_ASSERT_MODE=structural

- name: Stage checksum evidence and verify exact 21 GitHub Release assets
run: |
set -euo pipefail
shopt -s nullglob
version="${{ steps.release_meta.outputs.version }}"
release_dir="releases/${version}"
mkdir -p "$release_dir/.checksums"
sidecars=( "$release_dir"/*.sha256 )
if (( ${#sidecars[@]} == 0 )); then
echo "::error::No checksum sidecars were generated for $release_dir"
exit 3
fi
for sidecar in "${sidecars[@]}"; do
mv "$sidecar" "$release_dir/.checksums/$(basename "$sidecar")"
done
python3 scripts/verify_release_assets.py \
--version "$version" \
--f4-evidence-dir build/evidence/f4_linter_provisioning
find "$release_dir" -maxdepth 1 -type f -print | sort
find "$release_dir/.checksums" -maxdepth 1 -type f -print | sort

- name: Upload validated release directory
uses: actions/upload-artifact@v4
with:
name: validated-release-assets
path: |
releases/${{ steps.release_meta.outputs.version }}/ecli_*
releases/${{ steps.release_meta.outputs.version }}/.checksums/*.sha256
include-hidden-files: true
if-no-files-found: error

publish-pypi:
name: Publish to PyPI
runs-on: ubuntu-latest
needs:
- validate-built-artifacts
if: |
always() &&
(github.event_name == 'push' || github.event.inputs.publish_pypi == 'true') &&
needs.validate-built-artifacts.result == 'success'
permissions:
contents: read
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}

- name: Report recovery PyPI policy
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.publish_pypi != 'true' }}
run: |
echo "INFO: PyPI publish disabled for recovery run"

- name: Verify PyPI namespace ownership
if: ${{ github.event_name == 'push' || github.event.inputs.publish_pypi == 'true' }}
run: |
set -e
name=$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml","rb"))["project"]["name"])')
if [ "$name" != "ecli-editor" ]; then
echo "::error::pyproject.toml [project].name is '$name', expected 'ecli-editor'."
echo "::error::Reserved PyPI namespace is 'ecli-editor'. Either update pyproject.toml or reserve the new name first."
exit 1
fi
lookup=$(python3 -m pip index versions ecli-editor 2>&1 || true)
if ! echo "$lookup" | grep -q "Available versions"; then
echo "::error::PyPI project 'ecli-editor' lookup failed. Network issue or namespace deletion?"
echo "$lookup"
exit 1
fi
echo "::notice::PyPI namespace ecli-editor confirmed. Current versions:"
echo "$lookup"

- name: Download Python distributions
if: ${{ github.event_name == 'push' || github.event.inputs.publish_pypi == 'true' }}
uses: actions/download-artifact@v4
with:
name: python-dist
path: dist

- name: Normalize and verify PyPI distributions
if: ${{ github.event_name == 'push' || github.event.inputs.publish_pypi == 'true' }}
run: |
set -euo pipefail
echo "Downloaded Python distribution artifact layout:"
find dist -maxdepth 3 -type f -print | sort
mkdir -p dist-flat
find dist -maxdepth 3 -type f \( -name '*.whl' -o -name '*.tar.gz' \) -exec cp {} dist-flat/ \;
rm -rf dist
mkdir -p dist
shopt -s nullglob
dists=(dist-flat/*)
if (( ${#dists[@]} == 0 )); then
echo "::error::No wheel or sdist files found in downloaded python-dist artifact."
exit 1
fi
mv "${dists[@]}" dist/
echo "Flattened PyPI distribution layout:"
find dist -maxdepth 1 -type f -print | sort
python3 -m pip install --upgrade twine
python3 -m twine check dist/*

- name: Publish to PyPI
if: ${{ github.event_name == 'push' || github.event.inputs.publish_pypi == 'true' }}
# Pinned from pypa/gh-action-pypi-publish release/v1 on 2026-05-10.
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b
with:
password: ${{ secrets.PYPI_API_TOKEN }}
attestations: false

publish-github-release:
name: Publish GitHub release
runs-on: ubuntu-latest
needs:
- validate-built-artifacts
if: |
always() &&
(github.event_name == 'push' || github.event.inputs.publish_github_release != 'false') &&
needs.validate-built-artifacts.result == 'success'
permissions:
contents: write
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}

- name: Read release metadata
id: release_meta
run: |
set -euo pipefail
version="$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml","rb"))["project"]["version"])')"
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "tag=v${version}" >> "$GITHUB_OUTPUT"

- name: Download validated release directory
uses: actions/download-artifact@v4
with:
name: validated-release-assets
path: releases/${{ steps.release_meta.outputs.version }}

- name: Verify exact 21 GitHub Release assets before upload
run: |
set -euo pipefail
version="${{ steps.release_meta.outputs.version }}"
release_dir="releases/${version}"
python3 scripts/verify_release_assets.py \
--version "$version"
find "$release_dir" -maxdepth 1 -type f -print | sort
find "$release_dir/.checksums" -maxdepth 1 -type f -print | sort

- name: Create or update GitHub Release
uses: softprops/action-gh-release@v2
with:
Expand Down
1 change: 0 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -1272,7 +1272,6 @@ validate-built-artifacts:
$(call validate_artifact_if_requested,$(FREEBSD_PKG_FILE),validate-freebsd-contract,FreeBSD)
$(call validate_artifact_if_requested,$(MACOS_PKG_FILE),validate-macos-contract,macOS)
$(call validate_artifact_if_requested,$(WIN_PKG_FILE),validate-windows-contract,Windows)
$(call validate_release_assets_if_present)
@echo "--> OK: built artifact validation completed"


Expand Down
10 changes: 8 additions & 2 deletions docs/release/release-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,18 @@ See the LICENSE file in the project root for full license text.
- [ ] `make help`, `make help-full`, `make list-targets`, `make doctor`, and
`make sysinfo` match current package surfaces and canonical Python
scripts.
- [ ] `make validate-gate2` passes before any publish step.
- [ ] `make validate-gate2` passes as source and structural contract
validation; it does not inspect ignored historical release artifacts.
- [ ] `make validate-official-evidence-drift` passes; this invokes
`scripts/f4_linter_linux_provisioning.py --check-official-evidence-drift`
and blocks release readiness if the Linux official distro evidence
registry drifts from generated evidence.
- [ ] `make validate-release-assets` passes against `releases/<version>/`.
- [ ] The canonical `Release` workflow `validate-built-artifacts` job downloads
all build outputs, assembles `releases/<version>/`, generates adjacent
`.sha256` sidecars, runs `make validate-built-artifacts`, and is required
by both GitHub Release and PyPI publication jobs.
- [ ] `make validate-release-assets` passes against `releases/<version>/` after
checksum sidecars are staged under `.checksums/`.
- [ ] Required packaging scripts exist and are executable.
- [ ] Active shell wrappers under `scripts/` are absent; Python entrypoints under
`scripts/` are canonical. Windows PowerShell packaging
Expand Down
23 changes: 17 additions & 6 deletions docs/release/release-process.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,21 @@ See the LICENSE file in the project root for full license text.
## Process Stages

1. Build platform artifacts
2. Validate artifact contract and checksums
3. Publish package artifacts
4. Publish Python distribution (if enabled in workflow)
5. Publish release notes and release assets
2. Assemble current-version release artifacts with adjacent checksum evidence
3. Run the built-artifact gate
4. Stage checksum sidecars under `.checksums/` and verify the exact 21-asset set
5. Publish package artifacts, release notes, and release assets

## Required Controls

- Contract validation must happen before publish.
- `make validate-gate2` is the required pre-publish validation gate.
- `make validate-gate2` is the source and structural contract gate; it must not
inspect ignored historical release directories.
- `make validate-built-artifacts` is the required built-artifact gate after the
current release directory is assembled with adjacent checksum sidecars and
before GitHub Release or PyPI publication can run.
- `make validate-release-assets` is the exact 21 ECLI-owned GitHub Release asset
gate.
gate after checksum sidecars have been staged under `.checksums/`.
- Missing required artifacts must block release.
- Every official ECLI release uploads exactly 21 ECLI-owned physical GitHub
Release assets, one per canonical matrix entry. Release publication is
Expand Down Expand Up @@ -104,6 +108,13 @@ it delegates to `validate-pypi-contract`, which requires `twine` for strict PyPI
wheel/sdist metadata validation. `twine` is declared only in release/development
tooling dependencies, not in ECLI runtime dependencies.

The canonical `Release` workflow runs `make validate-built-artifacts` in the
`validate-built-artifacts` job after downloading all build outputs, assembling
`releases/<version>/`, and generating adjacent `.sha256` sidecars. The workflow
then moves those sidecars under `releases/<version>/.checksums/`, runs
`scripts/verify_release_assets.py` for the final exact-21 contract, and only then
allows GitHub Release or PyPI publication jobs to run.

## PyPI Namespace Pre-Reservation

The Python distribution name is `ecli-editor`; the import package and console
Expand Down
Loading
Loading