diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index c96957c4a..12a428fec 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -3,6 +3,22 @@ name: Publish to PyPI, npm, and crates.io on: release: types: [published] + workflow_dispatch: + inputs: + release_tag: + description: Existing immutable release tag to resume + required: true + default: v0.5.0 + type: string + waive_unreleased_entries: + description: Waive only the tagged CHANGELOG Unreleased-entry check + required: true + default: false + type: boolean + recovery_reason: + description: Public maintainer reason for the recovery dispatch + required: true + type: string permissions: contents: read @@ -20,24 +36,42 @@ jobs: steps: - uses: actions/checkout@v7 with: - ref: ${{ github.event.release.tag_name }} + ref: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag }} fetch-depth: 0 - name: Require the certified current main commit and release versions id: source shell: bash env: - RELEASE_TAG: ${{ github.event.release.tag_name }} - RELEASE_SHA: ${{ github.sha }} + RELEASE_TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag }} + EVENT_SHA: ${{ github.sha }} + WAIVE_UNRELEASED_ENTRIES: ${{ inputs.waive_unreleased_entries || false }} + RECOVERY_REASON: ${{ inputs.recovery_reason || '' }} run: | git fetch --no-tags origin \ +refs/heads/main:refs/remotes/origin/main + RELEASE_SHA="$(git rev-parse "$RELEASE_TAG^{}")" test "$(git rev-parse HEAD)" = "$RELEASE_SHA" test "$(git rev-parse "$RELEASE_TAG^{}")" = "$RELEASE_SHA" - test "$(git rev-parse refs/remotes/origin/main)" = "$RELEASE_SHA" - python3 scripts/ci/release-publish-preflight.py \ - --tag "$RELEASE_TAG" \ - --expected-sha "$RELEASE_SHA" + preflight_args=(--tag "$RELEASE_TAG" --expected-sha "$RELEASE_SHA") + if test "$GITHUB_EVENT_NAME" = release; then + test "$EVENT_SHA" = "$RELEASE_SHA" + test "$(git rev-parse refs/remotes/origin/main)" = "$RELEASE_SHA" + else + test "$WAIVE_UNRELEASED_ENTRIES" = true + test -n "$RECOVERY_REASON" + gh release view "$RELEASE_TAG" >/dev/null + git merge-base --is-ancestor "$RELEASE_SHA" refs/remotes/origin/main + git show \ + refs/remotes/origin/main:scripts/ci/release-publish-preflight.py \ + > "$RUNNER_TEMP/release-publish-preflight.py" + install -m 0755 \ + "$RUNNER_TEMP/release-publish-preflight.py" \ + scripts/ci/release-publish-preflight.py + preflight_args+=(--allow-unreleased-entries) + printf 'Recovery reason: %s\n' "$RECOVERY_REASON" + fi + python3 scripts/ci/release-publish-preflight.py "${preflight_args[@]}" printf 'release_sha=%s\n' "$RELEASE_SHA" >> "$GITHUB_OUTPUT" - name: Verify release license policy @@ -95,7 +129,7 @@ jobs: shell: bash env: GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag }} run: | asset_name="v0.5.0-artifacts.json" asset_count="$(gh release view "$RELEASE_TAG" --json assets \ diff --git a/CHANGELOG.md b/CHANGELOG.md index c91edc907..0ff8ee9e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- Add an explicit, maintainer-reasoned publication recovery dispatch that can + resume the immutable `v0.5.0` tag and retained candidate after waiving only + the tagged changelog's stale `[Unreleased]` entries; all artifact, checksum, + identity, ordering, and fail-closed registry checks remain required (#281). - Move all public npm packages from the unavailable `@graphforge` scope to the Curate Labs-owned `@curatelabs` scope, using `@curatelabs/graphforge` for the native binding and `@curatelabs/graphforge-*` for platform, CLI, and agent diff --git a/docs/development/v0.5.0-release-operator-runbook.md b/docs/development/v0.5.0-release-operator-runbook.md index ea5c09dfc..70cd5c275 100644 --- a/docs/development/v0.5.0-release-operator-runbook.md +++ b/docs/development/v0.5.0-release-operator-runbook.md @@ -179,6 +179,23 @@ gh run watch "$PUBLISH_RUN_ID" --repo CurateLabs/graphforge --exit-status On any failure, stop. Follow `publication-order.md`; do not manually continue with a later registry and do not attempt different bytes under `0.5.0`. +If the immutable tag's first publication run stopped before any registry write +only because its `[Unreleased]` section still held entries, record an explicit +maintainer waiver and resume the same retained candidate with: + +```bash +gh workflow run publish.yaml --repo CurateLabs/graphforge --ref main \ + -f release_tag=v0.5.0 \ + -f waive_unreleased_entries=true \ + -f recovery_reason="Maintainer waived tagged Unreleased entries under #281" +``` + +This recovery path requires the existing GitHub Release, requires the tagged +commit to remain an ancestor of `main`, and waives only that changelog hygiene +check using the reviewed recovery validator from current `main`. It does not +move the tag, rebuild bytes, or bypass candidate checksum, license, npm +identity, registry ordering, or publication failure checks. + After a green run, verify and record the public URLs on #195, #198, and #196, including registry digests/checksums and crates.io ownership, then close each issue only when its live acceptance criteria are proven. diff --git a/docs/reference/changelog.md b/docs/reference/changelog.md index c91edc907..0ff8ee9e7 100644 --- a/docs/reference/changelog.md +++ b/docs/reference/changelog.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- Add an explicit, maintainer-reasoned publication recovery dispatch that can + resume the immutable `v0.5.0` tag and retained candidate after waiving only + the tagged changelog's stale `[Unreleased]` entries; all artifact, checksum, + identity, ordering, and fail-closed registry checks remain required (#281). - Move all public npm packages from the unavailable `@graphforge` scope to the Curate Labs-owned `@curatelabs` scope, using `@curatelabs/graphforge` for the native binding and `@curatelabs/graphforge-*` for platform, CLI, and agent diff --git a/scripts/ci/release-publish-preflight.py b/scripts/ci/release-publish-preflight.py index 31f145938..69ab526e8 100644 --- a/scripts/ci/release-publish-preflight.py +++ b/scripts/ci/release-publish-preflight.py @@ -105,6 +105,7 @@ def validate( versions: dict[str, str], changelog: str, docs_changelog: str, + allow_unreleased_entries: bool = False, ) -> list[str]: errors: list[str] = [] version = release_version(tag) @@ -130,7 +131,7 @@ def validate( body = unreleased_body(changelog) if body is None: errors.append("CHANGELOG lacks an [Unreleased] section before the release section") - elif re.search(r"(?m)^\s*[-*]\s+", body): + elif re.search(r"(?m)^\s*[-*]\s+", body) and not allow_unreleased_entries: errors.append("CHANGELOG [Unreleased] still contains release-note entries") current_repo = "https://github.com/CurateLabs/graphforge" @@ -147,6 +148,11 @@ def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--tag", required=True, help="Release tag, e.g. v0.5.0") parser.add_argument("--expected-sha", required=True, help="Release-event commit SHA") + parser.add_argument( + "--allow-unreleased-entries", + action="store_true", + help="Waive only the [Unreleased] entry check for an immutable-tag recovery", + ) args = parser.parse_args(argv) version_module = load_version_module() @@ -157,6 +163,7 @@ def main(argv: list[str] | None = None) -> int: versions=version_module.read_current(), changelog=CHANGELOG.read_text(encoding="utf-8"), docs_changelog=DOCS_CHANGELOG.read_text(encoding="utf-8"), + allow_unreleased_entries=args.allow_unreleased_entries, ) errors.extend(version_module.check_aligned()) errors.extend(validate_metadata()) diff --git a/scripts/ci/test-release-publish-preflight.py b/scripts/ci/test-release-publish-preflight.py index aec2ebca5..3b17a9da9 100644 --- a/scripts/ci/test-release-publish-preflight.py +++ b/scripts/ci/test-release-publish-preflight.py @@ -83,12 +83,32 @@ def load_module(): } assert mod.validate(**values), mutation +stale_changelog = changelog.replace("_Nothing yet._", "- Stale release entry") +assert ( + mod.validate( + tag="v0.5.0", + expected_sha=sha, + actual_sha=sha, + versions=versions, + changelog=stale_changelog, + docs_changelog=stale_changelog, + allow_unreleased_entries=True, + ) + == [] +) + workflow = WORKFLOW.read_text(encoding="utf-8") preflight = workflow.split(" candidate-preflight:\n", 1)[1].split("\n publish-pypi:", 1)[0] assert "release-publish-preflight.py" in preflight assert "github.event.release.tag_name" in preflight assert "github.sha" in preflight assert "refs/remotes/origin/main" in preflight +assert "workflow_dispatch:" in workflow +assert "waive_unreleased_entries:" in workflow +assert "RECOVERY_REASON" in preflight +assert "--allow-unreleased-entries" in preflight +assert "git show" in preflight +assert "refs/remotes/origin/main:scripts/ci/release-publish-preflight.py" in preflight assert "npm whoami" in preflight assert "secrets.NPM_TOKEN" in preflight assert "M1-Release-Candidate-$RELEASE_SHA" in preflight