diff --git a/.agents/skills/rustfs-release-publish/SKILL.md b/.agents/skills/rustfs-release-publish/SKILL.md index 1c64897c8..9c246398d 100644 --- a/.agents/skills/rustfs-release-publish/SKILL.md +++ b/.agents/skills/rustfs-release-publish/SKILL.md @@ -1,19 +1,21 @@ --- name: rustfs-release-publish -description: "End-to-end RustFS release pipeline: bump version files on main directly to the final target version, cut a preview tag on that commit, verify the CI build and release artifacts, run the downloaded binary locally and exercise the console, validate the server with the latest rc client, then publish the final tag on the SAME validated commit — never a new bump commit, never latest main. Use whenever the user wants to release/publish a RustFS version (发版/发布)." +description: "End-to-end RustFS release pipeline: first publish any merged-but-unreleased rustfs/console changes and wait for its latest Release asset, then bump RustFS version files on main directly to the final target, publish a visible GitHub prerelease from a preview tag without updating latest channels, validate it, and publish the final tag on the SAME commit. Use whenever the user wants to release/publish a RustFS version (发版/发布)." --- # RustFS Release Publish (preview-validated pipeline) This skill orchestrates a full release. It wraps `rustfs-release-version-bump` (which only edits version files and opens the PR) with a mandatory preview-tag validation loop before the final tag is published. -Core design: **version files never carry a `-preview.N` suffix**. The preview suffix exists only in tag names. This works because the binary self-reports the git tag it was built from (`build::TAG` via shadow_rs, see `rustfs/src/config/cli.rs` `SHORT_VERSION`), and `build.yml` derives artifact names and prerelease classification from the tag name — Cargo.toml's version is only a no-tag fallback. Therefore the preview tag and the final tag can (and MUST) point at the exact same commit: what you validated is byte-for-byte the source that ships. +Core design: **version files never carry a `-preview.N` suffix**. The preview suffix exists only in tag names. A preview tag creates a visible GitHub Release marked Prerelease and uploads versioned assets, but it never becomes GitHub Latest and never updates `*-latest`, `latest.json`, R2, Docker, or Helm channels. This works because the binary self-reports the git tag it was built from (`build::TAG` via shadow_rs, see `rustfs/src/config/cli.rs` `SHORT_VERSION`), and `build.yml` derives artifact names and preview classification from the tag name — Cargo.toml's version is only a no-tag fallback. Therefore the preview tag and the final tag can (and MUST) point at the exact same commit: what you validated is byte-for-byte the source that ships. Pipeline shape: ``` -bump version files to (final version, ONE commit) -> merge +check console main against its latest Release + -> if ahead: publish console -> wait for Release asset + latest API + -> bump RustFS version files to (final version, ONE commit) -> merge -> tag at that commit -> CI green - -> verify release artifacts -> run binary locally + console checks + -> verify preview Release assets -> run binary locally + console checks -> validate with latest rc client -> tag at the SAME commit (zero delta) -> re-verify CI/release ``` @@ -23,7 +25,7 @@ On validation failure: fix lands on main via normal PR (version files are alread ## Required inputs - Final target version, for example `1.0.0-beta.10`. -- Preview iteration `N` (default: next unused preview tag for that target; check with `git tag -l '-preview.*'` — and for stable targets `git tag -l '-rc.*'` — after `git fetch --tags`). +- Preview iteration `N` (default: next unused preview tag for that target; check with `git tag -l '-preview.*'` after `git fetch --tags`). If the target version is missing or ambiguous, stop and ask before doing anything (see the semver gate below). @@ -45,12 +47,14 @@ Rules: ## Preview tag naming -- Prerelease target (contains `alpha`/`beta`/`rc`): preview tag is `-preview.N`, e.g. `1.0.0-beta.10-preview.3`. It contains `beta`, so `build.yml`'s substring-based classification marks it prerelease — safe. -- **Stable** target (e.g. `1.1.0`): NEVER tag `1.1.0-preview.N` — `build.yml` marks a tag prerelease only if its name contains `alpha`, `beta`, or `rc`, so `1.1.0-preview.N` would be treated as a stable release and overwrite `latest.json` as stable. Use `1.1.0-rc.N` as the preview tag instead. +- Use `-preview.N` for every target, e.g. `1.0.0-beta.10-preview.3` or `1.1.0-preview.1`. +- The canonical suffix is exactly `-preview.`. `build.yml` recognizes it before alpha/beta/rc classification and routes it to the preview-only path; any other tag containing `-preview` fails closed instead of being treated as a release. +- A preview Release MUST be published with `isPrerelease=true` and `isLatest=false`. Any `*-latest` preview asset or preview-triggered `latest.json`, R2, Docker, or Helm publication is a pipeline failure. ## Hard rules - Version files (Cargo.toml, Cargo.lock, README, flake.nix, Chart.yaml, rustfs.spec) are bumped ONCE, directly to ``. Never write a `-preview.N` suffix into any version file. If `rustfs-release-version-bump` is ever asked for a `-preview` version, that is a pipeline bug — stop. +- Preview Release assets are versioned and intentionally visible on the Releases page. Do not label them Latest or use them to update any latest distribution channel. - Tags have no `v` prefix. Always annotated: `git tag -a -m "Release "`. - The final tag MUST point at exactly `PREVIEW_HASH` — the commit the validated preview tag points at. Never tag current `main` HEAD (commits merged after validation are unvalidated), and never create an extra version-bump commit between preview and final. - Phases run in order; a failure in any phase blocks everything after it. After the fix lands on main, restart from Phase 2 with the next preview iteration against the new `origin/main` hash — do not resume mid-pipeline against a stale hash. @@ -63,6 +67,61 @@ Rules: - `gh auth status` works; confirm you can view `gh release list -L 3`. - Confirm the exact final target version with the user if not explicit. +### Console release gate + +Complete this gate before changing any RustFS version file or creating any RustFS tag. RustFS `build.yml` downloads the asset returned by `repos/rustfs/console/releases/latest`, so a successful Console build alone is insufficient. + +1. Read the latest published Console tag and compare it with Console `main`: + +```bash +CONSOLE_REPO="rustfs/console" +CONSOLE_LATEST=$(gh api "repos/${CONSOLE_REPO}/releases/latest" --jq .tag_name) +gh api "repos/${CONSOLE_REPO}/compare/${CONSOLE_LATEST}...main" \ + --jq '{status, ahead_by, behind_by, commits: [.commits[] | {sha, message: .commit.message}]}' +``` + +- `ahead_by == 0`: no merged Console change is waiting for release. Still verify the current latest asset using step 4, then continue to Phase 1. +- `ahead_by > 0` and `behind_by == 0`: publish Console before continuing. Report the merged commits and select the next unused `vX.Y.Z` tag. Default to the next patch version when the changes are fixes or backward-compatible UI work; stop for confirmation if a minor/major bump is plausible. +- Any diverged history or `behind_by > 0`: stop and resolve the Console release baseline explicitly. Do not guess a range or publish RustFS. + +2. Clone/fetch `rustfs/console` into a scratch directory and record its exact `main` commit. Before creating a tag, check for a `v*` tag or Release workflow already associated with that hash. If one is in progress, wait for it instead of creating another version: + +```bash +CONSOLE_SCRATCH=$(mktemp -d) +gh repo clone "$CONSOLE_REPO" "$CONSOLE_SCRATCH/console" +git -C "$CONSOLE_SCRATCH/console" fetch origin main --tags +CONSOLE_HASH=$(git -C "$CONSOLE_SCRATCH/console" rev-parse origin/main) +git -C "$CONSOLE_SCRATCH/console" tag --points-at "$CONSOLE_HASH" 'v*' +gh run list -R "$CONSOLE_REPO" --workflow release.yml --commit "$CONSOLE_HASH" --limit 5 +``` + +If no release exists or is running for `CONSOLE_HASH`, create the selected annotated tag at that exact hash and push it: + +```bash +git -C "$CONSOLE_SCRATCH/console" tag -a "" -m "Release " "$CONSOLE_HASH" +git -C "$CONSOLE_SCRATCH/console" push origin "" +``` + +Console tags include the `v` prefix. Pushing the tag triggers `.github/workflows/release.yml` (`🚀 Release`). Remove `CONSOLE_SCRATCH` after the gate completes. + +3. Find the exact tag run and wait for completion: + +```bash +gh run list -R "$CONSOLE_REPO" --workflow release.yml --branch "" --limit 1 +gh run watch -R "$CONSOLE_REPO" "" --exit-status +``` + +4. Block until the published Release is non-draft, the latest endpoint returns the expected tag, and `rustfs-console-.zip` is uploaded, non-empty, and carries a `sha256:` digest: + +```bash +gh release view -R "$CONSOLE_REPO" "" --json isDraft,isPrerelease,assets,url +test "$(gh api "repos/${CONSOLE_REPO}/releases/latest" --jq .tag_name)" = "" +test "$(gh api "repos/${CONSOLE_REPO}/releases/tags/" \ + --jq '[.assets[] | select(.name == "rustfs-console-.zip" and .state == "uploaded" and .size > 0 and (.digest | startswith("sha256:")))] | length')" -eq 1 +``` + +Treat a missing/mismatched asset, digest, latest tag, or failed/cancelled workflow as BLOCKED. Do not start Phase 1 until the Console gate passes. Record `CONSOLE_TAG`, `CONSOLE_HASH`, Console run URL, and Release URL for the final report. + ## Phase 1 — Version bump to the final target (once) - If main's version files already read `` (e.g. this is a restart after a failed preview), verify with `rg -n "" Cargo.toml rustfs.spec helm/rustfs/Chart.yaml` and skip to Phase 2. @@ -85,16 +144,16 @@ git push origin "" Pushing the tag triggers `.github/workflows/build.yml` ("Build and Release"); `docker.yml` chains off it via `workflow_run`. +The preview run builds versioned artifacts and publishes them in a GitHub prerelease. Its latest-channel, R2, Docker, and Helm jobs must be skipped. Those publication paths run only after the final tag is pushed. + On a restart (N+1), refresh `PREVIEW_HASH=$(git rev-parse origin/main)` first — it must contain the fix — and re-report it. -## Phase 3 — CI and artifact verification +## Phase 3 — CI and preview Release verification -- Watch the tag build: `gh run list --workflow build.yml --limit 5` then `gh run watch `. Every matrix target must succeed (linux x86_64/aarch64 × musl/gnu, macos-aarch64, windows-x86_64) plus the release and latest.json jobs. -- Verify the GitHub release: `gh release view "" --json isPrerelease,assets` - - `isPrerelease` must be `true`. - - Assets must include all 6 platform zips in both versioned (`rustfs--v.zip`) and `-latest` forms, plus `SHA256SUMS`, `SHA512SUMS`, `rustfs-.sbom.cdx.json`, `rustfs-.provenance.json`. -- Verify the chained Docker run succeeded: `gh run list --workflow docker.yml --limit 3`. -- Checksum spot-check for the platform you will run locally: download the zip and `SHA256SUMS`, verify with `shasum -a 256 -c` (grep to one line). +- Find and watch the tag build: `gh run list --workflow build.yml --branch "" --limit 1` then `gh run watch `. Every build matrix target must succeed (linux x86_64/aarch64 × musl/gnu, macos-aarch64, windows-x86_64). +- Confirm the Release publication jobs (`create-release`, `upload-release-assets`, and `publish-release`) succeed while `update-latest-version` is skipped. +- Verify `gh release view "" --json isPrerelease,assets,url`: `isPrerelease` must be `true`, and the Release must contain all 6 versioned platform zips, checksums, SBOM, and provenance with no `-latest` assets. Confirm `gh api repos/{owner}/{repo}/releases/latest --jq .tag_name` does not return ``. +- Confirm preview-triggered Docker and Helm jobs are skipped. Preview validation covers the built RustFS binaries, embedded console, and rc compatibility; Docker image construction and Helm publication are deferred to the final tag because the Dockerfiles consume GitHub Release assets. ## Phase 4 — Run the artifact locally, verify the console @@ -157,13 +216,14 @@ git push origin "" ``` - CI rebuilds from the same source; the only changed input is the tag name, so the binary now self-reports ``. -- Re-run the Phase 3 verification against the final tag: all matrix jobs green; `gh release view ""` shows the full asset set; for a prerelease target `isPrerelease` is `true`, for a stable target it must be `false` and `latest.json` must be updated. +- Verify the final tag's complete publication path: all matrix and release jobs green; `gh release view ""` shows the full versioned and `-latest` asset set plus checksums, SBOM, and provenance; Docker and Helm workflows succeed; `latest.json` points to ``. A stable target must have `isPrerelease=false` and `isLatest=true`. An alpha/beta/rc target must have `isPrerelease=true`; GitHub does not permit prereleases to be Latest, but the project `latest.json` still advances to the final non-preview target. - Optionally spot-check `./rustfs --version` from a final-tag artifact — it must report ``. ## Output contract Always report: +- Console gate result: previous/latest Console tags, whether merged changes required a release, `CONSOLE_HASH`, and Console run/Release URLs when a release was published. - Target version, preview tag(s) used, `PREVIEW_HASH` (which both tags point at). -- Per-phase result (PASS/FAIL/BLOCKED) with key evidence: CI run URLs, release URLs, console check results, the rc command matrix. +- Per-phase result (PASS/FAIL/BLOCKED) with key evidence: preview and final Release URLs, preview `isPrerelease`/`isLatest` state, final latest-channel state, console check results, and the rc command matrix. - Any deviation from this pipeline and why the user approved it. diff --git a/.agents/skills/rustfs-release-version-bump/SKILL.md b/.agents/skills/rustfs-release-version-bump/SKILL.md index 0e39ce737..3c760f409 100644 --- a/.agents/skills/rustfs-release-version-bump/SKILL.md +++ b/.agents/skills/rustfs-release-version-bump/SKILL.md @@ -18,7 +18,7 @@ Validated baseline: release pattern used in PR `#2957`. If target version is missing or ambiguous, stop and ask before editing. -Reject any target version containing a `-preview.` suffix: preview identifiers are tag-only (see `rustfs-release-publish`) and must never be written into version files. If asked for one, stop and point to the release pipeline instead of editing. +Reject any target version containing `-preview`: preview identifiers are tag-only (see `rustfs-release-publish`) and must never be written into version files. If asked for one, stop and point to the release pipeline instead of editing. ## Read before editing diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index c630abc8b..f33240860 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -23,6 +23,7 @@ on: - 'deny.toml' - '.github/actions/**' - '.github/workflows/**' + - 'scripts/security/check_preview_release_workflow.sh' - 'scripts/security/check_workflow_pins.sh' pull_request: types: [ opened, synchronize, reopened, closed ] @@ -33,6 +34,7 @@ on: - 'deny.toml' - '.github/actions/**' - '.github/workflows/**' + - 'scripts/security/check_preview_release_workflow.sh' - 'scripts/security/check_workflow_pins.sh' schedule: - cron: '0 3 * * 0' # Weekly on Sunday 03:00 UTC (staggered after the midnight ci/build crons) @@ -96,6 +98,9 @@ jobs: - name: Report unpinned GitHub Actions run: ./scripts/security/check_workflow_pins.sh --enforce + - name: Check preview release workflow policy + run: ./scripts/security/check_preview_release_workflow.sh + dependency-review: name: Dependency Review runs-on: ubuntu-latest diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 331221545..2274763d0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -107,13 +107,21 @@ jobs: # Determine build type based on trigger if [[ "${{ startsWith(github.ref, 'refs/tags/') }}" == "true" ]]; then - # Tag push - release or prerelease + # Tag push - preview, release, or prerelease should_build=true tag_name="${GITHUB_REF#refs/tags/}" version="${tag_name}" - # Check if this is a prerelease - if [[ "$tag_name" == *"alpha"* ]] || [[ "$tag_name" == *"beta"* ]] || [[ "$tag_name" == *"rc"* ]]; then + # Preview tags publish a GitHub prerelease for validation, but + # must not update any latest channel. + if [[ "$tag_name" =~ -preview\.[0-9]+$ ]]; then + build_type="preview" + is_prerelease=true + echo "🔍 Preview build detected: $tag_name" + elif [[ "$tag_name" == *"-preview"* ]]; then + echo "❌ Invalid preview tag: $tag_name (expected suffix: -preview.)" >&2 + exit 1 + elif [[ "$tag_name" == *"alpha"* ]] || [[ "$tag_name" == *"beta"* ]] || [[ "$tag_name" == *"rc"* ]]; then build_type="prerelease" is_prerelease=true echo "🚀 Prerelease build detected: $tag_name" @@ -714,6 +722,10 @@ jobs: echo "" case "$BUILD_TYPE" in + "preview") + echo "🔍 Preview artifacts are published in a GitHub prerelease" + echo "⏭️ Preview releases do not update latest channels" + ;; "development") echo "🛠️ Development build artifacts have been uploaded to OSS dev directory" echo "⚠️ This is a development build - not suitable for production use" @@ -732,7 +744,9 @@ jobs: echo "" echo "🐳 Docker Images:" - if [[ "${{ github.event.inputs.build_docker }}" == "false" ]]; then + if [[ "$BUILD_TYPE" == "preview" ]]; then + echo "⏭️ Preview tags do not publish Docker images" + elif [[ "${{ github.event.inputs.build_docker }}" == "false" ]]; then echo "⏭️ Docker image build was skipped (binary only build)" elif [[ "$BUILD_STATUS" == "success" ]]; then echo "🔄 Docker images will be built and pushed automatically via workflow_run event" @@ -740,11 +754,11 @@ jobs: echo "❌ Docker image build will be skipped due to build failure" fi - # Create GitHub Release (only for tag pushes) + # Create GitHub Release for every valid release tag, including previews create-release: name: Create GitHub Release needs: [ build-check, build-rustfs ] - if: startsWith(github.ref, 'refs/tags/') && needs.build-check.outputs.build_type != 'development' + if: startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'preview' || needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease') runs-on: ubuntu-latest permissions: contents: write @@ -769,7 +783,9 @@ jobs: BUILD_TYPE="${{ needs.build-check.outputs.build_type }}" # Determine release type for title - if [[ "$BUILD_TYPE" == "prerelease" ]]; then + if [[ "$BUILD_TYPE" == "preview" ]]; then + RELEASE_TYPE="preview" + elif [[ "$BUILD_TYPE" == "prerelease" ]]; then if [[ "$TAG" == *"alpha"* ]]; then RELEASE_TYPE="alpha" elif [[ "$TAG" == *"beta"* ]]; then @@ -816,6 +832,7 @@ jobs: --title "$TITLE" \ --notes "$RELEASE_NOTES" \ $PRERELEASE_FLAG \ + --latest=false \ --draft RELEASE_ID=$(gh release view "$TAG" --json databaseId --jq '.databaseId') @@ -830,7 +847,7 @@ jobs: upload-release-assets: name: Upload Release Assets needs: [ build-check, build-rustfs, create-release ] - if: startsWith(github.ref, 'refs/tags/') && needs.build-check.outputs.build_type != 'development' + if: startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'preview' || needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease') runs-on: ubuntu-latest permissions: contents: write @@ -920,8 +937,8 @@ jobs: # the pointed-to version is a prerelease. update-latest-version: name: Update Latest Version - needs: [ build-check, upload-release-assets ] - if: startsWith(github.ref, 'refs/tags/') + needs: [ build-check, publish-release ] + if: startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease') runs-on: ubuntu-latest steps: - name: Update latest.json @@ -980,7 +997,7 @@ jobs: publish-release: name: Publish Release needs: [ build-check, create-release, upload-release-assets ] - if: startsWith(github.ref, 'refs/tags/') && needs.build-check.outputs.build_type != 'development' + if: startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'preview' || needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease') runs-on: ubuntu-latest permissions: contents: write @@ -994,37 +1011,22 @@ jobs: shell: bash run: | TAG="${{ needs.build-check.outputs.version }}" - VERSION="${{ needs.build-check.outputs.version }}" - IS_PRERELEASE="${{ needs.build-check.outputs.is_prerelease }}" BUILD_TYPE="${{ needs.build-check.outputs.build_type }}" + RELEASE_ID="${{ needs.create-release.outputs.release_id }}" - # Determine release type - if [[ "$BUILD_TYPE" == "prerelease" ]]; then - if [[ "$TAG" == *"alpha"* ]]; then - RELEASE_TYPE="alpha" - elif [[ "$TAG" == *"beta"* ]]; then - RELEASE_TYPE="beta" - elif [[ "$TAG" == *"rc"* ]]; then - RELEASE_TYPE="rc" - else - RELEASE_TYPE="prerelease" - fi + # Publish the release and correct its channel state on retries. + # Only a stable final release may become GitHub Latest. + if [[ "$BUILD_TYPE" == "release" ]]; then + gh api --method PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" \ + -F draft=false \ + -F prerelease=false \ + -f make_latest=true >/dev/null else - RELEASE_TYPE="release" + gh api --method PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" \ + -F draft=false \ + -F prerelease=true \ + -f make_latest=false >/dev/null fi - # Get original release notes from tag - ORIGINAL_NOTES=$(git tag -l --format='%(contents)' "${TAG}") - if [[ -z "$ORIGINAL_NOTES" || "$ORIGINAL_NOTES" =~ ^[[:space:]]*$ ]]; then - if [[ "$IS_PRERELEASE" == "true" ]]; then - ORIGINAL_NOTES="Pre-release ${VERSION} (${RELEASE_TYPE})" - else - ORIGINAL_NOTES="Release ${VERSION}" - fi - fi - - # Publish the release (remove draft status) - gh release edit "$TAG" --draft=false - echo "🎉 Released $TAG successfully!" echo "📄 Release URL: ${{ needs.create-release.outputs.release_url }}" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index a2995b618..75ef45a59 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -82,7 +82,8 @@ jobs: github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && - github.event.workflow_run.head_branch != 'main') + github.event.workflow_run.head_branch != 'main' && + !contains(github.event.workflow_run.head_branch, '-preview')) runs-on: ubuntu-latest outputs: should_build: ${{ steps.check.outputs.should_build }} @@ -220,6 +221,13 @@ jobs: create_latest=true echo "🚀 Building with latest stable release version" ;; + *-preview*) + build_type="preview" + is_prerelease=true + should_build=false + should_push=false + echo "⏭️ Preview tags do not publish Docker images" + ;; # Prerelease versions (must match first, more specific) v*alpha*|v*beta*|v*rc*|*alpha*|*beta*|*rc*) build_type="prerelease" diff --git a/.github/workflows/helm-package.yml b/.github/workflows/helm-package.yml index 49d038069..7e313c9b6 100644 --- a/.github/workflows/helm-package.yml +++ b/.github/workflows/helm-package.yml @@ -33,11 +33,12 @@ jobs: build-helm-package: runs-on: ubuntu-latest if: | - github.event_name == 'workflow_dispatch' || + (github.event_name == 'workflow_dispatch' && !contains(github.event.inputs.version, '-preview')) || ( github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && - contains(github.event.workflow_run.head_branch, '.') + contains(github.event.workflow_run.head_branch, '.') && + !contains(github.event.workflow_run.head_branch, '-preview') ) outputs: diff --git a/scripts/security/check_preview_release_workflow.sh b/scripts/security/check_preview_release_workflow.sh new file mode 100755 index 000000000..1ad081643 --- /dev/null +++ b/scripts/security/check_preview_release_workflow.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env bash +set -euo pipefail + +build_workflow=".github/workflows/build.yml" +docker_workflow=".github/workflows/docker.yml" +helm_workflow=".github/workflows/helm-package.yml" + +require_line() { + local file="$1" + local line="$2" + local description="$3" + + if ! grep -Fxq "$line" "$file"; then + echo "missing preview release workflow contract: $description" >&2 + exit 1 + fi +} + +extract_job_if() { + local file="$1" + local job="$2" + + awk -v job="$job" ' + $0 == " " job ":" { in_job = 1 } + in_job && /^ if:/ { in_if = 1 } + in_if && /^ [A-Za-z0-9_-]+:/ && $0 !~ /^ if:/ { exit } + in_if { print } + in_job && $0 ~ /^ [A-Za-z0-9_-]+:$/ && $0 != " " job ":" { exit } + ' "$file" +} + +require_job_if() { + local file="$1" + local job="$2" + local expected="$3" + local actual + + actual=$(extract_job_if "$file" "$job") + if [[ "$actual" != "$expected" ]]; then + echo "missing preview release workflow contract: $job condition" >&2 + exit 1 + fi +} + +require_assignment() { + local block="$1" + local name="$2" + local expected="$3" + local count + + count=$(printf '%s\n' "$block" | grep -Ec "^[[:space:]]+${name}=") + if [[ "$count" -ne 1 || "$block" != *"${name}=${expected}"* ]]; then + echo "missing preview release workflow contract: $name=$expected" >&2 + exit 1 + fi +} + +require_no_assignment() { + local block="$1" + local name="$2" + + if printf '%s\n' "$block" | grep -Eq "^[[:space:]]+${name}="; then + echo "invalid preview release workflow contract: unexpected $name override" >&2 + exit 1 + fi +} + +tag_classification=$(awk ' + /if \[\[ "\$tag_name" =~ -preview\\\.\[0-9\]\+\$ \]\]; then/ { in_preview = 1 } + in_preview { print } + in_preview && $0 == " fi" { exit } +' "$build_workflow") +invalid_preview_condition="elif [[ \"\$tag_name\" == *\"-preview\"* ]]; then" +prerelease_condition="elif [[ \"\$tag_name\" == *\"alpha\"* ]] || [[ \"\$tag_name\" == *\"beta\"* ]] || [[ \"\$tag_name\" == *\"rc\"* ]]; then" +preview_branch=$(printf '%s\n' "$tag_classification" | awk -v boundary="$invalid_preview_condition" 'index($0, boundary) { exit } { print }') +invalid_preview_branch=$(printf '%s\n' "$tag_classification" | awk -v start="$invalid_preview_condition" -v boundary="$prerelease_condition" 'index($0, start) { in_branch = 1 } in_branch && index($0, boundary) { exit } in_branch { print }') +prerelease_branch=$(printf '%s\n' "$tag_classification" | awk -v start="$prerelease_condition" 'index($0, start) { in_branch = 1 } in_branch && $0 == " else" { exit } in_branch { print }') +release_branch=$(printf '%s\n' "$tag_classification" | awk '$0 == " else" { in_branch = 1; next } in_branch && $0 == " fi" { exit } in_branch { print }') + +if [[ -z "$preview_branch" || -z "$invalid_preview_branch" || -z "$prerelease_branch" || -z "$release_branch" ]]; then + echo "missing preview release workflow contract: ordered preview, invalid-preview, prerelease, and release branches" >&2 + exit 1 +fi +require_assignment "$preview_branch" "build_type" '"preview"' +require_assignment "$preview_branch" "is_prerelease" "true" +require_no_assignment "$invalid_preview_branch" "build_type" +if [[ "$invalid_preview_branch" != *"exit 1"* ]]; then + echo "missing preview release workflow contract: invalid preview tags must fail closed" >&2 + exit 1 +fi +require_assignment "$prerelease_branch" "build_type" '"prerelease"' +require_assignment "$prerelease_branch" "is_prerelease" "true" +require_assignment "$release_branch" "build_type" '"release"' +require_no_assignment "$release_branch" "is_prerelease" + +initial_prerelease_line=$(grep -Fn " is_prerelease=false" "$build_workflow") +preview_condition_line=$(grep -Fn " if [[ \"\$tag_name\" =~ -preview\\.[0-9]+\$ ]]; then" "$build_workflow") +if [[ $(printf '%s\n' "$initial_prerelease_line" | wc -l) -ne 1 || "${initial_prerelease_line%%:*}" -ge "${preview_condition_line%%:*}" ]]; then + echo "missing preview release workflow contract: is_prerelease=false must initialize before tag classification" >&2 + exit 1 +fi + +tag_branch_tail=$(awk ' + /if \[\[ "\$tag_name" =~ -preview\\\.\[0-9\]\+\$ \]\]; then/ { in_classification = 1 } + in_classification && $0 == " fi" { after_classification = 1; next } + after_classification && /^ elif / { exit } + after_classification { print } +' "$build_workflow") +post_strategy=$(awk ' + /# Determine build type based on trigger/ { in_strategy = 1 } + in_strategy && $0 == " fi" { after_strategy = 1; next } + after_strategy && $0 == " {" { exit } + after_strategy { print } +' "$build_workflow") +for block in "$tag_branch_tail" "$post_strategy"; do + require_no_assignment "$block" "build_type" + require_no_assignment "$block" "is_prerelease" +done +require_line "$build_workflow" " if [[ \"\$BUILD_TYPE\" == \"release\" ]] || [[ \"\$BUILD_TYPE\" == \"prerelease\" ]]; then" "latest artifact guard" +require_line "$build_workflow" " if: env.R2_ACCESS_KEY_ID != '' && (needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease' || needs.build-check.outputs.build_type == 'development')" "R2 publication guard" +release_guard="startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'preview' || needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease')" +for job in create-release upload-release-assets publish-release; do + require_job_if "$build_workflow" "$job" " if: $release_guard" +done +latest_guard="startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease')" +require_job_if "$build_workflow" "update-latest-version" " if: $latest_guard" +require_line "$build_workflow" " needs: [ build-check, publish-release ]" "latest update must follow release publication" +require_line "$build_workflow" " --latest=false \\" "new releases must start outside the latest channel" +prerelease_flag_block=$(awk ' + $0 == " PRERELEASE_FLAG=\"\"" { in_block = 1 } + in_block { print } + in_block && $0 == " fi" { exit } +' "$build_workflow") +IFS= read -r -d '' expected_prerelease_flag_block <<'EOF' || true + PRERELEASE_FLAG="" + if [[ "$IS_PRERELEASE" == "true" ]]; then + PRERELEASE_FLAG="--prerelease" + fi +EOF +expected_prerelease_flag_block=${expected_prerelease_flag_block%$'\n'} +if [[ "$prerelease_flag_block" != "$expected_prerelease_flag_block" ]]; then + echo "missing preview release workflow contract: prerelease flag propagation" >&2 + exit 1 +fi +require_line "$build_workflow" " \$PRERELEASE_FLAG \\" "GitHub prerelease creation flag" + +release_channel_block=$(awk ' + $0 == " if [[ \"\$BUILD_TYPE\" == \"release\" ]]; then" { in_block = 1 } + in_block { print } + in_block && $0 == " fi" { exit } +' "$build_workflow") +IFS= read -r -d '' expected_release_channel_block <<'EOF' || true + if [[ "$BUILD_TYPE" == "release" ]]; then + gh api --method PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" \ + -F draft=false \ + -F prerelease=false \ + -f make_latest=true >/dev/null + else + gh api --method PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" \ + -F draft=false \ + -F prerelease=true \ + -f make_latest=false >/dev/null + fi +EOF +expected_release_channel_block=${expected_release_channel_block%$'\n'} +if [[ "$release_channel_block" != "$expected_release_channel_block" ]]; then + echo "missing preview release workflow contract: only stable releases may become GitHub Latest" >&2 + exit 1 +fi + +IFS= read -r -d '' expected_docker_automatic_guard <<'EOF' || true + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch != 'main' && + !contains(github.event.workflow_run.head_branch, '-preview')) +EOF +expected_docker_automatic_guard=${expected_docker_automatic_guard%$'\n'} +require_job_if "$docker_workflow" "build-check" "$expected_docker_automatic_guard" + +docker_manual_guard=$(awk ' + $0 == " *-preview*)" { in_preview = 1 } + in_preview { print } + in_preview && $0 == " ;;" { exit } +' "$docker_workflow") +for assignment in 'build_type="preview"' 'is_prerelease=true' 'should_build=false' 'should_push=false'; do + name="${assignment%%=*}" + require_assignment "$docker_manual_guard" "$name" "${assignment#*=}" +done + +IFS= read -r -d '' expected_helm_guard <<'EOF' || true + if: | + (github.event_name == 'workflow_dispatch' && !contains(github.event.inputs.version, '-preview')) || + ( + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + contains(github.event.workflow_run.head_branch, '.') && + !contains(github.event.workflow_run.head_branch, '-preview') + ) +EOF +expected_helm_guard=${expected_helm_guard%$'\n'} +require_job_if "$helm_workflow" "build-helm-package" "$expected_helm_guard" + +echo "Preview release workflow contract ok."