From 36db8ff3f4bee21b06f3208422be7cf91c7d3e40 Mon Sep 17 00:00:00 2001 From: Can Celik Date: Wed, 16 Sep 2026 18:25:59 +0300 Subject: ci: promote stable releases from protected previews (#4241) --- .github/workflows/preview.yml | 79 +++++++++++++++++++------------------------ .github/workflows/release.yml | 72 +++++++++++++++++++++++++++++++-------- 2 files changed, 92 insertions(+), 59 deletions(-) (limited to '.github/workflows') diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 393dfb21..e1cce651 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5344962c..64ae182b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 # 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 -- cgit v1.2.3