Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
19 changes: 18 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -604,8 +604,25 @@ jobs:
run: |
set -euo pipefail
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" --draft=false
gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --draft=false
release="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}")"
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
gh release verify "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY"
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
gh release verify-asset "$RELEASE_TAG" "$asset" --repo "$GITHUB_REPOSITORY"
done
45 changes: 43 additions & 2 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 @@ -135,7 +140,15 @@ level claim.
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.
release-evidence gate. This artifact-only job has no git checkout: every
`gh release` command explicitly selects `--repo "$GITHUB_REPOSITORY"`.
9. The final step reads the version-specific Releases API and requires the exact
tag, typed `draft: false`, `prerelease: false`, and `immutable: true`. It 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,
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 @@ -156,6 +169,17 @@ level claim.
only that recoverable draft. An existing public release is never replaced.
- 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. An owner must
investigate missing metadata or attestation and verify the existing artifacts
without changing them; use a new reviewed version for an actual defect.
The existing workflow still rejects a public-release retry before creation,
so a generic whole-job rerun is not an automatic verify-only recovery path.
- 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 +194,22 @@ 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` executes the actual final shell
step in a checkout-free fixture with a stateful fake GitHub CLI. It checks the
repository 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 +218,7 @@ 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: 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)
148 changes: 148 additions & 0 deletions tests/test_immutable_release_publication.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
"""Exercise checkout-free publication without network or release authority."""

from __future__ import annotations

import json
import os
import subprocess
import sys
from pathlib import Path

import pytest

_ROOT = Path(__file__).resolve().parents[1]
_REPOSITORY = "ContextualWisdomLab/EgressWeave"
_TAG = "v0.3.0"
_ASSETS = ("SHA256SUMS", "egressweave-0.3.0.whl", "egressweave-0.3.0.tar.gz")
_GH = r'''
import json
import os
import sys
from pathlib import Path

path = Path(os.environ["FAKE_RELEASE_STATE"])
state = json.loads(path.read_text())
args = sys.argv[1:]
state["calls"].append(args)

def save():
path.write_text(json.dumps(state))

def fail(message):
save()
print(message, file=sys.stderr)
raise SystemExit(1)

if args[0] == "release":
if "--repo" not in args or args[args.index("--repo") + 1] != os.environ["GITHUB_REPOSITORY"]:
fail("no local git repository; explicit repository selection required")
if args[1] == "create":
if "--draft" not in args or "--verify-tag" not in args:
fail("draft and verified tag are mandatory")
state["created"] = True
elif args[1] == "edit":
state["published"] = True
elif args[1] == "verify":
if state.get("release_failure"):
fail("release attestation failed")
elif args[1] == "verify-asset":
asset = Path(args[3])
if not asset.is_file() or asset.name == state.get("asset_failure"):
fail("asset attestation failed")
state["verified_assets"].append(asset.name)
else:
fail("unexpected release command")
elif args[0] == "api":
if state.get("metadata_failure"):
fail("HTTP 503")
if args[1] != "repos/" + os.environ["GITHUB_REPOSITORY"] + "/releases/tags/" + os.environ["RELEASE_TAG"]:
fail("metadata request not bound to repository and tag")
print(json.dumps(state["metadata"]))
else:
fail("unexpected command")
save()
'''


def _script() -> str:
"""Read the actual final workflow step, not a copy of its commands."""
workflow = (_ROOT / ".github/workflows/release.yml").read_text(encoding="utf-8")
step = workflow.split(" - name: Create a complete draft and publish it atomically\n", 1)[1]
script = step.split(" run: |\n", 1)[1]
return "\n".join(line[10:] for line in script.splitlines() if line.startswith(" "))


def _run(tmp_path: Path, **changes: object) -> tuple[subprocess.CompletedProcess[str], dict]:
"""Execute shell in a non-repository workspace with an observable CLI boundary."""
metadata = {"tag_name": _TAG, "draft": False, "prerelease": False, "immutable": True}
metadata.update(changes.pop("metadata", {}))
state = {
"metadata": metadata, "calls": [], "verified_assets": [],
"created": False, "published": False, **changes,
}
state_path = tmp_path / "state.json"
state_path.write_text(json.dumps(state), encoding="utf-8")
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
gh = bin_dir / "gh"
gh.write_text(f"#!{sys.executable} -S\n" + _GH, encoding="utf-8")
gh.chmod(0o700)
evidence = tmp_path / "release-evidence"
evidence.mkdir()
for name in _ASSETS:
(evidence / name).write_text("reviewed fixture\n", encoding="utf-8")
env = {
key: value for key, value in os.environ.items()
if key not in {"GH_REPO", "GH_TOKEN", "GITHUB_TOKEN", "GIT_DIR", "GIT_WORK_TREE"}
}
env.update({
"PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}",
"FAKE_RELEASE_STATE": str(state_path),
"GITHUB_REPOSITORY": _REPOSITORY,
"RELEASE_TAG": _TAG,
})
result = subprocess.run(
["bash", "-c", _script()], cwd=tmp_path, env=env,
text=True, capture_output=True, timeout=15, check=False,
)
return result, json.loads(state_path.read_text(encoding="utf-8"))


def test_checkout_free_publish_verifies_release_and_every_asset(tmp_path: Path) -> None:
"""Explicit repository selection must work without a checkout or ambient GH_REPO."""
result, state = _run(tmp_path)
assert result.returncode == 0, result.stderr + result.stdout
assert state["created"] and state["published"]
assert ["release", "verify"] in [call[:2] for call in state["calls"]]
assert sorted(state["verified_assets"]) == sorted(_ASSETS)


@pytest.mark.parametrize("field,value", [
("immutable", False), ("immutable", None), ("immutable", "true"),
("draft", True), ("draft", "false"), ("prerelease", True),
("tag_name", "v9.9.9"),
])
def test_unverified_inventory_is_rejected(tmp_path: Path, field: str, value: object) -> None:
"""Published existence cannot stand in for typed, exact immutable identity."""
result, state = _run(tmp_path, metadata={field: value})
assert state["published"], result.stderr
assert result.returncode != 0
assert not state["verified_assets"]


@pytest.mark.parametrize("failure", ["metadata_failure", "release_failure"])
def test_verification_outage_is_not_success(tmp_path: Path, failure: str) -> None:
"""Read failure or invalid attestation must fail the publication result."""
result, state = _run(tmp_path, **{failure: True})
assert state["published"], result.stderr
assert result.returncode != 0
assert not state["verified_assets"]


@pytest.mark.parametrize("asset", _ASSETS)
def test_any_asset_verification_failure_is_fatal(tmp_path: Path, asset: str) -> None:
"""Check every artifact, including the checksum manifest, not only one wheel."""
result, state = _run(tmp_path, asset_failure=asset)
assert state["published"], result.stderr
assert result.returncode != 0
assert asset not in state["verified_assets"]
Loading