ci: promote stable releases from protected previews (#4241)

This commit is contained in:
Can Celik
2026-09-16 18:25:59 +03:00
committed by GitHub
parent aff99878d3
commit 36db8ff3f4
12 changed files with 644 additions and 238 deletions
@@ -8,12 +8,10 @@ Extra user intent/context: `${@:2}`
Process:
1. Determine the base ref.
1. Determine the base ref in the selected preview-based release checkout, not at current master.
- If `$1` is non-empty and looks like a ref/tag, use it.
- Otherwise use the latest release tag, preferring the repo's semver tag style:
```bash
git describe --tags --abbrev=0
```
- Otherwise use the currently published stable version from `origin/master:distribution/latest.json` after fetching master and tags. Do not use `git describe`: preview tags and off-master release preparation make nearest-tag ancestry an unreliable release boundary.
- This base is also the `Previous-Stable` tag recorded by release publication. Audit only changes that will ship from the selected preview, not newer master work.
2. Inspect the range from base ref to `HEAD`.
- Use first-parent history for release context:
+35 -44
View File
@@ -1,12 +1,9 @@
name: Preview
on:
workflow_dispatch:
inputs:
commit:
description: Optional master commit SHA to publish
required: false
type: string
push:
tags:
- "preview-*"
permissions:
contents: read
@@ -21,7 +18,7 @@ concurrency:
jobs:
preflight:
if: github.repository == 'herdrdev/herdr'
if: github.repository == 'herdrdev/herdr' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/preview-')
runs-on: ubuntu-latest
permissions:
contents: read
@@ -34,7 +31,22 @@ jobs:
built_at: ${{ steps.plan.outputs.built_at }}
base_version: ${{ steps.plan.outputs.base_version }}
protocol: ${{ steps.plan.outputs.protocol }}
endpoint_generation: ${{ steps.plan.outputs.endpoint_generation }}
steps:
- &require-release-admins
name: Require repository admins for publishing and reruns
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
for actor in "$GITHUB_ACTOR" "$GITHUB_TRIGGERING_ACTOR"; do
permission="$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$actor/permission" --jq .permission)"
if [ "$permission" != "admin" ]; then
echo "error: $actor must have repository admin permission to publish" >&2
exit 1
fi
done
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: master
@@ -46,16 +58,11 @@ jobs:
shell: bash
run: |
set -euo pipefail
git fetch origin master --tags
requested="${{ github.event.inputs.commit || '' }}"
if [ -n "$requested" ]; then
commit="$(git rev-parse "$requested^{commit}")"
if ! git merge-base --is-ancestor "$commit" origin/master; then
echo "error: requested commit $commit is not reachable from origin/master" >&2
exit 1
fi
else
commit="$(python3 scripts/preview.py select-commit --ref origin/master)"
git fetch --prune origin '+refs/heads/master:refs/remotes/origin/master' '+refs/heads/release/*:refs/remotes/origin/release/*' --tags
commit="$(python3 scripts/release.py preview-source --commit "$GITHUB_SHA")"
if [ "$(git rev-parse "refs/tags/$GITHUB_REF_NAME^{commit}")" != "$commit" ]; then
echo "error: preview tag no longer points at the triggering commit" >&2
exit 1
fi
current_preview="$(python3 scripts/preview.py current-commit --manifest distribution/preview.json || true)"
if [ "$current_preview" = "$commit" ] && node scripts/docs/preview.mjs check; then
@@ -69,8 +76,13 @@ jobs:
built_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
build_id="$day-$short_sha"
tag="preview-$build_id"
if [ "$GITHUB_REF_NAME" != "$tag" ]; then
echo "error: expected preview tag $tag; use just preview" >&2
exit 1
fi
base_version="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)"
protocol="$(python3 -c 'import re; print(re.search(r"pub const PROTOCOL_VERSION: u32 = (\d+);", open("src/protocol/wire.rs").read()).group(1))')"
endpoint_generation="$(python3 -c 'import re; print(re.search(r"pub const ENDPOINT_PROTOCOL_GENERATION: u32 = (\d+);", open("src/protocol/endpoint.rs").read()).group(1))')"
{
echo "should_publish=true"
echo "commit=$commit"
@@ -80,6 +92,7 @@ jobs:
echo "built_at=$built_at"
echo "base_version=$base_version"
echo "protocol=$protocol"
echo "endpoint_generation=$endpoint_generation"
} >> "$GITHUB_OUTPUT"
- name: Install Rust
@@ -119,7 +132,7 @@ jobs:
build:
needs: preflight
if: github.repository == 'herdrdev/herdr' && needs.preflight.outputs.should_publish == 'true'
if: github.repository == 'herdrdev/herdr' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/preview-') && needs.preflight.outputs.should_publish == 'true'
permissions:
contents: read
strategy:
@@ -268,7 +281,7 @@ jobs:
publish:
needs: [preflight, build]
if: github.repository == 'herdrdev/herdr' && needs.preflight.outputs.should_publish == 'true'
if: github.repository == 'herdrdev/herdr' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/preview-') && needs.preflight.outputs.should_publish == 'true'
runs-on: ubuntu-latest
concurrency:
group: docs-publish-master
@@ -277,6 +290,7 @@ jobs:
contents: write
issues: write
steps:
- *require-release-admins
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: master
@@ -311,7 +325,6 @@ jobs:
--previous '${{ steps.previous-preview.outputs.range_base }}' \
--commit '${{ needs.preflight.outputs.commit }}' \
--build-id '${{ needs.preflight.outputs.build_id }}' \
--base-version '${{ needs.preflight.outputs.base_version }}' \
--output PREVIEW_NOTES.md
python3 - <<'PY'
import json, pathlib
@@ -344,7 +357,7 @@ jobs:
echo "error: $PREVIEW_TAG exists as an immutable release with the wrong release type" >&2
exit 1
fi
tag_commit="$(git ls-remote origin "refs/tags/${PREVIEW_TAG}" | awk 'NR == 1 {print $1}')"
tag_commit="$(git rev-parse "refs/tags/${PREVIEW_TAG}^{commit}")"
if [ "$tag_commit" != "$PREVIEW_COMMIT" ]; then
echo "error: immutable release $PREVIEW_TAG points at $tag_commit, expected $PREVIEW_COMMIT" >&2
exit 1
@@ -405,6 +418,7 @@ jobs:
--built-at '${{ needs.preflight.outputs.built_at }}' \
--base-version '${{ needs.preflight.outputs.base_version }}' \
--protocol '${{ needs.preflight.outputs.protocol }}' \
--endpoint-generation '${{ needs.preflight.outputs.endpoint_generation }}' \
--notes PREVIEW_NOTES.md \
--sha-file preview-sha256.json \
--retain 30
@@ -522,26 +536,3 @@ jobs:
continue
fi
done
- name: Prune old preview prereleases
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh release list --repo "$GITHUB_REPOSITORY" --limit 100 --json tagName,isPrerelease,createdAt > preview-releases.json
python3 - <<'PY' > old-preview-tags.txt
import json
with open("preview-releases.json", encoding="utf-8") as handle:
data = json.load(handle)
releases = [
release for release in data
if release.get("isPrerelease") and str(release.get("tagName", "")).startswith("preview-")
]
releases.sort(key=lambda release: str(release.get("createdAt", "")), reverse=True)
for release in releases[30:]:
print(release["tagName"])
PY
while IFS= read -r tag; do
[ -n "$tag" ] || continue
gh release delete "$tag" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag
done < old-preview-tags.txt
+57 -15
View File
@@ -13,7 +13,41 @@ env:
RUST_TOOLCHAIN_VERSION: 1.96.1
jobs:
validate-release-source:
if: github.repository == 'herdrdev/herdr' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
outputs:
preview_commit: ${{ steps.source.outputs.preview_commit }}
previous_tag: ${{ steps.source.outputs.previous_tag }}
steps:
- &require-release-admins
name: Require repository admins for publishing and reruns
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
for actor in "$GITHUB_ACTOR" "$GITHUB_TRIGGERING_ACTOR"; do
permission="$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$actor/permission" --jq .permission)"
if [ "$permission" != "admin" ]; then
echo "error: $actor must have repository admin permission to publish" >&2
exit 1
fi
done
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
persist-credentials: false
- name: Require a published preview and release-only changes
id: source
env:
GH_TOKEN: ${{ github.token }}
run: |
git fetch origin master:refs/remotes/origin/master --tags
python3 scripts/release.py check-tag --tag "$GITHUB_REF_NAME" --github-output "$GITHUB_OUTPUT"
flake-check:
needs: validate-release-source
if: github.repository == 'herdrdev/herdr'
runs-on: ubuntu-latest
permissions:
@@ -37,6 +71,7 @@ jobs:
nix flake check --all-systems --no-build --print-build-logs
build:
needs: validate-release-source
if: github.repository == 'herdrdev/herdr'
permissions:
contents: read
@@ -166,6 +201,7 @@ jobs:
path: ${{ matrix.name }}
validate-release-inputs:
needs: validate-release-source
if: github.repository == 'herdrdev/herdr'
runs-on: ubuntu-latest
permissions:
@@ -195,12 +231,13 @@ jobs:
release:
needs: [build, flake-check, validate-release-inputs]
if: github.repository == 'herdrdev/herdr'
if: github.repository == 'herdrdev/herdr' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- *require-release-admins
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
@@ -224,13 +261,14 @@ jobs:
update-nix-package:
needs: release
if: github.repository == 'herdrdev/herdr'
if: github.repository == 'herdrdev/herdr' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
timeout-minutes: 2
continue-on-error: true
permissions: {}
steps:
- *require-release-admins
- name: Trigger stable Nix package update
env:
GH_TOKEN: ${{ secrets.HERDR_NIX_DISPATCH_TOKEN }}
@@ -242,8 +280,8 @@ jobs:
fi
close-released-issues:
needs: release
if: github.repository == 'herdrdev/herdr'
needs: [release, validate-release-source]
if: github.repository == 'herdrdev/herdr' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
continue-on-error: true
permissions:
@@ -251,6 +289,7 @@ jobs:
issues: write
steps:
- *require-release-admins
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
@@ -263,6 +302,7 @@ jobs:
NEXT_RELEASE_LABEL: pending-release
LEGACY_NEXT_RELEASE_LABEL: included-in-next-release
PREVIEW_RELEASED_LABEL: preview-released
PREVIOUS_TAG: ${{ needs.validate-release-source.outputs.previous_tag }}
run: |
set -euo pipefail
@@ -270,13 +310,6 @@ jobs:
VERSION="${GITHUB_REF_NAME#v}"
CURRENT_COMMIT="$(git rev-list -n 1 "$GITHUB_REF_NAME")"
PREVIOUS_TAG="$(git describe --first-parent --tags --match 'v[0-9]*' --abbrev=0 "${CURRENT_COMMIT}^" 2>/dev/null || true)"
if [ -z "$PREVIOUS_TAG" ]; then
echo "No previous release tag found; skipping issue close."
exit 0
fi
echo "Scanning released commits in $PREVIOUS_TAG..$GITHUB_REF_NAME for refs #<issue> mentions."
mapfile -t ISSUES < <(
git log --format='%s%n%b' "$PREVIOUS_TAG..$CURRENT_COMMIT" \
@@ -379,8 +412,8 @@ jobs:
done
update-latest-json:
needs: release
if: github.repository == 'herdrdev/herdr'
needs: [release, validate-release-source]
if: github.repository == 'herdrdev/herdr' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
concurrency:
group: docs-publish-master
@@ -389,6 +422,7 @@ jobs:
contents: write
steps:
- *require-release-admins
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: master
@@ -398,6 +432,7 @@ jobs:
- name: Publish tagged documentation and update distribution manifest
env:
GH_TOKEN: ${{ github.token }}
PREVIEW_COMMIT: ${{ needs.validate-release-source.outputs.preview_commit }}
run: |
VERSION="${GITHUB_REF_NAME#v}"
node scripts/docs/versions.mjs publish "$GITHUB_REF_NAME"
@@ -407,6 +442,7 @@ jobs:
cp "$ANNOUNCEMENT_PATH" "$ANNOUNCEMENT_ORIGINAL_PATH"
python3 scripts/changelog.py validate-product-announcement --path "$ANNOUNCEMENT_PATH"
RELEASE_PROTOCOL=$(git show "${GITHUB_REF_NAME}:src/protocol/wire.rs" | python3 -c 'import re, sys; match = re.search(r"pub const PROTOCOL_VERSION: u32 = (\d+);", sys.stdin.read()); sys.exit(1) if match is None else print(match.group(1))')
RELEASE_ENDPOINT_GENERATION=$(git show "${GITHUB_REF_NAME}:src/protocol/endpoint.rs" | python3 -c 'import re, sys; match = re.search(r"pub const ENDPOINT_PROTOCOL_GENERATION: u32 = (\d+);", sys.stdin.read()); sys.exit(1) if match is None else print(match.group(1))')
DOCS_CURRENT=$(node scripts/docs/versions.mjs current)
if [ "$DOCS_CURRENT" != "$VERSION" ]; then
@@ -418,7 +454,12 @@ jobs:
echo "distribution/latest.json is already at v$VERSION"
exit 0
fi
python3 scripts/changelog.py sync-latest-json --version "$VERSION" --output distribution/latest.json --announcement "$ANNOUNCEMENT_PATH" --protocol "$RELEASE_PROTOCOL"
# Apply only release preparation, never the hotfix implementation or newer master code.
git diff --binary "$PREVIEW_COMMIT" "$GITHUB_REF_NAME" > "$RUNNER_TEMP/release-preparation.patch"
if [ -s "$RUNNER_TEMP/release-preparation.patch" ]; then
git apply --3way --index "$RUNNER_TEMP/release-preparation.patch"
fi
python3 scripts/changelog.py sync-latest-json --version "$VERSION" --output distribution/latest.json --announcement "$ANNOUNCEMENT_PATH" --protocol "$RELEASE_PROTOCOL" --endpoint-generation "$RELEASE_ENDPOINT_GENERATION"
if cmp -s "$ANNOUNCEMENT_ORIGINAL_PATH" docs/next/product-announcement.json; then
printf 'null\n' > docs/next/product-announcement.json
else
@@ -431,7 +472,8 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -A README.md README.zh-CN.md docs/versions distribution/latest.json docs/next/product-announcement.json
git diff --cached --quiet || git commit -m "docs: publish release distribution for v$VERSION"
# Release-only changes applied above are already staged by git apply --index.
git diff --cached --quiet || git commit -m "release: synchronize metadata for v$VERSION"
for attempt in 1 2 3; do
git pull --rebase origin master
node scripts/docs/versions.mjs check
+15 -5
View File
@@ -261,7 +261,7 @@ account is not a verified maintainer, do not run release commands, push release
assets, or modify release channel files; follow the external contributor
guardrail.
Herdr has one main branch and two update channels. Stable and preview both build from `master`; there is no long-lived preview branch.
Herdr has one main branch and two update channels. Normal previews select a commit from `master`. Stable promotes a published preview, never the latest `master`. There is no long-lived release or preview branch.
Normal users default to stable. Stable docs are `/docs/`, stable updates use `distribution/latest.json`, and Homebrew/Nix stay stable-only.
@@ -279,16 +279,26 @@ herdr channel set stable
herdr update
```
Preview releases are GitHub prereleases produced by `.github/workflows/preview.yml` on manual dispatch and the Wednesday/Friday schedule. The workflow updates `distribution/preview.json`, which the private website publishes as `/preview.json`. Do not hand-edit `distribution/preview.json`; fix the workflow or `scripts/preview.py` and rerun Preview.
Preview releases are GitHub prereleases produced by `.github/workflows/preview.yml` only on `preview-*` tag pushes. Use `just preview <commit-or-ref>` (default: HEAD) to validate the source, create the annotated `preview-<commit-date>-<short-sha>` tag, and push it. Normal source commits must be reachable from master and contain the tag-triggered preview workflow; older dispatch-only revisions cannot be previewed by tagging them. For an isolated hotfix, create a temporary `release/<name>` branch from the current stable tag, apply only the reviewed fix, push that branch, then run `just preview` at its tip. CI validates the tagged commit, not a moving branch. Branch naming and ancestry prevent selection mistakes; they do not replace reviewing the hotfix diff. Ensure the fix also reaches master. Preview is required even for hotfixes. A hotfix based on a legacy stable release must include the promotion tooling update before previewing; CI rejects candidates that still carry the old ungated stable workflow.
Stable releases use:
All tags are protected by the repository's `release-tags` ruleset: only repository admins may create, update, or delete them. Do not grant GitHub Actions or writer bots a tag bypass. Both publishing workflows require tag-push events and check the original actor's and rerun actor's current repository admin permission before publication. Normal PR test workflows remain automatic and unchanged. Immutable releases protect published binaries; the explicitly accepted residual risk is that a trusted Write maintainer can publish against an existing tag before its legitimate release is published. These controls do not revoke GitHub's broader Write-role release-management permissions.
Preview notes contain only the build identifier (date and source SHA) and a comparison link. Do not generate a categorized commit summary for previews; curated release notes belong to stable releases.
The workflow updates `distribution/preview.json`, which the private website publishes as `/preview.json`. Do not hand-edit `distribution/preview.json`; fix the workflow or `scripts/preview.py` and rerun Preview. Published preview releases and tags are retained; CI must not delete protected tags or leave old preview tags without their releases.
Stable releases start in an isolated checkout at the selected published preview tag, not current master. Commit curated release docs there, then use:
```bash
just check
just release 0.x.y
just release 0.x.y preview-<build-id>
```
Before stable release, run `/pre-release-audit`, finalize `docs/next`, and run `just pre-release-check` to validate the staged docs, distribution contract, and render scaling. `just release` prepares the changelog and release commit, tags it, and pushes the tag. GitHub Actions builds binaries, creates the GitHub release, closes released issues, snapshots and promotes the tagged docs, and updates `distribution/latest.json`. The private website repository owns rendering and deployment.
Before stable release, run `/pre-release-audit` against the currently published stable tag, finalize `docs/next`, and run `just pre-release-check` to validate the staged docs, distribution contract, and render scaling. `just release` prepares the changelog and release commit, validates the preview-to-release diff, and pushes only an annotated stable tag. Its `Preview` and `Previous-Stable` trailers are required provenance, not optional notes. `just release-prepare` and `just release-publish` also require the preview tag argument. Do not merge or rebase newer master commits into the candidate.
Only the Herdr package version in Cargo.toml/Cargo.lock, changelogs, staged READMEs, staged website prose, product announcement, and stable skill may differ from the preview. Code, dependencies, API schemas, build configuration, and other files must match. These checks run locally and in CI before stable builds. Old previews without this promotion tooling require a new preview first. Stable rebuilds the selected source with stable version identity; it does not reuse preview binaries.
GitHub Actions builds binaries, creates the GitHub release, closes issues using the recorded previous stable boundary, snapshots the tagged docs, and updates `distribution/latest.json`. It applies only the release-preparation diff back to master with a three-way merge, preserving newer development. A conflict stops distribution publication and needs manual resolution; do not resolve it by copying the whole release tree over master. Remove temporary release/hotfix branches after publication and metadata reconciliation. The private website repository owns rendering and deployment.
Before the first stable Windows release, publish and verify a preview containing stable-channel support. Existing Windows preview users need that preview before `herdr channel set stable` can migrate them.
+27 -26
View File
@@ -13,7 +13,8 @@ test:
# Run repository maintenance contract tests
maintenance-test:
{{python}} -m unittest scripts.test_agent_detection_manifest_check scripts.test_changelog scripts.test_config_reference_check scripts.test_docs_translation_parity scripts.test_hermes_integration_asset scripts.test_package_windows_conpty scripts.test_preview scripts.test_unix_installer scripts.test_vendor_libghostty_vt scripts.test_vendor_portable_pty scripts.test_windows_cross
{{python}} -m unittest scripts.test_agent_detection_manifest_check scripts.test_changelog scripts.test_config_reference_check scripts.test_docs_translation_parity scripts.test_hermes_integration_asset scripts.test_package_windows_conpty scripts.test_preview scripts.test_release scripts.test_unix_installer scripts.test_vendor_libghostty_vt scripts.test_vendor_portable_pty scripts.test_windows_cross
bun test scripts/release-workflows.test.ts
# Run one nextest filter, e.g. `just test-one codex_stale_working`
test-one filter:
@@ -151,8 +152,19 @@ pre-release-check:
@echo "release review required: update skills/herdr/SKILL.md for this stable release so it matches the current CLI, IDs, agent lifecycle semantics, and safety guidance."
@echo "release policy: do not update skills/herdr/SKILL.md between stable releases; preview builds keep the latest stable skill."
# Prepare the release commit without tagging or pushing (usage: just release-prepare 0.1.1)
release-prepare version:
# Publish a preview by pushing an admin-owned tag at the selected source commit.
preview ref='HEAD':
git fetch --prune origin '+refs/heads/master:refs/remotes/origin/master' '+refs/heads/release/*:refs/remotes/origin/release/*' --tags
@set -eu; \
commit="$(python3 scripts/release.py preview-source --commit '{{ref}}')"; \
day="$(git show -s --format=%cs "$commit")"; \
short="$(git rev-parse --short=12 "$commit")"; \
tag="preview-$day-$short"; \
git tag -a "$tag" "$commit" -m "$tag"; \
git push origin "refs/tags/$tag"
# In a checkout based on the selected preview, prepare release-only metadata.
release-prepare version preview:
@printf '%s\n' '{{version}}' | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' || { \
echo "error: version must look like 0.6.6 without a v prefix"; \
exit 1; \
@@ -168,6 +180,7 @@ release-prepare version:
echo "error: tag v{{version}} already exists"; \
exit 1; \
fi
python3 scripts/release.py check-source --preview '{{preview}}'
just pre-release-check
python3 scripts/changelog.py prepare --version {{version}}
cp CHANGELOG.md docs/next/CHANGELOG.md
@@ -176,10 +189,11 @@ release-prepare version:
just check
git add CHANGELOG.md docs/next/CHANGELOG.md Cargo.toml Cargo.lock skills/herdr/SKILL.md
git diff --cached --quiet || git commit -m "release: v{{version}}"
@echo "v{{version}} release commit prepared. Review it, then run: just release-publish {{version}}"
python3 scripts/release.py check-source --preview '{{preview}}'
@echo "v{{version}} release commit prepared. Review it, then run: just release-publish {{version}} {{preview}}"
# Tag and push an already-prepared release commit (usage: just release-publish 0.1.1)
release-publish version:
# Tag a prepared preview-based release; never move master to the release candidate.
release-publish version preview:
@printf '%s\n' '{{version}}' | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' || { \
echo "error: version must look like 0.6.6 without a v prefix"; \
exit 1; \
@@ -188,11 +202,6 @@ release-publish version:
echo "error: working tree must be clean before publishing"; \
exit 1; \
fi
@branch="$(git branch --show-current)"; \
if [ "$branch" != "master" ]; then \
echo "error: release-publish must run from master, got $branch"; \
exit 1; \
fi
@git fetch origin master --tags
@if git rev-parse "v{{version}}" >/dev/null 2>&1; then \
echo "error: tag v{{version}} already exists"; \
@@ -206,24 +215,16 @@ release-publish version:
just release-docs-check
python3 scripts/changelog.py extract --version {{version}} --output /tmp/herdr-release-notes-check.md
rm -f /tmp/herdr-release-notes-check.md
@local_head="$(git rev-parse HEAD)"; \
remote_head="$(git rev-parse origin/master)"; \
if ! git merge-base --is-ancestor "$remote_head" "$local_head"; then \
echo "error: origin/master is not an ancestor of HEAD; pull or rebase before publishing"; \
exit 1; \
fi; \
if [ "$local_head" != "$remote_head" ]; then \
echo "pushing release commit to origin/master"; \
git push origin HEAD:master; \
fi
git tag -a v{{version}} -m "v{{version}}"
@previous="$(git show origin/master:distribution/latest.json | python3 -c 'import json,sys; print("v" + json.load(sys.stdin)["version"])')"; \
python3 scripts/release.py check --preview '{{preview}}' --version '{{version}}' --previous "$previous" && \
git tag -a v{{version}} -m "v{{version}}" -m "Preview: {{preview}}" -m "Previous-Stable: $previous"
git push origin v{{version}}
@echo "v{{version}} released — GitHub Actions building binaries and updating distribution/latest.json"
# Prepare, verify, tag, push, and trigger the GitHub Release workflow (usage: just release 0.1.1)
release version:
just release-prepare {{version}}
just release-publish {{version}}
# Prepare and promote a published preview, not the latest master.
release version preview:
just release-prepare {{version}} {{preview}}
just release-publish {{version}} {{preview}}
# Print default config
default-config:
+5 -1
View File
@@ -317,6 +317,7 @@ def build_latest_json(
protocol: int | None = None,
announcement: dict[str, str] | None = None,
releases: dict[str, Any] | None = None,
endpoint_generation: int | None = None,
) -> str:
normalized_version = normalize_version(version)
normalized_notes = notes.strip()
@@ -330,7 +331,8 @@ def build_latest_json(
ordered_sha256 = normalize_sha256(sha256, "sha256")
normalized_announcement = normalize_announcement(announcement, "root")
archived_releases = normalize_releases(releases)
endpoint_generation = read_endpoint_protocol_generation()
if endpoint_generation is None:
endpoint_generation = read_endpoint_protocol_generation()
current_metadata: dict[str, Any] = {
"notes": normalized_notes,
"protocol": protocol,
@@ -714,6 +716,7 @@ def cmd_sync_latest_json(args: argparse.Namespace) -> int:
protocol=int(new_manifest["protocol"]),
announcement=announcement,
releases=archived_releases_from_current_manifest(current_manifest),
endpoint_generation=args.endpoint_generation,
)
write_text(manifest_path, output)
if announcement is not None:
@@ -808,6 +811,7 @@ def build_parser() -> argparse.ArgumentParser:
sync_latest_json.add_argument("--output", default=str(DEFAULT_LATEST_JSON_PATH))
sync_latest_json.add_argument("--announcement", default=str(DEFAULT_PRODUCT_ANNOUNCEMENT_PATH))
sync_latest_json.add_argument("--protocol", type=int)
sync_latest_json.add_argument("--endpoint-generation", type=int)
sync_latest_json.set_defaults(func=cmd_sync_latest_json)
validate_product_announcement = subparsers.add_parser(
+10 -104
View File
@@ -19,25 +19,6 @@ EXPECTED_ASSET_NAMES = {
**{target: f"herdr-{target}" for target in ASSET_TARGETS},
"windows-x86_64": "herdr-windows-x86_64.zip",
}
HIDDEN_SUBJECTS = (
"docs: publish release distribution",
"docs: update website manifest",
"docs: update preview manifest",
"chore: approve contributor",
"chore: approve merged contributor",
)
TYPE_HEADINGS = {
"feat": "Added",
"fix": "Fixed",
"perf": "Performance",
"docs": "Maintenance",
"ci": "Maintenance",
"test": "Maintenance",
"refactor": "Maintenance",
"chore": "Maintenance",
}
TYPE_ORDER = ("Added", "Fixed", "Performance", "Maintenance", "Other")
COMMIT_RE = re.compile(r"^(?P<kind>[a-z]+)(?:\([^)]+\))?!?:\s+(?P<body>.+)$")
ENDPOINT_PROTOCOL_SOURCE_PATH = Path("src/protocol/endpoint.rs")
@@ -92,90 +73,21 @@ def previous_preview_commit(path: Path) -> str | None:
return commit if isinstance(commit, str) and commit.strip() else None
def hidden_subject(subject: str) -> bool:
lowered = subject.strip().lower()
return any(lowered.startswith(prefix) for prefix in HIDDEN_SUBJECTS)
def latest_publishable_commit(ref: str) -> str:
output = run_git(["log", "--pretty=format:%H%x00%s", ref])
for line in output.splitlines():
commit, _, subject = line.partition("\x00")
if commit and not hidden_subject(subject):
return commit
raise SystemExit(f"no publishable commit found in {ref}")
def commit_subjects(previous: str, commit: str) -> list[str]:
output = run_git(["log", "--pretty=format:%s", f"{previous}..{commit}"])
if not output:
return []
subjects = []
for line in output.splitlines():
stripped = line.strip()
if not stripped:
continue
if hidden_subject(stripped):
continue
subjects.append(stripped)
return subjects
def preview_range_base(previous: str, commit: str) -> str:
try:
stable = latest_stable_tag(commit)
except subprocess.CalledProcessError:
return previous
if not git_is_ancestor(previous, commit):
return stable
if git_is_ancestor(previous, stable) and git_is_ancestor(stable, commit):
return stable
return previous
def humanize_subject(subject: str) -> tuple[str, str]:
match = COMMIT_RE.match(subject)
if not match:
return "Other", subject[0].upper() + subject[1:]
kind = match.group("kind")
body = match.group("body").strip()
heading = TYPE_HEADINGS.get(kind, "Other")
if body:
body = body[0].upper() + body[1:]
else:
body = subject
return heading, body
def build_notes(previous: str, commit: str, build_id: str, base_version: str, repo: str) -> str:
short = commit[:12]
def build_notes(previous: str, commit: str, build_id: str, repo: str) -> str:
compare = f"https://github.com/{repo}/compare/{previous}...{commit}"
lines = [
f"Preview build {build_id}",
"",
f"Built from `{short}` on `master`.",
f"Base stable: v{normalize_version(base_version)}",
f"Compare: {compare}",
"",
]
grouped: dict[str, list[str]] = {heading: [] for heading in TYPE_ORDER}
for subject in commit_subjects(previous, commit):
heading, body = humanize_subject(subject)
grouped.setdefault(heading, []).append(body)
wrote = False
for heading in TYPE_ORDER:
items = grouped.get(heading, [])
if not items:
continue
wrote = True
lines.append(f"### {heading}")
for item in items:
lines.append(f"- {item}")
lines.append("")
if not wrote:
lines.extend(["### Changed", "- Rebuilt preview from the current master branch.", ""])
return "\n".join(lines).rstrip() + "\n"
return f"Preview build {build_id}\n\n[View changes]({compare})\n"
def default_asset_urls(repo: str, tag: str) -> dict[str, str]:
@@ -222,13 +134,15 @@ def build_manifest(
notes: str,
shas: dict[str, str],
retain: int,
endpoint_generation: int | None = None,
) -> str:
urls = default_asset_urls(repo, tag)
assets = asset_objects(urls, shas)
current = read_json(output) or {}
builds = current.get("builds") if isinstance(current.get("builds"), dict) else {}
builds = dict(builds)
endpoint_generation = read_endpoint_protocol_generation()
if endpoint_generation is None:
endpoint_generation = read_endpoint_protocol_generation()
builds[build_id] = {
"base_version": normalize_version(base_version),
"commit": commit,
@@ -264,7 +178,7 @@ def build_manifest(
def cmd_notes(args: argparse.Namespace) -> int:
previous = args.previous or previous_preview_commit(Path(args.manifest)) or latest_stable_tag()
notes = build_notes(previous, args.commit, args.build_id, args.base_version, args.repo)
notes = build_notes(previous, args.commit, args.build_id, args.repo)
Path(args.output).write_text(notes, encoding="utf-8")
return 0
@@ -284,6 +198,7 @@ def cmd_manifest(args: argparse.Namespace) -> int:
notes=notes,
shas=shas,
retain=args.retain,
endpoint_generation=args.endpoint_generation,
)
Path(args.output).write_text(content, encoding="utf-8")
return 0
@@ -296,11 +211,6 @@ def cmd_current_commit(args: argparse.Namespace) -> int:
return 0
def cmd_select_commit(args: argparse.Namespace) -> int:
print(latest_publishable_commit(args.ref))
return 0
def cmd_range_base(args: argparse.Namespace) -> int:
print(preview_range_base(args.previous, args.commit))
return 0
@@ -315,7 +225,6 @@ def main() -> int:
notes.add_argument("--previous")
notes.add_argument("--commit", required=True)
notes.add_argument("--build-id", required=True)
notes.add_argument("--base-version", required=True)
notes.add_argument("--repo", default="herdrdev/herdr")
notes.add_argument("--output", required=True)
notes.set_defaults(func=cmd_notes)
@@ -329,6 +238,7 @@ def main() -> int:
manifest.add_argument("--built-at", required=True)
manifest.add_argument("--base-version", required=True)
manifest.add_argument("--protocol", required=True, type=int)
manifest.add_argument("--endpoint-generation", required=True, type=int)
manifest.add_argument("--notes", required=True)
manifest.add_argument("--sha-file")
manifest.add_argument("--retain", type=int, default=30)
@@ -338,10 +248,6 @@ def main() -> int:
current.add_argument("--manifest", default="distribution/preview.json")
current.set_defaults(func=cmd_current_commit)
select = sub.add_parser("select-commit")
select.add_argument("--ref", default="origin/master")
select.set_defaults(func=cmd_select_commit)
range_base = sub.add_parser("range-base")
range_base.add_argument("--previous", required=True)
range_base.add_argument("--commit", required=True)
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, test } from "bun:test";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { join } from "node:path";
const load = (name: string): any =>
Bun.YAML.parse(readFileSync(new URL(`../.github/workflows/${name}.yml`, import.meta.url), "utf8"));
const preview = load("preview");
const release = load("release");
const adminGate = release.jobs["validate-release-source"].steps[0];
describe("official publishing workflow boundaries", () => {
test("publishing is tag-only while normal PR CI remains enabled", () => {
expect(preview.on).toEqual({ push: { tags: ["preview-*"] } });
expect(release.on).toEqual({ push: { tags: ["v*"] } });
expect(load("ci").on.pull_request).toBeDefined();
});
test("each publishing job rechecks both actors before using credentials", () => {
for (const [workflow, names] of [
[preview, ["preflight", "publish"]],
[release, ["validate-release-source", "release", "update-nix-package", "close-released-issues", "update-latest-json"]],
] as const) {
for (const name of names) {
const job = workflow.jobs[name];
expect(job.if).toContain("github.event_name == 'push'");
expect(job.if).toContain("startsWith(github.ref, 'refs/tags/");
expect(job.steps[0]).toEqual(adminGate);
}
}
expect(adminGate.run).toContain('"$GITHUB_ACTOR" "$GITHUB_TRIGGERING_ACTOR"');
expect(adminGate.env.GH_TOKEN).toBe("${{ github.token }}");
expect(adminGate.run).not.toContain("ogulcancelik");
});
test.skipIf(process.platform === "win32")("admin gate permits admins and fails closed for other roles or API errors", () => {
const dir = mkdtempSync("/var/tmp/herdr-admin-gate-");
try {
writeFileSync(join(dir, "gh"), `#!/bin/sh
case "$2" in
*/collaborators/admin-*/permission) echo admin ;;
*/collaborators/maintainer/permission) echo maintain ;;
*/collaborators/writer/permission) echo write ;;
*) exit 1 ;;
esac
`, { mode: 0o755 });
for (const [actor, trigger, succeeds] of [
["admin-one", "admin-two", true],
["writer", "admin-two", false],
["admin-one", "writer", false],
["admin-one", "maintainer", false],
["admin-one", "api-error", false],
] as const) {
const result = spawnSync("bash", ["-c", adminGate.run], {
env: { ...process.env, PATH: `${dir}:${process.env.PATH}`, GITHUB_REPOSITORY: "example/test", GITHUB_ACTOR: actor, GITHUB_TRIGGERING_ACTOR: trigger },
encoding: "utf8",
});
expect(result.status === 0).toBe(succeeds);
}
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
+210
View File
@@ -0,0 +1,210 @@
"""Validate stable source promotion; never build, tag, push, or publish a release."""
import argparse
import json
import re
import subprocess
import tomllib
from pathlib import Path
RELEASE_FILES = {
"CHANGELOG.md",
"docs/next/CHANGELOG.md",
"docs/next/README.md",
"docs/next/README.zh-CN.md",
"docs/next/product-announcement.json",
"skills/herdr/SKILL.md",
}
ASSETS = {
"herdr-linux-x86_64",
"herdr-linux-aarch64",
"herdr-macos-x86_64",
"herdr-macos-aarch64",
"herdr-windows-x86_64.zip",
}
def git(*args: str) -> str:
return subprocess.check_output(["git", *args], text=True).strip()
def version_tuple(version: str) -> tuple[int, ...]:
if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version):
raise ValueError(f"invalid stable version: {version}")
return tuple(map(int, version.split(".")))
def resolve(ref: str) -> str:
return git("rev-parse", "--verify", f"{ref}^{{commit}}")
def ancestor(base: str, commit: str) -> bool:
return subprocess.run(
["git", "merge-base", "--is-ancestor", base, commit], check=False
).returncode == 0
def published_preview(tag: str, repo: str) -> str:
if not re.fullmatch(r"preview-[A-Za-z0-9.-]+", tag):
raise ValueError("select a published preview-… tag")
commit = resolve(f"refs/tags/{tag}")
payload = json.loads(subprocess.check_output(
["gh", "api", f"repos/{repo}/releases/tags/{tag}"], text=True
))
if (
payload.get("tag_name") != tag
or payload.get("draft") is not False
or payload.get("prerelease") is not True
or payload.get("immutable") is not True
or not ASSETS.issubset({asset["name"] for asset in payload.get("assets", [])})
):
raise ValueError(f"{tag} must be an immutable published preview with all five assets")
# Resolve the remote tag too: a local tag must not substitute different source.
remote = git("ls-remote", f"https://github.com/{repo}.git", f"refs/tags/{tag}", f"refs/tags/{tag}^{{}}")
refs = dict(line.split()[::-1] for line in remote.splitlines())
remote_commit = refs.get(f"refs/tags/{tag}^{{}}", refs.get(f"refs/tags/{tag}"))
if commit != remote_commit:
raise ValueError(f"local preview tag {tag} does not match its published commit")
return commit
def normalized_cargo(text: str, path: str) -> tuple[dict, str]:
data = tomllib.loads(text)
if path == "Cargo.toml":
version = data["package"].pop("version")
else:
packages = [p for p in data["package"] if p["name"] == "herdr" and "source" not in p]
if len(packages) != 1:
raise ValueError("expected exactly one local herdr package in Cargo.lock")
version = packages[0].pop("version")
return data, version
def validate_diff(preview: str, candidate: str, version: str | None = None) -> None:
if not ancestor(preview, candidate):
raise ValueError("release must descend from the selected preview; do not rebase onto master")
changed = git("diff", "--no-renames", "--name-only", preview, candidate).splitlines()
for path in changed:
if path in {"Cargo.toml", "Cargo.lock"}:
before, _ = normalized_cargo(git("show", f"{preview}:{path}"), path)
after, _ = normalized_cargo(git("show", f"{candidate}:{path}"), path)
if before != after:
raise ValueError(f"{path}: only the herdr package version may change")
elif path not in RELEASE_FILES and not (
path.startswith("docs/next/website/src/content/docs/")
and path.endswith((".md", ".mdx"))
):
raise ValueError(f"unpreviewed change: {path}; publish a new preview first")
# Documentation exceptions must not turn into symlinks or submodules.
entry = git("ls-tree", candidate, "--", path)
if entry and not entry.startswith("100644 blob "):
raise ValueError(f"release preparation must use regular files: {path}")
versions = [normalized_cargo(git("show", f"{candidate}:{path}"), path)[1]
for path in ("Cargo.toml", "Cargo.lock")]
if versions[0] != versions[1] or (version is not None and versions[0] != version):
raise ValueError("release version must match Cargo.toml and Cargo.lock")
def tag_metadata(tag: str) -> tuple[str, str]:
if not re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+", tag):
raise ValueError("expected a stable vX.Y.Z tag")
if git("cat-file", "-t", f"refs/tags/{tag}") != "tag":
raise ValueError("stable releases require an annotated tag with preview provenance")
message = git("for-each-ref", "--format=%(contents)", f"refs/tags/{tag}")
fields = []
for key in ("Preview", "Previous-Stable"):
values = re.findall(rf"^{key}: (\S+)$", message, re.MULTILINE)
if len(values) != 1:
raise ValueError(f"release tag requires exactly one {key}: trailer")
fields.append(values[0])
if not re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+", fields[1]):
raise ValueError("invalid Previous-Stable tag")
return fields[0], fields[1]
def validate_release(preview_tag: str, candidate: str, version: str, previous: str, repo: str) -> str:
preview = published_preview(preview_tag, repo)
validate_diff(preview, candidate, version)
current = json.loads(git("show", "origin/master:distribution/latest.json"))["version"]
if version_tuple(version) <= version_tuple(previous.removeprefix("v")):
raise ValueError("stable version must increase from Previous-Stable")
if current == version:
# A retry after distribution publication must still validate the original boundary.
if resolve(f"refs/tags/v{version}") != resolve(candidate):
raise ValueError("published stable version points at different source")
elif previous != f"v{current}":
raise ValueError("Previous-Stable must name the currently published stable release")
resolve(f"refs/tags/{previous}")
return preview
def select_hotfix(branch: str, base: str) -> str:
if not re.fullmatch(r"release/[A-Za-z0-9][A-Za-z0-9._-]*", branch):
raise ValueError("hotfix previews require an explicit release/* branch")
if not re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+", base):
raise ValueError("hotfix base must be the current published stable tag")
commit = resolve(f"refs/remotes/origin/{branch}")
if not ancestor(resolve(f"refs/tags/{base}"), commit):
raise ValueError(f"hotfix branch must descend from {base}")
files = git("ls-tree", "--name-only", commit, "--", "scripts/release.py")
if not files or "scripts/release.py check-tag" not in git("show", f"{commit}:.github/workflows/release.yml"):
raise ValueError("hotfix source predates preview promotion; include the promotion tooling before previewing")
return commit
def select_preview(ref: str) -> str:
commit = resolve(ref)
workflow = git("show", f"{commit}:.github/workflows/preview.yml")
if not re.search(r'(?m)^on:\n push:\n tags:\n - "preview-\*"$', workflow):
raise ValueError("preview source predates tag-triggered previews; select a commit with the new publishing workflow")
if ancestor(commit, "refs/remotes/origin/master"):
return commit
base = "v" + json.loads(git("show", "origin/master:distribution/latest.json"))["version"]
branches = git("for-each-ref", "--format=%(refname:strip=3)", "refs/remotes/origin/release/")
for branch in branches.splitlines():
if resolve(f"refs/remotes/origin/{branch}") == commit:
return select_hotfix(branch, base)
raise ValueError("preview source must be on master or the tip of a published release/* hotfix branch")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
prepare = commands.add_parser("check-source")
prepare.add_argument("--preview", required=True)
prepare.add_argument("--commit", default="HEAD")
prepare.add_argument("--repo", default="herdrdev/herdr")
check = commands.add_parser("check")
check.add_argument("--preview", required=True)
check.add_argument("--version", required=True)
check.add_argument("--previous", required=True)
check.add_argument("--commit", default="HEAD")
check.add_argument("--repo", default="herdrdev/herdr")
tag = commands.add_parser("check-tag")
tag.add_argument("--tag", required=True)
tag.add_argument("--repo", default="herdrdev/herdr")
tag.add_argument("--github-output", type=Path)
preview = commands.add_parser("preview-source")
preview.add_argument("--commit", default="HEAD")
args = parser.parse_args()
if args.command == "preview-source":
print(select_preview(args.commit))
elif args.command == "check-source":
validate_diff(published_preview(args.preview, args.repo), args.commit)
elif args.command == "check":
validate_release(args.preview, args.commit, args.version, args.previous, args.repo)
else:
preview, previous = tag_metadata(args.tag)
commit = validate_release(preview, args.tag, args.tag[1:], previous, args.repo)
if args.github_output:
with args.github_output.open("a", encoding="utf-8") as output:
output.write(f"preview_commit={commit}\nprevious_tag={previous}\n")
print(f"{args.tag} promotes {preview}; previous stable: {previous}")
if __name__ == "__main__":
try:
main()
except (ValueError, subprocess.CalledProcessError, KeyError) as error:
raise SystemExit(f"error: {error}") from error
+8
View File
@@ -102,6 +102,14 @@ class ChangelogScriptTests(unittest.TestCase):
self.assertEqual(manifest["protocol"], read_protocol_version())
self.assertEqual(manifest["notes"], "### Fixed\n- One")
def test_build_latest_json_uses_selected_release_endpoint_generation(self) -> None:
manifest = json.loads(build_latest_json(
"0.1.1", "Release notes", release_assets("0.1.1"), release_sha256(),
endpoint_generation=7,
))
self.assertEqual(manifest["endpoint_generation"], 7)
self.assertEqual(manifest["releases"]["0.1.1"]["endpoint_generation"], 7)
def test_build_latest_json_embeds_notes_and_release_assets(self) -> None:
manifest = json.loads(
build_latest_json(
+20 -38
View File
@@ -11,21 +11,14 @@ import scripts.preview as preview
class PreviewNotesTests(unittest.TestCase):
def test_humanize_groups_conventional_subjects(self):
def test_notes_contain_only_build_and_comparison_link(self):
self.assertEqual(
preview.humanize_subject("feat(update): add preview channel"),
("Added", "Add preview channel"),
)
self.assertEqual(
preview.humanize_subject("fix: handle preview manifest"),
("Fixed", "Handle preview manifest"),
)
self.assertEqual(
preview.humanize_subject("not conventional"),
("Other", "Not conventional"),
preview.build_notes("previous-sha", "current-sha", "2026-09-16-abcdef123456", "herdrdev/herdr"),
"Preview build 2026-09-16-abcdef123456\n\n"
"[View changes](https://github.com/herdrdev/herdr/compare/previous-sha...current-sha)\n",
)
def test_build_manifest_archives_current_assets(self):
def test_build_manifest_archives_assets_with_selected_source_generation(self):
with tempfile.TemporaryDirectory() as tmp:
output = Path(tmp) / "preview.json"
notes = "Preview notes\n"
@@ -44,13 +37,14 @@ class PreviewNotesTests(unittest.TestCase):
"windows-x86_64": "a" * 64,
},
retain=30,
endpoint_generation=77,
)
data = json.loads(content)
self.assertEqual(data["channel"], "preview")
self.assertEqual(data["build_id"], "2026-06-02-abcdef123456")
self.assertEqual(
data["endpoint_generation"],
preview.read_endpoint_protocol_generation(),
77,
)
self.assertEqual(
data["assets"]["linux-x86_64"]["sha256"],
@@ -68,7 +62,7 @@ class PreviewNotesTests(unittest.TestCase):
self.assertIn("2026-06-02-abcdef123456", data["builds"])
self.assertEqual(
data["builds"]["2026-06-02-abcdef123456"]["endpoint_generation"],
preview.read_endpoint_protocol_generation(),
77,
)
def test_windows_preview_asset_requires_sha256(self):
@@ -88,24 +82,6 @@ class PreviewNotesTests(unittest.TestCase):
retain=1,
)
def test_hidden_subjects_include_preview_manifest_commits(self):
self.assertTrue(preview.hidden_subject("docs: update preview manifest"))
self.assertTrue(preview.hidden_subject("docs: update website manifest"))
self.assertTrue(preview.hidden_subject("docs: publish release distribution"))
self.assertFalse(preview.hidden_subject("release: v0.7.0"))
self.assertFalse(preview.hidden_subject("fix: repair preview manifest"))
def test_latest_publishable_commit_keeps_release_commits(self):
output = "\n".join(
[
"manifest\x00docs: update website manifest for v0.7.0",
"release\x00release: v0.7.0",
"feature\x00feat: add plugin v1 system",
]
)
with mock.patch.object(preview, "run_git", return_value=output):
self.assertEqual(preview.latest_publishable_commit("origin/master"), "release")
def test_preview_range_base_advances_to_stable_tag(self):
with (
mock.patch.object(preview, "latest_stable_tag", return_value="v0.7.0"),
@@ -118,7 +94,10 @@ class PreviewNotesTests(unittest.TestCase):
def test_preview_range_base_keeps_previous_preview_for_unreleased_work(self):
def is_ancestor(ancestor: str, descendant: str) -> bool:
return (ancestor, descendant) == ("v0.7.0", "new-feature")
return (ancestor, descendant) in {
("v0.7.0", "new-feature"),
("previous-preview", "new-feature"),
}
with (
mock.patch.object(preview, "latest_stable_tag", return_value="v0.7.0"),
@@ -129,7 +108,14 @@ class PreviewNotesTests(unittest.TestCase):
"previous-preview",
)
def test_post_stable_history_selects_release_and_bases_range_on_stable_tag(self):
def test_hotfix_preview_uses_stable_base_instead_of_newer_master_preview(self):
with (
mock.patch.object(preview, "latest_stable_tag", return_value="v0.7.0"),
mock.patch.object(preview, "git_is_ancestor", return_value=False),
):
self.assertEqual(preview.preview_range_base("newer-master", "hotfix"), "v0.7.0")
def test_post_stable_history_bases_range_on_stable_tag(self):
with tempfile.TemporaryDirectory() as tmp:
repo = Path(tmp)
@@ -156,13 +142,9 @@ class PreviewNotesTests(unittest.TestCase):
release = git("rev-parse", "HEAD")
git("tag", "v0.7.0")
marker.write_text("manifest\n", encoding="utf-8")
git("commit", "-am", "docs: update website manifest for v0.7.0")
original_cwd = os.getcwd()
try:
os.chdir(repo)
self.assertEqual(preview.latest_publishable_commit("HEAD"), release)
self.assertEqual(
preview.preview_range_base(previous_preview, release),
"v0.7.0",
+190
View File
@@ -0,0 +1,190 @@
import json
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from scripts import release
class ReleaseTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix="herdr-release-", dir="/var/tmp" if os.name != "nt" else None)
self.addCleanup(self.temp.cleanup)
self.previous_cwd = Path.cwd()
os.chdir(self.temp.name)
self.addCleanup(os.chdir, self.previous_cwd)
self.git("init", "-q", "-b", "master")
self.git("config", "user.name", "Release Test")
self.git("config", "user.email", "release@example.invalid")
self.put("Cargo.toml", '[package]\nname = "herdr"\nversion = "1.0.0"\n[dependencies]\nserde = "1"\n')
self.put("Cargo.lock", 'version = 4\n[[package]]\nname = "herdr"\nversion = "1.0.0"\n[[package]]\nname = "serde"\nversion = "1.0.0"\n')
self.put("src/main.rs", "fn main() {}\n")
self.put("distribution/latest.json", json.dumps({"version": "1.0.0"}))
self.put("scripts/release.py", "# promotion tooling\n")
self.put(".github/workflows/release.yml", "run: python3 scripts/release.py check-tag\n")
self.put(".github/workflows/preview.yml", 'on:\n push:\n tags:\n - "preview-*"\n')
self.commit("stable")
self.git("tag", "v1.0.0")
self.put("src/main.rs", "fn main() { println!(\"preview\"); }\n")
self.preview = self.commit("preview")
self.git("tag", "preview-test")
self.git("update-ref", "refs/remotes/origin/master", "HEAD")
def git(self, *args):
return subprocess.check_output(["git", *args], text=True, stderr=subprocess.STDOUT).strip()
def put(self, path, text):
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(text, encoding="utf-8")
def commit(self, message):
self.git("add", ".")
self.git("commit", "-qm", message)
return self.git("rev-parse", "HEAD")
def prepare(self):
for path in ("Cargo.toml", "Cargo.lock"):
self.put(path, Path(path).read_text().replace('version = "1.0.0"', 'version = "1.0.1"', 1))
self.put("CHANGELOG.md", "# 1.0.1\nFixed the bug.\n")
return self.commit("release preparation")
def test_promotion_excludes_newer_master_and_records_explicit_previous_stable(self):
self.put("src/new-feature.rs", "untested feature\n")
master = self.commit("newer master")
self.git("update-ref", "refs/remotes/origin/master", master)
self.git("checkout", "-q", "-b", "release/1.0.1", self.preview)
candidate = self.prepare()
self.git("tag", "-a", "v1.0.1", "-m", "v1.0.1\n\nPreview: preview-test\nPrevious-Stable: v1.0.0")
self.assertEqual(release.tag_metadata("v1.0.1"), ("preview-test", "v1.0.0"))
with mock.patch.object(release, "published_preview", return_value=self.preview):
self.assertEqual(release.validate_release("preview-test", candidate, "1.0.1", "v1.0.0", "test/repo"), self.preview)
self.assertFalse(Path("src/new-feature.rs").exists())
self.git("checkout", "-q", "master")
with self.assertRaisesRegex(ValueError, "unpreviewed change"):
release.validate_diff(self.preview, master)
def test_code_dependencies_schema_and_build_changes_are_rejected(self):
cases = {
"src/main.rs": "fn main() { panic!(); }\n",
"Cargo.toml": '[package]\nname = "herdr"\nversion = "1.0.1"\n[dependencies]\nserde = "2"\n',
"Cargo.lock": Path("Cargo.lock").read_text().replace('name = "serde"\nversion = "1.0.0"', 'name = "serde"\nversion = "2.0.0"'),
"docs/next/api/herdr-api.schema.json": "{}\n",
".github/workflows/release.yml": "changed\n",
}
for path, content in cases.items():
with self.subTest(path=path):
self.git("reset", "--hard", self.preview)
self.put(path, content)
candidate = self.commit("not release preparation")
with self.assertRaises(ValueError):
release.validate_diff(self.preview, candidate)
def test_release_docs_and_skill_are_allowed_but_symlinks_are_not(self):
for path in release.RELEASE_FILES | {"docs/next/website/src/content/docs/index.mdx"}:
self.put(path, "release prose\n")
candidate = self.prepare()
release.validate_diff(self.preview, candidate, "1.0.1")
if os.name != "nt":
Path("CHANGELOG.md").unlink()
Path("CHANGELOG.md").symlink_to("src/main.rs")
with self.assertRaisesRegex(ValueError, "regular files"):
release.validate_diff(self.preview, self.commit("symlink"))
def test_preview_source_accepts_master_history_and_rejects_unpublished_branch(self):
self.assertEqual(release.select_preview(self.preview), self.preview)
self.assertEqual(release.select_preview("v1.0.0"), self.git("rev-parse", "v1.0.0"))
self.put("src/main.rs", "unpublished work\n")
candidate = self.commit("unpublished")
with self.assertRaisesRegex(ValueError, "preview source must"):
release.select_preview(candidate)
def test_preview_source_rejects_legacy_dispatch_even_on_master(self):
self.put(".github/workflows/preview.yml", "on:\n workflow_dispatch:\n")
legacy = self.commit("legacy preview workflow")
self.git("update-ref", "refs/remotes/origin/master", legacy)
with self.assertRaisesRegex(ValueError, "predates tag-triggered previews"):
release.select_preview(legacy)
def test_wrong_ancestry_and_mismatched_version_fail(self):
candidate = self.prepare()
with self.assertRaisesRegex(ValueError, "must descend"):
release.validate_diff(candidate, self.preview)
with self.assertRaisesRegex(ValueError, "version must match"):
release.validate_diff(self.preview, candidate, "1.0.2")
def test_published_preview_requires_immutable_complete_release_and_matching_remote_tag(self):
payload = {"tag_name": "preview-test", "draft": False, "prerelease": True,
"immutable": True, "assets": [{"name": name} for name in release.ASSETS]}
real_git = release.git
def git(*args):
if args[0] == "ls-remote":
return f"{self.preview}\trefs/tags/preview-test"
return real_git(*args)
real_output = subprocess.check_output
def output(args, **kwargs):
return json.dumps(payload) if args[0] == "gh" else real_output(args, **kwargs)
with mock.patch.object(release, "git", side_effect=git), mock.patch.object(release.subprocess, "check_output", side_effect=output):
self.assertEqual(release.published_preview("preview-test", "test/repo"), self.preview)
for key, value in (("draft", True), ("prerelease", False), ("immutable", False), ("assets", [])):
with self.subTest(key=key), mock.patch.dict(payload, {key: value}):
with self.assertRaisesRegex(ValueError, "immutable published preview"):
release.published_preview("preview-test", "test/repo")
self.git("tag", "-f", "preview-test", "v1.0.0")
with self.assertRaisesRegex(ValueError, "does not match"):
release.published_preview("preview-test", "test/repo")
def test_missing_provenance_or_stale_previous_release_fails(self):
self.git("tag", "v1.0.1")
with self.assertRaisesRegex(ValueError, "annotated"):
release.tag_metadata("v1.0.1")
self.git("tag", "-af", "v1.0.1", "-m", "v1.0.1")
with self.assertRaisesRegex(ValueError, "Preview"):
release.tag_metadata("v1.0.1")
candidate = self.prepare()
with mock.patch.object(release, "published_preview", return_value=self.preview):
with self.assertRaisesRegex(ValueError, "currently published"):
release.validate_release("preview-test", candidate, "1.0.1", "v0.9.0", "test/repo")
with self.assertRaisesRegex(ValueError, "must increase"):
release.validate_release("preview-test", candidate, "1.0.1", "v1.0.1", "test/repo")
def test_hotfix_isolated_from_master_and_metadata_sync_preserves_master_code(self):
self.put("src/feature.rs", "b and c\n")
master = self.commit("features b and c")
self.git("checkout", "-q", "-b", "release/hotfix", "v1.0.0")
self.put("src/main.rs", "fn main() { /* fix d */ }\n")
hotfix = self.commit("fix d")
self.git("update-ref", "refs/remotes/origin/release/hotfix", hotfix)
self.assertEqual(release.select_hotfix("release/hotfix", "v1.0.0"), hotfix)
self.assertEqual(release.select_preview(hotfix), hotfix)
self.git("rm", "scripts/release.py")
legacy = self.commit("legacy release tooling")
self.git("update-ref", "refs/remotes/origin/release/hotfix", legacy)
with self.assertRaisesRegex(ValueError, "predates preview promotion"):
release.select_hotfix("release/hotfix", "v1.0.0")
self.git("reset", "--hard", hotfix)
self.git("update-ref", "refs/remotes/origin/release/hotfix", hotfix)
with self.assertRaisesRegex(ValueError, "release/\\*"):
release.select_hotfix("feature/unreviewed", "v1.0.0")
self.git("tag", "v2.0.0", master)
with self.assertRaisesRegex(ValueError, "must descend"):
release.select_hotfix("release/hotfix", "v2.0.0")
candidate = self.prepare()
release.validate_diff(hotfix, candidate, "1.0.1")
patch = subprocess.check_output(["git", "diff", "--binary", hotfix, candidate])
self.git("checkout", "-q", "master")
subprocess.run(["git", "apply", "--3way", "--index"], input=patch, check=True)
self.assertEqual(Path("src/feature.rs").read_text(), "b and c\n")
self.assertNotIn("fix d", Path("src/main.rs").read_text())
self.assertEqual(release.normalized_cargo(Path("Cargo.toml").read_text(), "Cargo.toml")[1], "1.0.1")
if __name__ == "__main__":
unittest.main()