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
50 changes: 42 additions & 8 deletions .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 \
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions docs/development/v0.5.0-release-operator-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion scripts/ci/release-publish-preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"
Expand All @@ -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()
Expand All @@ -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())
Expand Down
20 changes: 20 additions & 0 deletions scripts/ci/test-release-publish-preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines 100 to +111

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

workflow="$(fd -t f -g 'publish.yaml' . | head -n1)"
test -n "$workflow"

rg -n -C 12 \
  'workflow_dispatch:|waive_unreleased_entries|RECOVERY_REASON|allow-unreleased-entries|v0\.5\.0|git show|refs/remotes/origin/main:scripts/ci/release-publish-preflight.py' \
  "$workflow"

Repository: CurateLabs/graphforge

Length of output: 159


🏁 Script executed:

set -euo pipefail
printf '%s\n' 'Candidate workflow files:'
git ls-files | rg '(^|/)(publish|release).*\.ya?ml$|\.github/workflows/.*\.ya?ml$' || true
printf '%s\n' 'Relevant identifiers:'
rg -n -C 8 \
  'candidate-preflight|waive_unreleased_entries|RECOVERY_REASON|allow-unreleased-entries|release-publish-preflight|workflow_dispatch|v0\.5\.0|refs/remotes/origin/main' \
  .github scripts 2>/dev/null || true
printf '%s\n' 'Test file:'
sed -n '1,180p' scripts/ci/test-release-publish-preflight.py

Repository: CurateLabs/graphforge

Length of output: 50379


🏁 Script executed:

set -euo pipefail
printf '%s\n' 'Workflow source section:'
sed -n '1,82p' .github/workflows/publish.yaml
printf '%s\n' 'Workflow test section:'
sed -n '80,125p' scripts/ci/test-release-publish-preflight.py
printf '%s\n' 'All workflow assertions and recovery references:'
rg -n -C 4 \
  'assert |waive_unreleased_entries|recovery_reason|RECOVERY_REASON|allow-unreleased-entries|RELEASE_TAG|RELEASE_SHA|GITHUB_EVENT_NAME|github\.event\.release\.tag_name|github\.sha' \
  scripts/ci/test-release-publish-preflight.py .github/workflows/publish.yaml
printf '%s\n' 'Preflight waiver implementation and callers:'
rg -n -C 8 \
  'allow-unreleased-entries|allow_unreleased|unreleased|def validate|argparse|release-publish-preflight' \
  scripts/ci/release-publish-preflight.py scripts/ci/test-release-publish-preflight.py

Repository: CurateLabs/graphforge

Length of output: 43706


🏁 Script executed:

set -euo pipefail
python3 - <<'PY'
from pathlib import Path

workflow = Path(".github/workflows/publish.yaml").read_text(encoding="utf-8")
test = Path("scripts/ci/test-release-publish-preflight.py").read_text(encoding="utf-8")
preflight = workflow.split("  candidate-preflight:\n", 1)[1].split("\n  publish-pypi:", 1)[0]

# Reproduce the test's relevant raw-token checks.
checks = [
    "release-publish-preflight.py",
    "github.event.release.tag_name",
    "github.sha",
    "refs/remotes/origin/main",
    "workflow_dispatch:",
    "waive_unreleased_entries:",
    "RECOVERY_REASON",
    "--allow-unreleased-entries",
    "git show",
    "refs/remotes/origin/main:scripts/ci/release-publish-preflight.py",
]
assert all(token in (workflow if token == "workflow_dispatch:" or token == "waive_unreleased_entries:" else preflight)
           for token in checks)

# A comment-only bypass marker satisfies the current assertion while the
# executable waiver operation is absent.
mutated = preflight.replace(
    '            preflight_args+=(--allow-unreleased-entries)\n',
    '            # preflight_args+=(--allow-unreleased-entries)\n',
)
assert "--allow-unreleased-entries" in mutated
assert "preflight_args+=(--allow-unreleased-entries)" not in mutated

# The current source has a real executable waiver operation and a non-empty
# reason check, but the test does not bind either token to that recovery branch.
assert 'test -n "$RECOVERY_REASON"' in preflight
assert 'preflight_args+=(--allow-unreleased-entries)' in preflight
branch = preflight.split('if test "$GITHUB_EVENT_NAME" = release; then', 1)[1].split("\n          fi", 1)[0]
assert 'test "$WAIVE_UNRELEASED_ENTRIES" = true' in branch
assert 'test -n "$RECOVERY_REASON"' in branch
assert 'preflight_args+=(--allow-unreleased-entries)' in branch
assert 'printf \'Recovery reason: %s\\n\' "$RECOVERY_REASON"' in branch

print("raw assertions accept a comment-only waiver marker")
print("current waiver and reason checks are executable and inside the non-release branch")
PY

Repository: CurateLabs/graphforge

Length of output: 248


🏁 Script executed:

set -euo pipefail
python3 - <<'PY'
from pathlib import Path

workflow = Path(".github/workflows/publish.yaml").read_text(encoding="utf-8")
preflight = workflow.split("  candidate-preflight:\n", 1)[1].split("\n  publish-pypi:", 1)[0]

tokens = [
    "release-publish-preflight.py",
    "github.event.release.tag_name",
    "github.sha",
    "refs/remotes/origin/main",
    "workflow_dispatch:",
    "waive_unreleased_entries:",
    "RECOVERY_REASON",
    "--allow-unreleased-entries",
    "git show",
    "refs/remotes/origin/main:scripts/ci/release-publish-preflight.py",
]
for token in tokens:
    source = workflow if token in {"workflow_dispatch:", "waive_unreleased_entries:"} else preflight
    print(f"{token!r}: {token in source}")

print("\nRelevant executable lines:")
for number, line in enumerate(preflight.splitlines(), 1):
    if any(token in line for token in (
        "GITHUB_EVENT_NAME", "WAIVE_UNRELEASED_ENTRIES", "RECOVERY_REASON",
        "allow-unreleased-entries", "git show", "printf 'Recovery reason",
    )):
        print(f"{number}: {line}")

print("\nBranch extraction:")
marker = 'if test "$GITHUB_EVENT_NAME" = release; then'
print("marker present:", marker in preflight)
if marker in preflight:
    suffix = preflight.split(marker, 1)[1]
    print(suffix.split("\n          fi", 1)[0])
PY

Repository: CurateLabs/graphforge

Length of output: 1826


Make the recovery workflow test semantic. Assert that only the workflow_dispatch recovery branch requires WAIVE_UNRELEASED_ENTRIES=true and a non-empty RECOVERY_REASON, records the reason, validates the immutable v0.5.0 same-SHA recovery, and adds --allow-unreleased-entries after those checks. Assert that the release path cannot use the waiver.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/ci/test-release-publish-preflight.py` around lines 100 - 111, Update
the assertions in the test around the extracted candidate-preflight block to
verify workflow semantics rather than only string presence: require
WAIVE_UNRELEASED_ENTRIES=true and a non-empty RECOVERY_REASON exclusively in the
workflow_dispatch recovery branch, ensure the reason is recorded, immutable
v0.5.0 same-SHA recovery is validated, and --allow-unreleased-entries is added
only after those checks. Also assert that the release-triggered path cannot use
the waiver.

assert "npm whoami" in preflight
assert "secrets.NPM_TOKEN" in preflight
assert "M1-Release-Candidate-$RELEASE_SHA" in preflight
Expand Down
Loading