Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
16a5d84
test(release): reproduce checkout-free publication and immutable inve…
seonghobae Sep 13, 2026
7bb3fc7
fix(release): bind checkout-free publisher and verify immutable assets
seonghobae Sep 13, 2026
4438694
docs(release): require immutable state and signed asset verification
seonghobae Sep 13, 2026
a36413d
test(release): make subprocess return-code contract explicit
seonghobae Sep 14, 2026
06b9d56
test(release): prove tag and upload inventory binding
seonghobae Sep 14, 2026
cd00ba9
test(release): bind fake publisher to exact tag and assets
seonghobae Sep 14, 2026
b894fc2
test(release): reject omitted draft publication transition
seonghobae Sep 14, 2026
1c4129d
fix(release): bind fake publication state to draft transition
seonghobae Sep 14, 2026
ca9b73d
test(release): reject conflicting draft publication flags
seonghobae Sep 14, 2026
fd8bb39
fix(release): require one exact draft-clearing edit flag
seonghobae Sep 14, 2026
fa37849
test(release): reject publication before draft creation
seonghobae Sep 14, 2026
deb4df1
test(release): bind publication to created draft
seonghobae Sep 14, 2026
5dfe316
test(release): reject pre-publication attestation verification
seonghobae Sep 14, 2026
6204965
test(release): cover pre-publication asset attestation
seonghobae Sep 14, 2026
7dd5970
test(release): require publication before attestation checks
seonghobae Sep 14, 2026
075251d
test(release): require post-publication tag revalidation
seonghobae Sep 14, 2026
1188493
test(release): model post-publication tag identity
seonghobae Sep 14, 2026
7e6435f
fix(release): revalidate immutable tag after publication
seonghobae Sep 14, 2026
d7f59e9
docs(release): document post-publication tag binding
seonghobae Sep 14, 2026
4fcd481
test(release): require immutable metadata before tag binding
seonghobae Sep 14, 2026
3aeba8f
fix(release): bind tag after immutable publication proof
seonghobae Sep 14, 2026
b5f1769
docs(release): bind tag after immutable metadata proof
seonghobae Sep 14, 2026
443d0bf
test(release): require attestation read authority
seonghobae Sep 14, 2026
67ec4bd
fix(release): grant attestation read authority
seonghobae Sep 14, 2026
5e8caa1
test(release): bind required workflow evidence to integrating PR
seonghobae Sep 14, 2026
94e2202
fix(release): bind workflow evidence to integrating PR
seonghobae Sep 14, 2026
2820ef9
test(release): require verify-only public release recovery
seonghobae Sep 14, 2026
99a41c3
fix(release): verify existing public release without mutation
seonghobae Sep 14, 2026
4b073d3
test(release): model public inventory lookup before publication
seonghobae Sep 14, 2026
cb6f359
docs(release): document verify-only public release recovery
seonghobae Sep 14, 2026
7eeed33
style(test): document verify-only rerun harness
seonghobae Sep 14, 2026
84110ee
test(release): bind workflow evidence contract to PR events
seonghobae Sep 15, 2026
c525136
test(release): target remote attestation loop after inventory gate
seonghobae Sep 15, 2026
b61f301
test(release): lock exact integrating PR workflow selector
seonghobae Sep 15, 2026
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
68 changes: 56 additions & 12 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ jobs:

gh api --paginate --slurp \
-H "X-GitHub-Api-Version: 2022-11-28" \
"repos/${GITHUB_REPOSITORY}/actions/runs?head_sha=${SOURCE_HEAD_SHA}&per_page=100" \
"repos/${GITHUB_REPOSITORY}/actions/runs?head_sha=${SOURCE_HEAD_SHA}&event=pull_request&per_page=100" \
> "$run_pages"

security_scan_run_id=""
Expand Down Expand Up @@ -317,7 +317,8 @@ jobs:
fi
latest_run="$(
jq -c --arg path "$workflow_path" --arg head "$SOURCE_HEAD_SHA" \
'[.[] | .workflow_runs[]? | select(.path == $path and .head_sha == $head)] | sort_by(.created_at, .id) | last // empty' \
--argjson pr "$SOURCE_PR_NUMBER" \
'[.[] | .workflow_runs[]? | select(.path == $path and .head_sha == $head and .event == "pull_request" and ((.pull_requests // []) | any(.number == $pr)))] | sort_by(.created_at, .id) | last // empty' \
"$run_pages"
)"
if [ -z "$latest_run" ]; then
Expand Down Expand Up @@ -553,6 +554,7 @@ jobs:
timeout-minutes: 10
permissions:
actions: read
attestations: read
contents: write
steps:
- name: Download complete reviewed release evidence
Expand Down Expand Up @@ -591,21 +593,63 @@ jobs:
if [ -n "$existing_release" ]; then
release_id="${existing_release%%$'\t'*}"
release_is_draft="${existing_release#*$'\t'}"
if [ "$release_is_draft" != "true" ]; then
echo "::error::A public GitHub Release already exists for this tag."
exit 1
if [ "$release_is_draft" = "true" ]; then
gh api --method DELETE "repos/${GITHUB_REPOSITORY}/releases/${release_id}"
else
echo "Public GitHub Release already exists; verify it in place without mutation."
fi
gh api --method DELETE "repos/${GITHUB_REPOSITORY}/releases/${release_id}"
fi
- name: Create a complete draft and publish it atomically
env:
GH_TOKEN: ${{ github.token }}
RELEASE_SHA: ${{ github.sha }}
RELEASE_TAG: ${{ inputs.release_tag }}
run: |
set -euo pipefail
gh release create "$RELEASE_TAG" release-evidence/* \
--verify-tag \
--draft \
--title "EgressWeave ${RELEASE_TAG#v}" \
--generate-notes
gh release edit "$RELEASE_TAG" --draft=false
release_endpoint="repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}"
release="$(gh api "$release_endpoint" 2>/dev/null || true)"
if [ -z "$release" ]; then
gh release create "$RELEASE_TAG" release-evidence/* \
--repo "$GITHUB_REPOSITORY" \
--verify-tag \
--draft \
--title "EgressWeave ${RELEASE_TAG#v}" \
--generate-notes
gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --draft=false
release="$(gh api "$release_endpoint")"
fi
if ! jq -e --arg tag "$RELEASE_TAG" '
.tag_name == $tag and .draft == false and
.prerelease == false and .immutable == true
' <<<"$release" >/dev/null; then
echo "::error::Published release is not immutable; it is ineligible for released-owner consumption."
exit 1
fi
local_asset_names=()
for asset in release-evidence/*; do
if [ ! -f "$asset" ] || [ ! -s "$asset" ]; then
echo "::error::Release evidence contains a missing or empty artifact."
exit 1
fi
local_asset_names+=("$(basename "$asset")")
done
if [ "${#local_asset_names[@]}" -eq 0 ]; then
echo "::error::Release evidence is empty."
exit 1
fi
local_assets="$(printf '%s\n' "${local_asset_names[@]}" | LC_ALL=C sort)"
remote_assets="$(jq -r '.assets[]?.name' <<<"$release" | LC_ALL=C sort)"
if [ "$remote_assets" != "$local_assets" ]; then
echo "::error::Published release asset inventory does not exactly match reviewed evidence."
exit 1
fi
published_tag="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${RELEASE_TAG}")"
published_tag_sha="$(jq -er '.object.sha' <<<"$published_tag")"
if [ "$published_tag_sha" != "$RELEASE_SHA" ]; then
echo "::error::Published release tag does not point to the exact reviewed commit."
exit 1
fi
gh release verify "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY"
for asset in release-evidence/*; do
gh release verify-asset "$RELEASE_TAG" "$asset" --repo "$GITHUB_REPOSITORY"
done
68 changes: 62 additions & 6 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ checksums have been re-verified.
release tag.
5. Keep the release workflow and every third-party action pinned to reviewed
commit SHAs.
6. Enable GitHub release immutability in repository settings before publication.
The publisher does not hold Administration permission and must not gain a
long-lived administrative credential to change that setting. Its completion
gate verifies the actual published result rather than assuming the setting.
The runner must support `gh release verify` and `gh release verify-asset`.

The project cannot be published through this workflow until the PyPI project or
pending Trusted Publisher exists. That external enrollment is deliberately not
Expand Down Expand Up @@ -132,10 +137,27 @@ level claim.
action with attestations enabled. It receives no repository-content write
permission and no long-lived package-index token.
8. Only after PyPI succeeds, the final job rechecks that the tag still points to
the reviewed SHA, verifies `SHA256SUMS`, creates a draft GitHub Release with
all evidence attached, and then publishes that complete draft. It refuses to
overwrite an existing public release and also depends directly on the same
release-evidence gate.
the reviewed SHA and verifies `SHA256SUMS`. A recoverable draft for the tag may
be deleted and rebuilt. If no release exists, the job creates a complete draft
with all reviewed evidence and publishes it. If an already-public release
exists, the job does not delete, recreate, edit, or add assets to it; it enters
verify-only recovery and subjects the existing release to the same completion
gate. This artifact-only job has no git checkout: every `gh release` command
explicitly selects `--repo "$GITHUB_REPOSITORY"`.
9. The completion gate reads the version-specific Releases API and requires the
exact tag, typed `draft: false`, `prerelease: false`, and `immutable: true`.
It also requires the remote release asset-name inventory to equal exactly the
non-empty local `release-evidence/*` inventory; missing or extra remote assets
fail closed. Only after these checks prove the public result is the reviewed
immutable inventory does it re-read the associated tag ref and require the
exact reviewed workflow SHA. `gh release create --verify-tag` proves that the
tag exists; it is not used as a substitute for commit binding.
10. The final step then verifies the signed release attestation and every local
release-evidence file, including `SHA256SUMS`, using `gh release verify` and
`gh release verify-asset`. Missing or empty files, mismatched identities,
mismatched asset inventory, mutable releases, unavailable metadata, and
invalid attestations fail the run. A public release's mere existence does not
pass this completion gate.

## Failure and retry semantics

Expand All @@ -153,9 +175,24 @@ level claim.
tag remains at the reviewed commit. Correct the external publisher or
environment configuration and rerun the failed jobs; do not move the tag.
- If the final GitHub Release job leaves a draft, a retry may delete and rebuild
only that recoverable draft. An existing public release is never replaced.
only that recoverable draft. An existing public release is never replaced or
mutated.
- Never republish changed bytes under an existing version. Correct a release
with a new version and a transparent changelog entry.
- A completion-gate failure after publication does not undo PyPI or GitHub
publication. Do not delete, retag or recycle the version. Rerunning the failed
final job is a verify-only recovery when the public release already exists: it
may succeed only if exact typed metadata, immutable state, reviewed tag SHA,
exact release-asset inventory, release attestation, and every asset attestation
validate. The rerun must not edit that public release. A mutable, mismatched,
or unverifiable public release remains ineligible for released-owner
consumption and requires investigation; use a new reviewed version for an
actual artifact defect.
- If release immutability was disabled or changed during publication, a complete
but mutable public release may exist. It remains ineligible for released-owner
consumption. Enabling the setting afterward does not retroactively validate
it. Do not describe the post-publication check as an atomic administrative
preflight or as proof that publication never occurred on a failed run.

## Post-release verification

Expand All @@ -170,9 +207,23 @@ level claim.
- Install the wheel in clean Python 3.10 and Python 3.13 environments and run a
minimal import/version check outside the source tree.
- Confirm the GitHub Release tag resolves to the exact workflow and protected
`main` commit.
`main` commit, the release is immutable, and the release and artifact
attestations validate. Use the version-specific URL, not `releases/latest`.
- For LifeOS or another released-owner consumer, separately verify that the
released version implements the required executable transport/API contract.
A schema, destination identifier, source branch, or release inventory entry
alone does not prove DNS/IP, proxy, redirect, connect-time or language-runtime
enforcement. This publisher repair does not implement those consumer gaps.
- Restore an empty `[Unreleased]` section only in the next normal development PR.

The focused command `python -m pytest -q
tests/test_immutable_release_publication.py
tests/test_immutable_release_tag_revalidation.py` executes the actual final
shell step in a checkout-free fixture with a stateful fake GitHub CLI. It checks
the repository/tag binding and fail-closed completion outcomes without any
network or publication authority. It does not replace full repository, hosted
exact-head, independent-review, real publication or consumer-canary evidence.

## Authoritative references

- [GitHub Docs: Manually running a workflow](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/manually-run-a-workflow)
Expand All @@ -181,3 +232,8 @@ level claim.
- [GitHub Docs: REST API endpoints for commits](https://docs.github.com/en/rest/commits/commits)
- [PyPI Docs: Publishing with a Trusted Publisher](https://docs.pypi.org/trusted-publishers/using-a-publisher/)
- [PyPI Docs: Trusted Publishing security model](https://docs.pypi.org/trusted-publishers/security-model/)
- [GitHub Docs: Immutable releases](https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases)
- [GitHub CLI: Create a release](https://cli.github.com/manual/gh_release_create)
- [GitHub CLI: Repository environment](https://cli.github.com/manual/gh_help_environment)
- [GitHub CLI: Verify a release](https://cli.github.com/manual/gh_release_verify)
- [GitHub CLI: Verify a release asset](https://cli.github.com/manual/gh_release_verify-asset)
45 changes: 45 additions & 0 deletions tests/test_immutable_release_attestation_order.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Reject release-attestation verification before publication."""

from __future__ import annotations

import runpy
from pathlib import Path

_HELPERS = runpy.run_path(
str(Path(__file__).with_name("test_immutable_release_publication.py"))
)
_run = _HELPERS["_run"]
_script = _HELPERS["_script"]

_PUBLISH = 'gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --draft=false'
_RELEASE_VERIFY = 'gh release verify "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY"'
_ASSET_LOOP_START = "for asset in release-evidence/*; do"


def test_release_attestation_verification_must_follow_publication(tmp_path: Path) -> None:
"""A draft has no immutable-release attestation to accept as publication evidence."""
script = _script()
assert script.count(_PUBLISH) == 1
assert script.count(_RELEASE_VERIFY) == 1
without_verify = script.replace(_RELEASE_VERIFY, "", 1)
mutated = without_verify.replace(_PUBLISH, f"{_RELEASE_VERIFY}\n{_PUBLISH}", 1)
result, state = _run(tmp_path, script=mutated)
assert result.returncode != 0
assert state["published"] is False
assert not state["verified_assets"]


def test_asset_attestation_verification_must_follow_publication(tmp_path: Path) -> None:
"""Remote asset attestations cannot be accepted before release publication."""
script = _script()
assert script.count(_PUBLISH) == 1
assert script.count(_ASSET_LOOP_START) >= 2
loop_start = script.rindex(_ASSET_LOOP_START)
loop_end = script.index("\ndone", loop_start) + len("\ndone")
asset_loop = script[loop_start:loop_end]
without_loop = script[:loop_start] + script[loop_end:]
mutated = without_loop.replace(_PUBLISH, f"{asset_loop}\n{_PUBLISH}", 1)
result, state = _run(tmp_path, script=mutated)
assert result.returncode != 0
assert state["published"] is False
assert not state["verified_assets"]
38 changes: 38 additions & 0 deletions tests/test_immutable_release_attestation_permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Require the GitHub Release publisher to read release attestations."""

from __future__ import annotations

from pathlib import Path

_ROOT = Path(__file__).resolve().parents[1]
_WORKFLOW = _ROOT / ".github" / "workflows" / "release.yml"


def _publisher_permissions() -> dict[str, str]:
"""Return only direct scalar permissions owned by publish-github-release."""
workflow = _WORKFLOW.read_text(encoding="utf-8")
publisher = workflow.split("\n publish-github-release:\n", 1)[1]
permissions = publisher.split("\n permissions:\n", 1)[1]
parsed: dict[str, str] = {}
for line in permissions.splitlines():
if not line.startswith(" "):
break
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
key, separator, value = stripped.partition(":")
if not separator or not key or not value.strip():
raise AssertionError("publisher permissions must be direct scalar entries")
if key in parsed:
raise AssertionError(f"duplicate publisher permission: {key}")
parsed[key] = value.strip()
return parsed


def test_publisher_has_minimum_release_attestation_authority() -> None:
"""Attestation verification must have read authority without broader token scope."""
assert _publisher_permissions() == {
"actions": "read",
"attestations": "read",
"contents": "write",
}
26 changes: 26 additions & 0 deletions tests/test_immutable_release_command_order.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Reject release publication sequences that violate draft-creation order."""

from __future__ import annotations

import runpy
from pathlib import Path

_HELPERS = runpy.run_path(
str(Path(__file__).with_name("test_immutable_release_publication.py"))
)
_run = _HELPERS["_run"]
_script = _HELPERS["_script"]


def test_release_edit_must_follow_matching_draft_creation(tmp_path: Path) -> None:
"""Publishing before the matching draft exists must fail closed."""
script = _script()
edit = 'gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --draft=false'
create = 'gh release create "$RELEASE_TAG"'
assert script.count(edit) == 1
assert script.count(create) == 1
without_edit = script.replace(edit, "", 1)
mutated = without_edit.replace(create, f"{edit}\n{create}", 1)
result, state = _run(tmp_path, script=mutated)
assert result.returncode != 0
assert not state["published"]
40 changes: 40 additions & 0 deletions tests/test_immutable_release_draft_transition.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Guard the draft-to-public transition in the immutable release harness."""

from __future__ import annotations

import importlib.util
from pathlib import Path

import pytest

_MODULE_PATH = Path(__file__).with_name("test_immutable_release_publication.py")
_SPEC = importlib.util.spec_from_file_location(
"test_immutable_release_publication_contract", _MODULE_PATH
)
if _SPEC is None or _SPEC.loader is None: # pragma: no cover - import machinery invariant
raise RuntimeError("release publication test harness could not be loaded")
_HARNESS = importlib.util.module_from_spec(_SPEC)
_SPEC.loader.exec_module(_HARNESS)


@pytest.mark.parametrize(
"replacement",
[
'gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY"',
'gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --draft=false --draft=true',
],
)
def test_release_edit_must_clear_draft_before_metadata_can_be_public(
tmp_path: Path,
replacement: str,
) -> None:
"""Missing or conflicting draft flags must not produce publishable metadata."""
script = _HARNESS._script()
expected = (
'gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --draft=false'
)
assert expected in script
mutated = script.replace(expected, replacement, 1)
result, state = _HARNESS._run(tmp_path, script=mutated)
assert result.returncode != 0
assert state["published"] is False
Loading
Loading