diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab0a403..64d7347 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,20 @@ name: release on: - push: - tags: - - "v*" + workflow_dispatch: + inputs: + tag: + description: "Existing signed release tag to publish (for example, v1.2.0)." + required: true + type: string + immutable_releases_preflight: + description: "Repository owner confirmed immutable releases through the administration API immediately before dispatch." + required: true + type: boolean + +env: + RELEASE_TAG: ${{ inputs.tag }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} permissions: contents: read @@ -20,6 +31,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + ref: ${{ env.RELEASE_TAG }} - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" @@ -31,25 +43,25 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - git fetch origin main - git merge-base --is-ancestor "$GITHUB_SHA" origin/main - test "$(git rev-parse "${GITHUB_REF}^{commit}")" = "$GITHUB_SHA" - VERSION="${GITHUB_REF_NAME#v}" + test "$GITHUB_REF" = "refs/heads/$DEFAULT_BRANCH" + SOURCE_COMMIT="$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}")" + git fetch origin "$DEFAULT_BRANCH" + git merge-base --is-ancestor "$SOURCE_COMMIT" "origin/$DEFAULT_BRANCH" + test "$(git rev-parse HEAD)" = "$SOURCE_COMMIT" + VERSION="${RELEASE_TAG#v}" PACKAGE_VERSION="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')" test "$VERSION" = "$PACKAGE_VERSION" - test "$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/check-runs" --jq '[.check_runs[] | select(.name == "conformance-gate" and .conclusion == "success")] | length')" -ge 1 + test "$(gh api "repos/$GITHUB_REPOSITORY/commits/$SOURCE_COMMIT/check-runs" --jq '[.check_runs[] | select(.name == "conformance-gate" and .conclusion == "success")] | length')" -ge 1 - - name: Require immutable GitHub releases before publication + - name: Require owner-admin immutable-release preflight env: - GH_TOKEN: ${{ github.token }} + IMMUTABLE_RELEASES_PREFLIGHT: ${{ inputs.immutable_releases_preflight }} run: | - test "$(gh api \ - -H "X-GitHub-Api-Version: 2026-03-10" \ - "repos/$GITHUB_REPOSITORY/immutable-releases" \ - --jq '.enabled')" = true + test "$GITHUB_ACTOR" = "$GITHUB_REPOSITORY_OWNER" + test "$IMMUTABLE_RELEASES_PREFLIGHT" = "true" - name: Require finalized release metadata in the exact tagged checkout - run: python scripts/check_release_metadata.py --version "${GITHUB_REF_NAME#v}" + run: python scripts/check_release_metadata.py --version "${RELEASE_TAG#v}" - name: Re-run conformance on the tagged checkout run: | @@ -60,8 +72,8 @@ jobs: - name: Build exact source, reproducible wheel, and external manifest run: | python scripts/release_artifacts.py build \ - --ref "$GITHUB_REF" \ - --release "${GITHUB_REF_NAME#v}" \ + --ref "refs/tags/$RELEASE_TAG" \ + --release "${RELEASE_TAG#v}" \ --output-dir dist - name: Smoke-test the exact wheel @@ -93,8 +105,8 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - VERSION="${GITHUB_REF_NAME#v}" - TAG_OBJECT="$(git rev-parse "$GITHUB_REF_NAME")" + VERSION="${RELEASE_TAG#v}" + TAG_OBJECT="$(git rev-parse "$RELEASE_TAG")" TAG_VERIFIED="$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$TAG_OBJECT" --jq '.verification.verified')" TAG_REASON="$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$TAG_OBJECT" --jq '.verification.reason')" if test "$TAG_VERIFIED" = true; then @@ -122,23 +134,29 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - gh release create "$GITHUB_REF_NAME" \ + gh release create "$RELEASE_TAG" \ dist/*.zip dist/*.whl dist/*.tar.gz dist/*.cdx.json dist/*.manifest.json \ --repo "$GITHUB_REPOSITORY" \ --verify-tag \ --draft \ - --title "VSTD ${GITHUB_REF_NAME}" \ + --title "VSTD $RELEASE_TAG" \ --notes-file release-notes.md - name: Publish the complete draft atomically env: GH_TOKEN: ${{ github.token }} - run: gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false + run: gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --draft=false + + - name: Require the published release to be immutable + env: + GH_TOKEN: ${{ github.token }} + run: | + test "$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" --jq '.immutable')" = true - name: Stage only the Python distributions for PyPI uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: verifier-standard-${{ github.ref_name }} + name: verifier-standard-${{ env.RELEASE_TAG }} path: | dist/*.whl dist/*.tar.gz @@ -158,7 +176,7 @@ jobs: - name: Download the exact tested Python distributions uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: verifier-standard-${{ github.ref_name }} + name: verifier-standard-${{ env.RELEASE_TAG }} path: dist - name: Publish through PyPI Trusted Publishing diff --git a/AGENTS.md b/AGENTS.md index 8f7111e..0c87dda 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,7 +65,7 @@ Use TIME for disagreements among normative documents, schemas, runtime behavior, conformance tests, or public implementation claims. Do not use it for an honestly represented `UNKNOWN`, a receipt or Graph `CONFLICTED` state, ordinary design work, or a roadmap item. `TIME == CLEAR` is a release invariant for version 1.2.0. Development branches and normal -pull-request checks may retain exact unresolved entries, but the tag-triggered publication +pull-request checks may retain exact unresolved entries, but the owner-dispatched publication workflow must reject the exact tagged checkout unless its status is `CLEAR`. ## 2. Prime directive diff --git a/CHANGELOG.md b/CHANGELOG.md index 22d6f71..44753b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -298,9 +298,11 @@ subprocess. - Test the advertised Python 3.10 through 3.13 range, add release-integrity and installed-wheel jobs, and expose one required conformance gate for branch protection. -- Add a tag-triggered release workflow that refuses non-main or unconformed commits, - rebuilds and smoke-tests exact artifacts, records tag signature status without - relabeling it, and creates GitHub/Sigstore attestations for every uploaded asset. +- Add an owner-dispatched release workflow for an existing tag that refuses a + non-default-branch dispatch or a tag outside protected history, requires the protected + conformance check and an owner-confirmed immutable-release preflight, rebuilds and + smoke-tests exact artifacts, records tag signature status without relabeling it, and + creates GitHub/Sigstore attestations for every uploaded asset. - Add structured ambiguity, counterexample, and implementation feedback surfaces plus public conduct and pull-request consequence checks. diff --git a/HUMANS.md b/HUMANS.md index 394ac8a..e5dcd3c 100644 --- a/HUMANS.md +++ b/HUMANS.md @@ -140,6 +140,6 @@ coordinates; resolve only from evidence. Do **not** escalate a receipt's `CONFLICTED` evidence, an honest `UNKNOWN`, a roadmap item, or speculative research to TIME. Development branches may keep precise open contradictions. -For publication, the tag-triggered workflow checks the exact tagged `TIME.md` and fails +For publication, the owner-dispatched release workflow checks the exact tagged `TIME.md` and fails unless it contains exactly one `Status: CLEAR` line; maintainer judgment cannot override that release invariant. diff --git a/RELEASING.md b/RELEASING.md index 0fa1384..75012bf 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -11,7 +11,7 @@ exact, publicly resolvable commit. Development branches may record precise contradictions with [`TIME.md`](TIME.md) set to `Status: OPEN`; normal pull-request checks do not prohibit that state. Publication is -different: the tag-triggered workflow runs `python scripts/check_time_status.py` against +different: the owner-dispatched release workflow runs `python scripts/check_time_status.py` against the exact tagged checkout and fails unless it contains exactly one `Status: CLEAR` line. There is no subjective override. @@ -20,7 +20,7 @@ period, `CHANGELOG.md` says `UNRELEASED`, `CITATION.cff` identifies a release ca has no `date-released`, and install instructions distinguish a source checkout from the latest published package. Before tagging, land an explicit release-finalization change that uses the actual publication date consistently in the changelog and citation metadata; do -not fabricate or backdate it. The tag workflow enforces this with +not fabricate or backdate it. The release workflow enforces this with `python scripts/check_release_metadata.py --version ` and also refuses release-candidate Zenodo metadata. @@ -82,8 +82,14 @@ release-candidate Zenodo metadata. [repository setting](https://docs.github.com/en/code-security/how-tos/secure-your-supply-chain/establish-provenance-and-integrity/prevent-release-changes) and the [versioned API](https://docs.github.com/en/rest/repos/repos?apiVersion=2026-03-10#check-if-immutable-releases-are-enabled-for-a-repository). - The tag workflow repeats this check and stops before publication when the setting is - disabled. Immutability locks the published tag and attached assets and generates a + GitHub documents this endpoint as requiring repository Administration read permission. + The default Actions token cannot request that permission, so the release workflow must + not present its token as an independent administrative recheck. Instead, the repository + owner performs this authenticated preflight immediately before dispatching the existing + `release.yml` workflow and supplies its required + `immutable_releases_preflight=true` control input. The workflow requires the dispatcher + to equal the repository owner and verifies the published release's `immutable` field as + a postcondition. Immutability locks the published tag and attached assets and generates a GitHub release attestation; it does not correct false metadata. Corrections, revocations, and superseding releases remain additive. Enable the setting only after the draft-first workflow is present on the protected release commit. @@ -119,11 +125,15 @@ release-candidate Zenodo metadata. The manifest's source ref MUST resolve to its recorded public commit. The source ZIP file set and every member byte MUST match that commit. CRLF/LF equivalence is not accepted as byte identity. -8. Push the tag only after all preceding checks pass. The tag-triggered release workflow - rechecks protected-main ancestry, package version, the successful protected +8. Push the tag only after all preceding checks pass. Re-run the owner-authenticated + immutable-release API preflight, then dispatch `.github/workflows/release.yml` from the + protected default branch with the exact existing tag and + `immutable_releases_preflight=true`. The owner-dispatched release workflow + rechecks signed-tag identity, protected-main ancestry, package version, the successful protected repository-check aggregate (the `conformance-gate` status context), the full test - suite, immutable-release setting, deterministic build, installed wheel, and artifact - manifest. + suite, final metadata, deterministic build, installed wheel, and artifact manifest. + The administrative preflight is owner-observed rather than Actions-token-observed; the + workflow stops unless that fact is explicitly supplied and refuses a non-owner dispatch. It then attests the tested source ZIP, wheel, source distribution, SBOM, and external release manifest. The workflow creates a draft, attaches the complete set, and only then publishes it. A second job can diff --git a/TIME.md b/TIME.md index 297804c..601aed0 100644 --- a/TIME.md +++ b/TIME.md @@ -19,5 +19,5 @@ When a contradiction is open, change the status to `Status: OPEN` and record the coordinates, both incompatible claims, evidence for each side, and affected behavior. An evidence-backed repair removes the resolved live entry and returns this file to `Status: CLEAR`; Git history preserves the prior state. Development branches may remain -open. The tag-triggered publication workflow checks the exact tagged checkout and fails +open. The owner-dispatched publication workflow checks the exact tagged checkout and fails unless this file contains exactly one `Status: CLEAR` line. diff --git a/tests/test_release_artifacts.py b/tests/test_release_artifacts.py index 553acf0..4d01093 100644 --- a/tests/test_release_artifacts.py +++ b/tests/test_release_artifacts.py @@ -355,8 +355,8 @@ def test_release_notes_use_the_github_tag_object_verification() -> None: def test_release_is_drafted_with_attested_sbom_before_publication() -> None: workflow = RELEASE_WORKFLOW.read_text(encoding="utf-8") - create = workflow.index('gh release create "$GITHUB_REF_NAME"') - publish = workflow.index('gh release edit "$GITHUB_REF_NAME"') + create = workflow.index('gh release create "$RELEASE_TAG"') + publish = workflow.index('gh release edit "$RELEASE_TAG"') assert "dist/*.cdx.json" in workflow assert "--draft" in workflow[create:publish] @@ -482,20 +482,31 @@ def test_release_metadata_gate_accepts_one_final_consistent_coordinate(tmp_path: release_metadata.require_finalized(tmp_path, "1.2.0") -def test_tag_release_contract_binds_main_version_gate_and_final_metadata() -> None: +def test_release_contract_binds_tag_owner_preflight_and_final_metadata() -> None: workflow = RELEASE_WORKFLOW.read_text(encoding="utf-8") required = ( - 'git merge-base --is-ancestor "$GITHUB_SHA" origin/main', - 'git rev-parse "${GITHUB_REF}^{commit}"', + "workflow_dispatch:", + "immutable_releases_preflight:", + "ref: ${{ env.RELEASE_TAG }}", + 'test "$GITHUB_REF" = "refs/heads/$DEFAULT_BRANCH"', + 'git merge-base --is-ancestor "$SOURCE_COMMIT" "origin/$DEFAULT_BRANCH"', + 'test "$(git rev-parse HEAD)" = "$SOURCE_COMMIT"', 'test "$VERSION" = "$PACKAGE_VERSION"', - 'commits/$GITHUB_SHA/check-runs', + 'commits/$SOURCE_COMMIT/check-runs', 'select(.name == "conformance-gate" and .conclusion == "success")', - 'repos/$GITHUB_REPOSITORY/immutable-releases', - "--jq '.enabled')\" = true", - 'python scripts/check_release_metadata.py --version "${GITHUB_REF_NAME#v}"', + 'test "$GITHUB_ACTOR" = "$GITHUB_REPOSITORY_OWNER"', + 'test "$IMMUTABLE_RELEASES_PREFLIGHT" = "true"', + 'python scripts/check_release_metadata.py --version "${RELEASE_TAG#v}"', + 'gh release create "$RELEASE_TAG"', + 'releases/tags/$RELEASE_TAG', + "--jq '.immutable')\" = true", ) for fragment in required: assert fragment in workflow - assert workflow.index('repos/$GITHUB_REPOSITORY/immutable-releases') < workflow.index( - 'gh release create "$GITHUB_REF_NAME"' + assert "repos/$GITHUB_REPOSITORY/immutable-releases" not in workflow + assert workflow.index('test "$IMMUTABLE_RELEASES_PREFLIGHT" = "true"') < workflow.index( + 'gh release create "$RELEASE_TAG"' + ) + assert workflow.index('gh release create "$RELEASE_TAG"') < workflow.index( + "--jq '.immutable')\" = true" )