Compare commits

..

9 Commits

Author SHA1 Message Date
overtrue 7c30f18d58 test(kms): use absolute fixture paths on Windows 2026-11-11 07:17:38 +08:00
cxymds c9743f166e fix(s3): normalize leading-slash keys in bulk deletes (#9258)
* fix(s3): normalize leading-slash keys in bulk deletes

* test(s3): align bulk delete reserved-prefix guard expectations
2026-11-10 17:56:00 -05:00
Boyi (Elliot) Li 50707a610c test(utils,rio,checksums): add checksum SIMD equivalence and bench UTs (#9217)
Add unaligned-boundary SIMD vs scalar equivalence unit tests and on-demand (#[ignore]) utility-level throughput microbenchmarks across rustfs-utils, rustfs-rio, and rustfs-checksums:
- crates/utils/src/hash.rs: verify HighwayHasher SIMD (AVX2/SSE4.1/NEON) against highway::PortableHash scalar reference and contiguous hash_encode vs odd-chunked hash_encode_slices across unaligned tail boundaries; provide on-demand throughput microbenchmark.
- crates/rio/src/checksum.rs: verify SIMD-accelerated CRC32, CRC32C, and CRC64_NVME against bitwise scalar reference implementations across vector tail boundaries and measure utility-level throughput across all 10 BASE_CHECKSUM_TYPES.
- crates/checksums/src/lib.rs: verify streaming chunked update + finalize + headers() against one-shot digests across all 9 ChecksumAlgorithm variants for unaligned chunk and payload sizes, plus on-demand throughput microbenchmark.
2026-11-10 17:54:11 -05:00
Patrick Wicki ba3e0e1546 fix(admin): keep measured bucket usage when the overlay is unconverged (#9243)
The single-process usage overlay clears usage_snapshot_complete when it
holds counts it cannot reconcile with the persisted snapshot, for
example writes into a bucket that has no verified baseline. The admin
datausageinfo path then judged the overlaid document as structurally
incomplete and replaced it with an empty one, hiding every bucket the
scanner had measured. Because each write restarts that bucket's scanner
fence, a continuously written bucket kept the whole response empty
indefinitely.

Decide whether the persisted snapshot is usable before the overlay is
applied, and report overlay-induced incompleteness as a non-converged
snapshot instead, as is already done for buckets the scan never reached
(rustfs#5806).
2026-11-10 17:52:06 -05:00
Chris An 95e128336d ci: narrow checks for stable installation metadata (#9269)
* ci: narrow checks for stable installation metadata

* fix: reject release metadata marker collisions
2026-11-10 16:10:04 -05:00
Chris An 8b7bbdb17d chore(release): align installation references for 1.0.5 (#9268) 2026-11-10 14:48:02 -05:00
Chris An 2780bba67b ci: stage release builds and promote verified artifacts (#9265)
* ci: stage release builds and promote verified artifacts

* fix: preserve release package and channel retry contracts

* fix: align installation chart versions with sealed candidates
2026-11-10 13:15:05 -05:00
hector 3a23122104 ci: run macOS release builds on macos-latest-xlarge (#9260)
Use the larger GitHub-hosted macOS runner for the macos-aarch64
release target. The target id, triple, and other platforms stay the same.

Co-authored-by: hector <majinghe@users.noreply.github.com>
2026-11-10 18:55:19 +08:00
Ramakrishna Chilaka 0b1ebeffec perf(ecstore): reduce syscalls in multipart copy, commit, and delete (#9247) 2026-11-10 12:27:10 +08:00
170 changed files with 10563 additions and 2989 deletions
+21 -26
View File
@@ -1,43 +1,38 @@
---
name: rustfs-release-publish
description: Publish a RustFS version through Console, release-branch CI, preview acceptance, human confirmation, and final publication. Use only for an explicit release request.
description: Publish a RustFS version through frozen Linux candidate acceptance, desktop completion, manifest approval, byte-preserving publication and post-release follow-up. Use only for an explicit release request.
---
# RustFS Release
Require the exact final SemVer target before version edits or publication. If unspecified, inspect current releases/tags and ask with concrete candidates; do not guess the channel or bump. A named bump type resolves to an exact version that still needs confirmation. Report the confirmed target verbatim; resolve any later mismatch before proceeding. Independent read-only preflight may continue.
Require the exact final SemVer target before version edits or publication. If unspecified, inspect releases/tags and ask with concrete candidates; do not guess the channel or bump. Reuse the user's confirmed target and authorized delivery scope. Independent read-only preflight may continue.
## Invariants
- Cargo stores `<target>`, never `-preview.N`. Before publication, installation defaults retain the previous available deliverable. Final and preview binaries embed their own tag names, so version/asset names differ despite identical source.
- Use annotated tags without `v`. Preview names are exactly `<target>-preview.<digits>`; choose the next unused iteration after fetching tags. Preview Releases are prereleases, never Latest; publish only versioned assets/exact Docker tags, never latest assets, R2, Helm, moving Docker channels, or installation/banner updates.
- `release/<target>` owns the candidate. Retain its history, backport only reviewed fixes, and never merge main wholesale or force-push it. Pin `PREVIEW_HASH` to its exact commit with complete passing CI. Preview and final tags both point there; never tag a later main/branch HEAD or insert another bump.
- Phase failures block dependents. Keep preview Releases until final publication and notes generation finish; CI removes Releases while retaining tags. Never move an existing preview or final tag.
- Cargo and every candidate executable contain the final product version. `<target>-preview.<run_number>` is an immutable candidate identifier, not a public tag or displayed binary version.
- Freeze the exact source, reviewed workflow recipe, toolchain, Console, CLI, dependency lock, previous stable version and validation plan. Newer unrelated main or Console releases do not change those inputs.
- Retain `release/<target>` history when a release branch is used; backport only reviewed fixes. Pin `CANDIDATE_SHA` to its exact CI-validated commit. Never force-push a release branch or move a public tag.
- Build Linux first. Complete field acceptance before macOS/Windows and packaging. Full certification and final acceptance are required before publication; a partial Linux PASS grants no release authority.
- Promote the accepted bytes. Final publication never recompiles binaries or rebuilds packages/images. Infrastructure retries reuse successful artifacts; changed source or inputs require a new candidate and affected acceptance.
- Phase failures block dependents. No latest/default-channel update before versioned delivery. Use the [pipeline runbook](../../../docs/operations/release-pipeline.md) for executable entry points, locks and recovery.
## Read by Phase
Read only the current phase's procedure; retain completed evidence across turns. Follow root verification, Git/PR, and authorization rules.
## Read by phase
| Phase | Procedure |
|---|---|
| 0 | Clean tree, fetch main/tags, verify GitHub access, then complete the mandatory [Console release gate](references/console-gate.md). A green Console build alone is insufficient. |
| 1–2 | [Prepare/reuse the release candidate and run exact-SHA branch CI](references/release-candidate.md). Source version changes use `rustfs-release-version-bump` with authorized delivery scope. |
| 3 | [Verify the preview build, assets, channels, and notes](references/preview-release.md). |
| 4–5 | [Run the downloaded binary, Console CRUD, and latest-rc matrix](references/preview-acceptance.md). Every check must pass. |
| Confirmation | Complete the gate below and end the turn. |
| 6 | After fresh confirmation, [publish and verify the final tag](references/final-release.md) at `PREVIEW_HASH`. |
| 7 | [Reconcile Security Advisories, milestones, installation references, and the website; reconcile main and remove the completed release branch](references/post-release-updates.md) only after complete publication. |
| 0 | Clean tree, fetch main/tags, verify access, then complete the mandatory [Console gate](references/console-gate.md). |
| 1–2 | [Prepare the exact source and run branch CI](references/release-candidate.md). Version edits use `rustfs-release-version-bump`. |
| 3 | [Build and verify the Linux candidate](references/preview-release.md). |
| 4–5 | [Run artifact, field, Console and rc acceptance](references/preview-acceptance.md), then complete desktop/package/image/chart validation. |
| Confirmation | Report the complete manifest and obtain the confirmation below. |
| 6 | [Publish the accepted manifest](references/final-release.md). |
| 7 | [Reconcile advisories, milestones, installation references, website and release branch](references/post-release-updates.md). |
## Human Confirmation
## Human confirmation
After Phases 3–5 pass, recheck that the release branch still equals `PREVIEW_HASH` and that the current Console asset tag/digest match the preview's recorded build inputs. Report the target, branch, preview tag/hash, branch CI run, preview Release URL, accepted Console asset identity/result, and rc matrix. Explicitly ask whether to publish the final tag and end the turn without creating/pushing it.
After all acceptance passes, report the target, source SHA, source CI run, Linux and completion run URLs, final manifest SHA-256, accepted Console/CLI identities, compatibility/performance results and backlog report. Explicitly ask whether to publish this exact manifest and end the turn without dispatching publication.
Only a new affirmative user reply to that exact report authorizes Phase 6. The original release request, earlier confirmation, silence, or automated follow-up does not count. Confirmation binds the target, preview tag, hash, and accepted build inputs; a changed value or repeated/failed acceptance cycle invalidates it. Revalidate and obtain fresh confirmation before continuing.
Only a new affirmative reply to that exact report authorizes Phase 6. An earlier general release request, silence or automatic continuation does not count. Reuse confirmation for retries of that same manifest; do not ask again merely because a destination failed. A changed source, input, manifest or acceptance result invalidates it and requires renewed acceptance/confirmation.
## Recovery
## Recovery and reporting
- Before the final tag exists, land fixes on main, backport to the candidate, verify target versions, and restart branch CI/acceptance. Use a new preview iteration once a preview was tagged; main may advance independently.
- If only an external build input changed, record it, create a new preview iteration even when the source SHA is unchanged, and repeat the affected build and artifact acceptance before fresh confirmation. Reuse source checks whose inputs remain valid.
- Once the final tag exists, retry publication jobs for that exact tag. A source fix requires a newly confirmed target, not retagging or a newer preview. Phase 7 retries recheck artifact availability and superseding releases.
- If abandoned after the bump merged, report the unpublished version on main and record whether to revert or let the next release overwrite it. Deleting unpublished candidate branches or preview tags requires an explicit decision. Completed release branches follow Phase 7 cleanup; keep release tags.
Report completed phase results and incomplete work with evidence: Console previous/latest tags, release need, hash and run/Release URLs; target, branch CI/SHA, previews and hash, confirmation state; preview/final assets, latest-channel state, Console/rc results and cleanup; advisory reconciliation results and pending disclosure decisions; both milestone states, installation PR checks/merge state, and website text/link/PR/deployment state. Keep artifact publication separate from follow-up delivery; do not call an open PR a live update. Report deviations and their authorization.
Resume failed infrastructure jobs without rebuilding successful units. Fix product defects on main, backport when needed, and start a new candidate. After a final tag exists, retry the same bytes or use a new patch version; never move the tag. Do not conflate implemented, CI-passed, field-accepted, approved, published and post-release-complete states. Report incomplete work and deviations with evidence; a tag or green build alone is not a completed release.
@@ -4,7 +4,7 @@ Read during Phase 0, before changing RustFS version files or tags. Follow the pa
### 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.
Complete this gate before changing any RustFS version file or creating any RustFS tag. Candidate creation resolves `repos/rustfs/console/releases/latest` once and freezes the exact asset for every build, so a successful Console build alone is insufficient. After freezing, later Console releases do not silently change or invalidate the accepted inputs.
1. Read the latest published Console tag and compare it with Console `main`:
@@ -1,22 +1,13 @@
# Final Release Publication
# Final release publication
Read only after fresh user confirmation of the preview acceptance report. The [parent confirmation gate](../SKILL.md#human-confirmation) applies.
Read only after fresh confirmation of the complete candidate report. The [parent confirmation gate](../SKILL.md#human-confirmation) applies.
## Phase 6 — Publish the final tag on the validated commit
Dispatch `release-publish.yml` on main with the successful completion run ID and approved `release-manifest.json` SHA-256. Do not create the tag manually. The workflow revalidates source, bytes and trusted certification, then creates the annotated final tag at `CANDIDATE_SHA`. A recipe change fails closed and requires a new candidate; unrelated main changes do not require a rebuild.
No second source version bump. Before creating the final tag, recheck the remote `release/<target>` HEAD equals `PREVIEW_HASH` and the current Console release tag/asset digest still match the accepted preview inputs. If either changed after confirmation, the approval no longer applies; validate the changed inputs with a new preview and obtain fresh confirmation. The final tag goes on the exact commit the preview validated:
Require both versioned publication and channel publication jobs to succeed. Verify the final tag's commit and manifest identity; all six platform ZIPs and compatibility aliases; DEB/RPM, checksums, SBOM, provenance and evidence bundle; both multi-architecture image variants in Docker Hub, GHCR and Quay; and the accepted Helm chart in the chart repository. Download a host-compatible final artifact and confirm its digest is exactly the accepted candidate digest and its version/source output agrees.
```bash
git fetch origin --tags
git rev-parse "<preview-tag>^{commit}" # must equal PREVIEW_HASH — abort if not
git tag -a "<target>" -m "Release <target>" "$PREVIEW_HASH"
git push origin "<target>"
```
For stable releases verify GitHub Latest, R2 download aliases and `latest.json` unless a newer stable release already owns those channels. Prereleases must remain GitHub prereleases and leave stable pointers unchanged. Verify generated release notes compare against the frozen previous stable release, excluding internal previews.
- CI rebuilds from the same source with a different tag name, so the binary self-reports `<target>`. Other inputs can change: `build.yml` reads Console `releases/latest` during each build. Compare the actual Console tag, URL, and verified digest in every final build job with the accepted preview record, and inspect available build provenance for other relevant changes. A pre-tag check cannot prevent a later input change. On a mismatch or missing evidence, repeat affected acceptance against the final artifacts and report the limitation; failed acceptance leaves release validation incomplete even if publication already succeeded. Never move the final tag to conceal a failure.
- Verify the final tag's complete publication path: all matrix and release jobs green; `gh release view "<target>"` shows the full versioned and `-latest` asset set plus checksums, SBOM, and provenance; Docker and Helm workflows succeed; `latest.json` points to `<target>`. 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.
- Verify the final Release body contains `## What's Changed` and a Full Changelog link. When `PREVIOUS_DELIVERABLE` exists, the link MUST be `https://github.com/rustfs/rustfs/compare/<PREVIOUS_DELIVERABLE>...<target>` and the baseline MUST equal the preview Release baseline; for example, both `1.0.0-beta.12-preview.1` and `1.0.0-beta.12` compare from `1.0.0-beta.11`.
- Verify the preview cleanup: `cleanup-preview-releases` must succeed and `gh release view "<preview-tag>"` must then report `release not found` for every preview iteration of this target. Each surviving preview tag must still resolve to its own recorded commit; the accepted preview resolves to `PREVIEW_HASH`. If the job failed, delete the leftover Releases manually with `gh release delete "<preview-tag>" --yes` and report it.
- Verify a host-compatible final artifact's checksum and `./rustfs --version`; it must report `<target>` and the expected source SHA. This identity check does not replace affected acceptance when build inputs changed.
On infrastructure failure retry the same workflow/seal; matching destinations are reused. Never clobber an asset, force a tag, rebuild the image or silently accept a changed digest. A source fix after the final tag requires a new patch version. Cross-registry writes are not atomic: report the unfinished destination rather than claiming publication complete.
Never pass `--cleanup-tag` when removing preview Releases. Remove them manually only if `cleanup-preview-releases` failed, after final publication and notes generation. Record deleted Releases and surviving tags.
New candidates create no preview Releases to clean up. For a legacy preview release, preserve its tag and remove its Release only after final delivery and notes are verified. Do not pass `--cleanup-tag`. Complete the [post-release follow-up](post-release-updates.md) separately.
@@ -24,7 +24,7 @@ Reconcile advisories after every completed final release, including Console-only
1. Re-read the published deliverable state before edits or a retry. If a newer release has superseded this target, do not downgrade installation references or the website announcement. Report the superseding release and leave those defaults intact.
2. Invoke `rustfs-release-version-bump` with stage `post-release`, the exact target, and the authorized delivery scope. Prepare the follow-up on current main; do not edit or move either validated release tag. Cargo versions may already be preparing the next target and must remain untouched.
3. Verify the exact Docker image manifest, Release download links, RPM source archive, and Helm repository entry before publishing the installation PR. The chart workflow overrides chart/app versions from the final tag, pins the source checkout to that release, and waits for its default Docker image before packaging.
3. Verify the exact Docker image manifest, Release download links, RPM source archive, and Helm repository entry before publishing the installation PR. The chart is packaged and tested before sealing; publication uploads those same bytes only after versioned Docker images are available.
4. Report the installation PR and its merge state separately from the completed release. Reuse existing merge authorization and repository checks; do not infer new merge authority from this reference.
## rustfs.com top banner
@@ -54,4 +54,4 @@ Use the existing announcement configuration from [website PR #112](https://githu
1. Compare the exact published tag with current main. Audit every release-side commit since their merge base, including inherited backports. An ahead count is a history difference, not proof of missing changes. Use ancestry and patch equivalence, then inspect current code and regression evidence for squashed, adapted, superseded, or reverted changes. Record each commit as present, superseded, release-only, or missing; a positive `git cherry` result alone does not prove a missing fix.
2. Bring missing shared changes to main through a reviewed PR and the required checks before closing the follow-up. Preserve main's later changes; decide version bumps and pinned Console configuration separately. If a history merge is needed, use a merge commit and verify the release commit is an ancestor of main; squash does not establish that ancestry. Do not merge main into the published release branch or move its tags. Report unresolved changes instead of claiming reconciliation is complete.
3. After publication and authorized follow-ups are complete, delete the remote `release/<target>` branch. Immediately before deletion, verify its HEAD still equals the published tag's commit and no open PR targets or originates from it. Preserve a branch with newer work or open PRs and report the blocker. Use a deletion with an expected-old-SHA check to avoid removing a concurrently advanced branch.
4. Verify the remote branch is absent and the final and preview tags still resolve to their recorded commits. Remove a local release branch only if it is task-owned, unchecked out, and has no unpreserved work. Record cleanup separately from artifact publication; an explicitly authorized deletion before reconciliation does not make unresolved main changes complete.
4. Verify the remote branch is absent and the final tag and any legacy preview tags still resolve to their recorded commits. Remove a local release branch only if it is task-owned, unchecked out, and has no unpreserved work. Record cleanup separately from artifact publication; an explicitly authorized deletion before reconciliation does not make unresolved main changes complete.
@@ -1,10 +1,10 @@
# Preview Artifact Acceptance
# Candidate Artifact Acceptance
Read after Phase 3 succeeds. Complete every check below before the parent skill's manual confirmation gate. These checks cover the downloaded artifact, embedded Console, and latest rc client.
Read after Phase 3 succeeds. Complete every check below before the parent skill's manual confirmation gate. These checks cover the downloaded artifact, embedded Console, and the rc release frozen in the candidate inputs.
## Phase 4 — Run the artifact locally, verify the console
Use a unique task-owned scratch directory. Select the versioned archive matching the host OS, architecture, and runtime, download its checksum file, and verify the archive before extraction. Do not substitute a different build when a matching artifact is unavailable; report that acceptance gap. The shell example below assumes the verified executable is at `RUSTFS_BINARY` (`rustfs.exe` on Windows), `SCRATCH` is the task directory, and `S3_PORT` and `CONSOLE_PORT` are distinct available ports. Use equivalent native commands when the shell differs.
Use a unique task-owned scratch directory. Select the versioned archive matching the acceptance host, and verify it against the sealed manifest before extraction. Before desktop completion, use the Linux acceptance environment rather than rebuilding for the local desktop. Do not substitute a different build when a matching artifact is unavailable; report that acceptance gap. The shell example below assumes the verified executable is at `RUSTFS_BINARY` (`rustfs.exe` on Windows), `SCRATCH` is the task directory, and `S3_PORT` and `CONSOLE_PORT` are distinct available ports. Use equivalent native commands when the shell differs.
Bind both listeners to loopback. Choose task-only credentials, set loopback proxy exclusions for the server/client processes, and record the server PID. A port probe does not reserve a port; retry startup with fresh ports on a bind conflict rather than connecting to an existing service.
@@ -24,16 +24,16 @@ PREVIEW_SERVER_PID=$!
Checks (all must pass):
- `"$RUSTFS_BINARY" --version` reports the preview tag name and the short SHA of `PREVIEW_HASH`. Reporting `<target>` without the `-preview.N` suffix means the build did not embed the tag — treat as FAIL and investigate before proceeding.
- `"$RUSTFS_BINARY" --version` reports the exact final `<target>` and full `CANDIDATE_SHA`. A preview suffix, another version or another source is a failure.
- Wait with a bounded readiness deadline while checking the recorded server process remains alive; `curl --noproxy '*' -fsS "$S3_ENDPOINT/health/ready"` must return ready from this process's listener.
- Startup log shows the embedded console being served (this was the regression that `fix(release): require embedded console assets` guards).
- Open `CONSOLE_ENDPOINT` in the browser and log in with the task credentials. The dashboard must render without JS console errors; create a bucket, upload a file, download it back (byte-identical), and delete the object and bucket. Keep the server running for Phase 5.
## Phase 5 — Validate with the latest rc client
## Phase 5 — Validate with the pinned rc client
`rc` is the RustFS CLI client from <https://github.com/rustfs/cli>.
- Resolve the latest release with `gh api repos/rustfs/cli/releases/latest --jq .tag_name`; verify the local client's version or download and verify a host-compatible client into the task directory. Set `RC_BIN` to that executable and record its version/digest. Avoid changing the user's installed client for this check.
- Read the CLI release and asset digests from `inputs.json`; verify the local client's version or download a host-compatible asset from that exact release into the task directory. If the host is a desktop, resolve its asset within the same pinned CLI release and record the digest; do not independently follow `latest`. Set `RC_BIN` to that executable and record its version/digest. Avoid changing the user's installed client for this check.
- Inspect the client's current help for an isolated configuration option and use it when supported. Otherwise choose a unique `PREVIEW_ALIAS` and confirm it is absent before creating it; never overwrite an existing alias. Use task-owned bucket/user names and input/output files. The names below are safe only inside the newly started isolated server.
- Point the client at `S3_ENDPOINT` with loopback proxy exclusions and run the command matrix, recording PASS/FAIL per command:
@@ -60,3 +60,9 @@ cmp <local-file> "$SCRATCH/out"
```
- Any FAIL blocks acceptance. On success or failure, stop only the recorded task process and remove only task-created aliases/data after retaining the needed failure evidence. Do not erase an existing client configuration or unrelated server data.
## Field acceptance and desktop completion
Before accepting the Linux manifest, record Scanner/Heal, multi-pool, memory, major features, high-concurrency 503, performance and previous-version integrity/upgrade results in backlog. Preserve the release-specific agreed limits and baseline; a PASS label without artifact identities or test results does not satisfy acceptance.
Dispatch `release-complete.yml` only after that report passes, using the exact Linux manifest digest and backlog URL. Require native macOS/Windows checks, package extraction, image startup/scans, chart tests and complete certification to pass. Complete any desktop Console/client checks applicable to the release against these same archives. Record the completion run and final manifest SHA-256 before entering the parent confirmation gate. Partial Linux acceptance does not authorize final publication.
@@ -1,25 +1,9 @@
# Preview Release Verification
# Linux candidate verification
Read for Phase 3 after the candidate passed exact-SHA CI. The [parent release invariants](../SKILL.md) apply.
Read for Phase 3 after the source passed exact-SHA CI. The [parent invariants](../SKILL.md) apply; commands and recovery live in the [pipeline runbook](../../../../docs/operations/release-pipeline.md).
## Preview channels
Dispatch `release-candidate.yml` on main with the confirmed final version and `CANDIDATE_SHA`. Record the run ID, frozen `inputs.json`, every Linux archive digest and `linux-manifest.json` SHA-256. Require the four Linux builds, native startup/S3 checks, previous-release upgrade matrix and Linux seal to succeed. Missing, skipped, cancelled or failed required work is incomplete acceptance.
- Use `<target>-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.<digits>`. `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, Helm, or moving Docker channel tag is a pipeline failure. Docker images may use the exact preview version tag, including its existing variant suffixes.
- Preview Releases are cleaned up by the `cleanup-preview-releases` job after `publish-release` succeeds for the deliverable tag. It deletes every Release whose tag is exactly `<target>-preview.<digits>` and never passes `--cleanup-tag`, so the tags survive.
Verify the frozen Console identity equals the one accepted in Phase 0. Every matrix job consumes that same digest and exact toolchain. The binary must report the final product version and source commit; its candidate ID is separate. No public tag, Release, registry tag, R2 release path, Helm repository or latest pointer should change during this stage.
## Phase 3 — CI and preview Release verification
- Find and watch the tag build: `gh run list --workflow build.yml --branch "<preview-tag>" --limit 1` then `gh run watch <run-id>`. Every build matrix target must succeed (linux x86_64/aarch64 × musl/gnu, macos-aarch64, windows-x86_64).
- Confirm the preview tag resolves to the Phase 2 CI-validated `PREVIEW_HASH`. If the release branch advances during acceptance, repeat Phases 2–5 with a new preview tag.
- Confirm the Release publication jobs (`create-release`, `upload-release-assets`, and `publish-release`) succeed while `update-latest-version` is skipped.
- Record the inputs actually consumed by each build job: source SHA and the embedded Console release tag, asset URL, and verified SHA256 from the download logs. The workflow reads Console `releases/latest` separately for each build; confirm the matrix used the Console asset accepted in Phase 0. A missing identity or differing asset blocks acceptance until resolved. Retain available toolchain and build provenance with the artifact checksums; the same source SHA alone does not establish equivalent builds.
- Verify `gh release view "<preview-tag>" --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 `<preview-tag>`.
- Record `PREVIOUS_DELIVERABLE`, selected from published Releases by `publishedAt` after excluding the current tag and every `-preview.N` tag. Verify `gh release view "<preview-tag>" --json body --jq .body` contains `## What's Changed` and, when `PREVIOUS_DELIVERABLE` exists, `**Full Changelog**: https://github.com/rustfs/rustfs/compare/<PREVIOUS_DELIVERABLE>...<preview-tag>`. For a repository with no previous deliverable, verify a Full Changelog link exists and record the GitHub baseline fallback.
- Confirm Helm is skipped. If preview Docker images are published, confirm they use only exact preview tags (and variant suffixes), with no `latest`, `alpha`, `beta`, or `rc` channel updates. Preview validation covers the built RustFS binaries, embedded console, and rc compatibility; it does not authorize advancing any installation default.
## Release notes
- When a previous deliverable exists, GitHub Release notes for the preview and final tags MUST use it as their shared comparison baseline: the most recently published non-preview Release before the target. Internal `-preview.N` Releases are explicitly excluded from that selection, even when they point at the same commit as the final tag — cleanup runs after the notes are generated, so the preview Release is still present and would otherwise be picked as the baseline. If no previous deliverable exists, omit `previous_tag_name` and record that GitHub's default baseline fallback was used.
- Generated Release notes carry a workflow-management marker so retries can repair them. Before manually curating a generated body, remove that marker; unmarked non-placeholder notes are preserved by later workflow runs.
A green Linux run is ready for field acceptance, not publication. The partial certification must have `publish_allowed: false`. Retain the exact Linux manifest for the next stage; a new source/build-input candidate must not inherit the old approval. A newer unrelated main commit does not invalidate this evidence.
@@ -16,22 +16,13 @@ Read for Phases 1–2. The [parent release invariants](../SKILL.md) and Console
- Only when neither the release branch, an unfinished preview tag, nor current main already has `<target>`: if main is at a later version, select and verify a historical commit containing `<target>` rather than downgrading main; block if none exists. Otherwise invoke the `rustfs-release-version-bump` skill with stage `prepare`, the final `<target>` (NOT a preview version), and full GitHub flow (commit/push/PR).
- If a bump PR was needed, get it merged into main and record its merge commit. For a new release branch when main already has `<target>`, identify the main commit that introduced those versions. Phase 2 chooses a release candidate containing that commit; an existing release branch keeps its own history and a newer main HEAD is not required.
Do not wait for the moving latest main HEAD to become green. The release-branch CI in Phase 2 validates the chosen commit before a preview tag is created.
Do not wait for the moving latest main HEAD to become green. The release-branch CI in Phase 2 validates the chosen commit before a candidate build is dispatched.
## Phase 2 — Pin and validate the release candidate, then publish the preview tag
## Phase 2 — Pin and validate the release source
- Use `release/<target>`. On the first attempt, create it from a selected main commit containing the version bump and intended changes; it need not be the latest main HEAD. On a restart, retain the existing branch and backport only reviewed fixes from main. Verify the branch's Cargo versions still equal `<target>`. If adopting an existing preview, run this branch CI before continuing its acceptance; reuse the tag only when it points to the validated SHA.
- The current `ci.yml` runs automatically on main pushes, not release-branch pushes. Dispatch it explicitly with `gh workflow run ci.yml --ref "release/<target>"`. Locate that dispatch run, verify its `head_sha` equals the release branch's remote HEAD, and require the full expected CI job set to pass, including `Quick Checks`, `Test and Lint`, and `End-to-End Tests (full merge gate)`. Treat a missing or skipped required lane as incomplete. A successful PR check or CI run for a different SHA does not qualify. If the branch advances or a run is cancelled, validate the new SHA again. Do not tag a candidate with failing or incomplete CI.
- Use `release/<target>`. On the first attempt, create it from a selected main commit containing the version bump and intended changes; it need not be the latest main HEAD. On a restart, retain the existing branch and backport only reviewed fixes from main. Verify the branch's Cargo versions still equal `<target>`. A legacy preview can anchor source selection, but its tag and old artifacts do not constitute a candidate in the new protocol; freeze and verify a new candidate after the release recipe is aligned.
- The current `ci.yml` runs automatically on main pushes, not release-branch pushes. Dispatch it explicitly with `gh workflow run ci.yml --ref "release/<target>"`. Locate that dispatch run, verify its `head_sha` equals the release branch's remote HEAD, and require the full expected CI job set to pass, including `Quick Checks`, `Test and Lint`, and `End-to-End Tests (full merge gate)`. Treat a missing or skipped required lane as incomplete. A successful PR check or CI run for a different SHA does not qualify. If the branch advances or a run is cancelled, validate the new SHA again. Do not dispatch a candidate with failing or incomplete CI.
- Prefer protecting `release/*` against force pushes and deletion and requiring reviewed backport PRs. Check live rulesets rather than assuming the main ruleset covers release branches; report a missing release-branch rule as a gap.
- Set `PREVIEW_HASH` to the CI-validated release-branch commit and recheck the remote branch HEAD immediately before tagging. Report the branch, commit, and CI run URL; do not derive `PREVIEW_HASH` from a later `origin/main` fetch. When adopting an existing preview tag at this SHA, skip tag creation and continue Phase 3.
```bash
git tag -a "<preview-tag>" -m "Release <preview-tag>" "$PREVIEW_HASH"
git push origin "<preview-tag>"
```
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. Docker may publish exact preview image tags. Latest-channel, R2, and Helm publication must be skipped; installation references and the website announcement stay unchanged.
On a restart after a backport, re-run release-branch CI before setting the new `PREVIEW_HASH`. Use the next unused preview iteration when the validated commit differs from an existing preview tag; never move an existing tag.
- Set `CANDIDATE_SHA` to the CI-validated release-branch commit. Record the branch, exact commit and CI run URL. The new candidate protocol starts from a commit whose release recipe matches the reviewed main dispatcher; backport the recipe before freezing an older release branch if needed.
- Dispatch the Linux candidate as described in [candidate verification](preview-release.md). Do not create a preview or final tag here. The final executable already contains `<target>`; publication later promotes those same bytes.
- After a source or build-input change, run the affected source checks and create a new candidate. Reuse the frozen candidate after an infrastructure failure. Do not restart acceptance for unrelated main advances.
@@ -4,18 +4,18 @@ description: Prepare an exact RustFS Cargo version or align installation referen
---
# Release Version Files
Require an exact target; ask if missing or ambiguous. Reject targets containing `-preview`: those are tag-only. Infer `post-release` only from an explicit request or parent workflow; otherwise use `prepare`. Delivery defaults to local edit/verify; commit, push, PR, and merge follow existing authorization and [Git rules](../../references/pull-requests.md).
Require an exact target; ask if missing or ambiguous. Reject targets containing `-preview`: those identify candidates, not product versions or new public tags. Infer `post-release` only from an explicit request or parent workflow; otherwise use `prepare`. Delivery defaults to local edit/verify; commit, push, PR, and merge follow existing authorization and [Git rules](../../references/pull-requests.md).
| Stage | Files and constraints |
|---|---|
| `prepare` | Only `Cargo.toml` and `Cargo.lock`: workspace package, internal dependency, and workspace member versions become `<target>`. External dependencies stay unchanged. |
| `post-release` | Only `README.md`, `README_ZH.md`, `flake.nix`, `helm/rustfs/Chart.yaml`, and `rustfs.spec`. Cargo may already target the next release; leave it untouched. |
Before installation edits, verify the exact non-preview Release, downloadable assets/source archive, pullable `rustfs/rustfs:<target>` image manifest, and published Helm chart. Block on missing artifacts. Do not downgrade defaults if a newer deliverable superseded the target. Preview preparation retains the previous published installation references; follow-up commits never replace either validated tag.
Before installation edits, verify the exact non-preview Release, downloadable assets/source archive, pullable `rustfs/rustfs:<target>` image manifest, and published Helm chart. Block on missing artifacts. Do not downgrade defaults if a newer deliverable superseded the target. Candidate preparation retains the previous published installation references; follow-up commits never replace validated tags or sealed artifacts.
Packaging policy:
- Docker image tags have no `v` prefix. Derive chart/app versions with `scripts/helm_chart_version.sh <target>` (`beta.N -> 0.N.0`; other versions unchanged). Helm CI derives versions from the final tag, so no early chart bump is required.
- Docker image tags have no `v` prefix. Derive chart/app versions with `scripts/helm_chart_version.sh <target>`; both equal the final product SemVer, including prerelease suffixes. Helm CI uses the frozen candidate version before sealing, so no early installation chart bump is required. Previously published chart versions remain unchanged.
- RPM `Version` is numeric; `Release` is the prerelease suffix or `1` for stable. Expanded `Source0` and the unpack directory must resolve to the exact target including its suffix. Add the changelog entry using current git identity, date, and target; never copy an example identity/date.
- Changing these mappings requires explicit confirmation.
+9
View File
@@ -22,6 +22,14 @@ TEST_THREADS ?= 1
# differ from CI and .config/nextest.toml test-groups will NOT apply.
.PHONY: script-tests
.PHONY: release-package-tests
release-package-tests: ## Run release package integration on Linux with fpm, dpkg and rpm installed
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_packages.py
.PHONY: release-image-tests
release-image-tests: ## Run OCI integration with Docker, Go and skopeo; set RELEASE_IMAGE_VARIANT and RELEASE_IMAGE_OUTPUT
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_images.py
script-tests: ## Run shell script tests
@echo "Running script tests..."
trap 'status=$$?; printf "ERROR: script-tests failed (exit %s): %s\n" "$$status" "$$BASH_COMMAND" >&2; exit "$$status"' ERR
@@ -57,6 +65,7 @@ script-tests: ## Run shell script tests
$(RUSTFS_PYTHON_BIN) ./scripts/test_download_release_asset.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_inspect_btf_prototypes.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_build_workflow.py
$(RUSTFS_PYTHON_BIN) -m unittest discover -s scripts/release -p "test_*.py"
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_glibc.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_upgrade_workflow.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_migration_gate_evidence.py
+135
View File
@@ -0,0 +1,135 @@
{
"schema_version": 1,
"plan_id": "rustfs-release-validation",
"description": "Certification for immutable candidates. All shipped platforms require native identity and startup; stable and prerelease require the pinned upgrade matrix. Linux-only acceptance never authorizes publication.",
"release_types": [
"preview",
"prerelease",
"stable"
],
"targets": [
{
"os": "linux",
"arch": "x86_64",
"abi": "musl"
},
{
"os": "linux",
"arch": "aarch64",
"abi": "musl"
},
{
"os": "linux",
"arch": "x86_64",
"abi": "gnu"
},
{
"os": "linux",
"arch": "aarch64",
"abi": "gnu"
},
{
"os": "macos",
"arch": "aarch64",
"abi": "none"
},
{
"os": "windows",
"arch": "x86_64",
"abi": "msvc"
}
],
"targets_basis": "Platform matrix of .github/workflows/build.yml (prepare-platform-matrix). gnu and macOS targets build with the pyroscope feature; musl and Windows targets build with no extra features.",
"features": [
"pyroscope"
],
"trusted_workflows": [
{
"repository": "rustfs/rustfs",
"workflow_id": "release-candidate.yml"
},
{
"repository": "rustfs/rustfs",
"workflow_id": "release-complete.yml"
}
],
"trusted_workflows_basis": "Receipts are produced by the reviewed main workflows and fetched by exact run ID with GitHub artifact digest verification. Candidate inputs freeze the harness commit separately from product source.",
"lanes": [
{
"lane": "identity",
"harness_repository": "rustfs/rustfs",
"harness_sha": "candidate.workflow_sha",
"harness_basis": "The exact workflow commit is frozen in candidate inputs; no mutable branch is accepted as a harness identity.",
"timeout_seconds": 1800,
"environment_capabilities": [
"process-exec"
],
"cases": [
"artifact-identity",
"startup"
],
"applies_to": {
"release_types": [
"preview",
"prerelease",
"stable"
],
"platforms": "all"
},
"applies_basis": "Artifact identity and startup checks are mandatory for every release type, preview included, on every shipped target (rustfs/backlog#2662 frozen design item 5)."
},
{
"lane": "upgrade",
"harness_repository": "rustfs/rustfs",
"harness_sha": "candidate.workflow_sha",
"harness_basis": "The exact workflow commit is frozen in candidate inputs; no mutable branch is accepted as a harness identity.",
"timeout_seconds": 5400,
"environment_capabilities": [
"process-exec",
"previous-release-artifact"
],
"cases": [
"direct-upgrade",
"mixed-version-upgrade",
"bucket-config-upgrade",
"bucket-config-rollback",
"multipart-layout-upgrade",
"multipart-layout-baseline"
],
"cases_basis": "Case IDs of the pinned previous-release matrix in .github/workflows/e2e-upgrade.yml (source.version/asset/sha256 and the case list mapped there).",
"applies_to": {
"release_types": [
"prerelease",
"stable"
],
"platforms": [
{
"os": "linux",
"arch": "x86_64",
"abi": "gnu"
}
]
},
"applies_basis": "preview releases use the pre-declared smaller set (identity lane only). The upgrade lane is only exercised on linux-x86_64-gnu, the platform of the pinned source assets."
}
],
"upgrade_sources": {
"basis": "Exactly the two previous-release sources pinned in .github/workflows/e2e-upgrade.yml are exercised today (source.version / source.asset / source.sha256). No other N-1/N-2 compatibility is claimed, and these digests are not inferred from tags.",
"sources": [
{
"version": "1.0.1",
"role": "N-1",
"artifact_name": "rustfs-linux-x86_64-gnu-v1.0.1.zip",
"artifact_sha256": "9742e183cb0dfb0d48fa909797af572fe47b7f827569036baa4156e0d3958ee5"
},
{
"version": "1.0.0-rc.5",
"role": "N-2",
"artifact_name": "rustfs-linux-x86_64-gnu-v1.0.0-rc.5.zip",
"artifact_sha256": "3ee8df71e8edcfada533be452c4135868f697bc515460ae97b027313eade7a3d"
}
],
"rollback_supported": false,
"rollback_basis": "Only the bucket-config-rollback case (rollback_to_previous_release_reads_current_bucket_metadata) is exercised. Full data-plane rollback is not certified and is never a default claim."
}
}
+81 -21
View File
@@ -350,10 +350,10 @@
"roles": [
"periodic",
"manual",
"release"
"reusable"
],
"events": [
"push",
"workflow_call",
"schedule",
"workflow_dispatch"
],
@@ -367,7 +367,7 @@
"monitor": "workflow",
"max_age_hours": 36,
"result_owner": "build.yml",
"disposition": "retained independent entry",
"disposition": "Builds artifacts only; public release writes belong to release-publish.yml.",
"failure_owner": "workflow",
"origin": "baseline",
"alert_contract": "scheduled-validation"
@@ -745,14 +745,13 @@
},
{
"workflow": ".github/workflows/docker.yml",
"name": "Docker Images",
"name": "Build candidate images",
"roles": [
"manual",
"release"
"release",
"reusable"
],
"events": [
"workflow_run",
"workflow_dispatch"
"workflow_call"
],
"cadence": "event",
"timezone": "UTC",
@@ -761,7 +760,7 @@
"policy": "active",
"monitor": "none",
"result_owner": "docker.yml",
"disposition": "retained independent entry",
"disposition": "Builds artifacts only; public release writes belong to release-publish.yml.",
"monitor_reason": "Event-driven or operator-requested work has no periodic freshness obligation.",
"origin": "baseline"
},
@@ -954,14 +953,13 @@
},
{
"workflow": ".github/workflows/helm-package.yml",
"name": "Publish helm chart to artifacthub",
"name": "Build candidate Helm chart",
"roles": [
"manual",
"release"
"release",
"reusable"
],
"events": [
"workflow_run",
"workflow_dispatch"
"workflow_call"
],
"cadence": "event",
"timezone": "UTC",
@@ -970,7 +968,7 @@
"policy": "active",
"monitor": "none",
"result_owner": "helm-package.yml",
"disposition": "retained independent entry",
"disposition": "Builds artifacts only; public release writes belong to release-publish.yml.",
"monitor_reason": "Event-driven or operator-requested work has no periodic freshness obligation.",
"origin": "baseline"
},
@@ -1182,14 +1180,13 @@
},
{
"workflow": ".github/workflows/package.yml",
"name": "Package DEB/RPM",
"name": "Build candidate packages",
"roles": [
"manual",
"release"
"release",
"reusable"
],
"events": [
"workflow_run",
"workflow_dispatch"
"workflow_call"
],
"cadence": "event",
"timezone": "UTC",
@@ -1198,7 +1195,7 @@
"policy": "active",
"monitor": "none",
"result_owner": "package.yml",
"disposition": "retained independent entry",
"disposition": "Builds artifacts only; public release writes belong to release-publish.yml.",
"monitor_reason": "Event-driven or operator-requested work has no periodic freshness obligation.",
"origin": "baseline"
},
@@ -1727,6 +1724,69 @@
"disposition": "retained independent entry",
"monitor_reason": "Event-driven or operator-requested work has no periodic freshness obligation.",
"origin": "baseline"
},
{
"workflow": ".github/workflows/release-candidate.yml",
"name": "Release candidate (Linux)",
"roles": [
"manual",
"release"
],
"events": [
"workflow_dispatch"
],
"cadence": "event",
"timezone": "UTC",
"schedule": [],
"owner": "@overtrue",
"policy": "active",
"monitor": "none",
"result_owner": "release-candidate.yml",
"disposition": "Explicit immutable candidate stage",
"monitor_reason": "Operator-requested release stage has no periodic freshness obligation.",
"origin": "release-candidate-migration"
},
{
"workflow": ".github/workflows/release-complete.yml",
"name": "Release candidate (complete)",
"roles": [
"manual",
"release"
],
"events": [
"workflow_dispatch"
],
"cadence": "event",
"timezone": "UTC",
"schedule": [],
"owner": "@overtrue",
"policy": "active",
"monitor": "none",
"result_owner": "release-complete.yml",
"disposition": "Explicit immutable candidate stage",
"monitor_reason": "Operator-requested release stage has no periodic freshness obligation.",
"origin": "release-candidate-migration"
},
{
"workflow": ".github/workflows/release-publish.yml",
"name": "Publish accepted release",
"roles": [
"manual",
"release"
],
"events": [
"workflow_dispatch"
],
"cadence": "event",
"timezone": "UTC",
"schedule": [],
"owner": "@overtrue",
"policy": "active",
"monitor": "none",
"result_owner": "release-publish.yml",
"disposition": "Explicit immutable candidate stage",
"monitor_reason": "Operator-requested release stage has no periodic freshness obligation.",
"origin": "release-candidate-migration"
}
]
}
+11 -4
View File
@@ -5,7 +5,7 @@
## Billing facts
This repository is public. For a public repository, GitHub's standard hosted runners (`ubuntu-latest`, `windows-latest`, `macos-latest`) cost nothing and have no minute cap, whatever plan the organisation is on. GitHub bills only two things: larger hosted runners, which have custom labels and are not used here, and standard runners in private repositories.
This repository is public. For a public repository, GitHub's standard hosted runners (`ubuntu-latest`, `windows-latest`, `macos-latest`) cost nothing and have no minute cap, whatever plan the organisation is on. GitHub bills only two things: larger hosted runners, including the `macos-latest-xlarge` release lane used here, and standard runners in private repositories.
Every other label in this repository is capacity RustFS pays for. ARC pods and lab machines accrue cost while the machine exists. A Ubicloud label is metered for the minutes a job holds its VM:
@@ -13,6 +13,7 @@ Every other label in this repository is capacity RustFS pays for. ARC pods and l
|---|---|---|---|
| `ubuntu-latest` | nobody | GitHub-hosted, Ubuntu 24.04, 4 vCPU, 16 GB RAM, about 14 GB of free disk. Ships a C toolchain, `docker`, `gh`, `curl`, `wget`, `jq`, Python. Can `mount` tmpfs and loop devices; io_uring works | the default for every job |
| `windows-latest`, `macos-latest` | nobody | GitHub-hosted standard runners | Windows and macOS targets |
| `macos-latest-xlarge` | RustFS | GitHub-hosted larger Apple Silicon runner | macOS release builds, deferred until Linux acceptance for candidates |
| `sm-standard-2` | RustFS | ARC pod, Ubuntu 24.04, minimal image: no `gh`, no C toolchain; Docker only on the `dind-` label | only a job that a hosted runner cannot run (see the placement rule) |
| `sm-standard-4` | RustFS | ARC pod, Ubuntu 24.04, 14 CPUs and 28 GB (the label is not the core count), large disk | workspace-scale Cargo builds |
| `ubicloud-standard-16-arm` | RustFS | Ubicloud VM, alias of `ubicloud-standard-16-arm-ubuntu-2404`: Ubuntu 24.04 arm64, 16 vCPU, 48 GB, 300 GB disk. Reached through the `build.yml` matrix, so `actionlint.yaml` does not list it | native aarch64 release builds (`linux-aarch64-musl`, `linux-aarch64-gnu`) |
@@ -32,7 +33,7 @@ A job runs on `ubuntu-latest` unless it has one of these reasons, and the reason
Jobs that do only the following have no reason to be self-hosted: shell or Python scripts, `cargo fmt`, `cargo deny`, typos, actionlint, API calls (`gh`, dependency review, issue and stale bots, CLA checks), downloading an artifact and running a prebuilt binary, or a `cargo build -p rustfs` with its own cache key. `e2e-distributed.yml` and `e2e-upgrade.yml` build the server and the `e2e_test` harness on `ubuntu-latest` inside their budgets, so "it compiles something" alone is not a reason.
The functional chain's `prepare` and `complete-chain` jobs, the performance runner availability check, and runner hygiene use `ubuntu-latest`: they inspect APIs or artifacts, not the lab hosts. The actual functional and performance suites remain on their lab runners. Functional-chain health collection uses `ubuntu-latest` so a lab outage cannot also stop its independent monitor. Docker image packaging also uses `ubuntu-latest` for both musl and glibc variants; it downloads release binaries rather than compiling the workspace, and still sets up QEMU and Buildx explicitly.
The functional chain's `prepare` and `complete-chain` jobs, the performance runner availability check, and runner hygiene use `ubuntu-latest`: they inspect APIs or artifacts, not the lab hosts. The actual functional and performance suites remain on their lab runners. Functional-chain health collection uses `ubuntu-latest` so a lab outage cannot also stop its independent monitor. Docker image packaging also uses `ubuntu-latest` for both musl and glibc variants; it wraps verified candidate binaries rather than compiling the workspace, and still sets up QEMU and Buildx explicitly.
Moving these jobs off AKS reduces capacity demand, not necessarily the Azure bill immediately. VM costs fall only when nodes scale down or smaller capacity replaces them.
@@ -52,7 +53,7 @@ A hosted runner is also the only choice for a few things the pods cannot do: `mo
These are the patterns that have cost real money here without validating anything more:
- An `if: always()` summary or alert job on a self-hosted label inside a `workflow_run`-triggered workflow. The upstream workflow completes many times a day, the real jobs skip, and the summary still starts a pod each time. Gate it on the upstream job having run, and keep it on `ubuntu-latest`.
- Per-push workflows on `main`. A merge triggers the full `ci.yml` matrix, `cache-warm.yml` and the `build.yml` development build; with tens of merges a day that is as much self-hosted time as all pull requests together. Anything that does not verify the merged tree itself belongs on a schedule or behind a `paths` filter.
- Per-push workflows on `main`. A merge triggers the applicable `ci.yml` matrix and cache warming; `build.yml` development builds are scheduled or manual; with tens of merges a day that is as much self-hosted time as all pull requests together. Anything that does not verify the merged tree itself belongs on a schedule or behind a `paths` filter.
- `apt-get install` of tools the hosted image already ships. It is only needed on the minimal pod images.
- A throttled `CARGO_BUILD_JOBS` on a 14-CPU pod. The limit exists for link-storm reasons documented in `ci.yml`; measure before raising it, and measure before lowering it further.
- Tiny jobs on ARC pods. Each one pays roughly a minute of pod start-up before its first step; a compile-free job that takes ten seconds costs more to schedule than to run.
@@ -66,7 +67,7 @@ gh api "repos/rustfs/rustfs/actions/runs/<run_id>/jobs?per_page=100" \
--jq '.jobs[] | "\(.conclusion)\t\(.labels | join(","))\t\(.started_at)\t\(.completed_at)\t\(.name)"'
```
Only rows whose label starts with `sm-`, `dind-`, `ubicloud-`, `smoke-` or `pf-` cost anything. At the October 2026 review, a full pull-request run of `ci.yml` spent about 150 self-hosted minutes, a third of it in jobs that never compiled; treat that as the scale of what a placement mistake costs, not as a current figure.
Rows using `macos-latest-xlarge` or a label starting with `sm-`, `dind-`, `ubicloud-`, `smoke-` or `pf-` use paid capacity. At the October 2026 review, a full pull-request run of `ci.yml` spent about 150 self-hosted minutes, a third of it in jobs that never compiled; treat that as the scale of what a placement mistake costs, not as a current figure.
## Checklist for a new or moved job
@@ -76,3 +77,9 @@ Only rows whose label starts with `sm-`, `dind-`, `ubicloud-`, `smoke-` or `pf-`
- A self-hosted `runs-on` carries a comment naming its reason from the placement rule.
- Every `./.github/actions/setup` call states `cache-save-if`, and a key read from one pool is not written from the other.
- Moving a job does not change its check name, so the `main` ruleset and `docs/testing/ci-gates.md` stay as they are; renaming it does, and then that document's change checklist applies.
## Release stages
The [candidate release pipeline](../../docs/operations/release-pipeline.md) builds Linux first and defers paid desktop capacity until acceptance. Daily development builds also select Linux; use manual platform selection for desktop development artifacts. All six platforms remain required before publication. The pipeline records and promotes the exact tested bytes. Builder workflows have no public-release credentials. Keep periodic development builds, PR verification and field-test chains separate when their contracts differ; do not re-enable disabled workflows as part of a source-file migration.
Larger runners remain billed for public repositories; see [GitHub runner billing](https://docs.github.com/en/actions/concepts/runners/larger-runners#billing). Actual spending comes from organization billing, not runner labels or an estimated build duration.
+149 -8
View File
@@ -23,9 +23,16 @@ on:
- 'deny.toml'
- '.github/actions/**'
- '.github/workflows/**'
- 'scripts/release/create_or_update_release.sh'
- 'scripts/release/package_service_scripts.sh'
- 'scripts/release/package_versions.sh'
- 'scripts/release/**'
- 'scripts/security/generate_release_supply_chain_assets.py'
- '.config/release-validation-plan.json'
- '.github/scheduled-validations.json'
- 'scripts/test_helm_release_workflow.py'
- 'scripts/test_release_packages.py'
- 'scripts/test_release_images.py'
- 'Dockerfile*'
- 'entrypoint.sh'
- 'bootstrap/**'
- 'scripts/test_package_service_scripts.sh'
- 'scripts/test_package_versions.sh'
- 'scripts/test_docker_workflow.py'
@@ -41,9 +48,16 @@ on:
- 'deny.toml'
- '.github/actions/**'
- '.github/workflows/**'
- 'scripts/release/create_or_update_release.sh'
- 'scripts/release/package_service_scripts.sh'
- 'scripts/release/package_versions.sh'
- 'scripts/release/**'
- 'scripts/security/generate_release_supply_chain_assets.py'
- '.config/release-validation-plan.json'
- '.github/scheduled-validations.json'
- 'scripts/test_helm_release_workflow.py'
- 'scripts/test_release_packages.py'
- 'scripts/test_release_images.py'
- 'Dockerfile*'
- 'entrypoint.sh'
- 'bootstrap/**'
- 'scripts/test_package_service_scripts.sh'
- 'scripts/test_package_versions.sh'
- 'scripts/test_docker_workflow.py'
@@ -132,12 +146,38 @@ jobs:
if: github.repository == 'rustfs/rustfs' && (github.event_name != 'pull_request' || github.event.action != 'closed')
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
actions: read
outputs:
release_changed: ${{ steps.scope.outputs.release_changed }}
steps:
- name: Checkout repository
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
fetch-depth: 0
persist-credentials: false
- name: Scope native packaging checks to release changes
id: scope
env:
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
run: |
python3 - <<'PY'
import os, re, subprocess
event, base = os.environ['GITHUB_EVENT_NAME'], os.environ.get('BASE_SHA', '')
changed = event == 'workflow_dispatch'
if event in ('push', 'pull_request'):
if not re.fullmatch(r'[0-9a-f]{40}', base) or base == '0' * 40:
changed = True
else:
paths = subprocess.check_output(['git', 'diff', '--name-only', base, os.environ['GITHUB_SHA']], text=True).splitlines()
pattern = r'^(scripts/release/|scripts/test_release_|Dockerfile|entrypoint.sh$|bootstrap/|\.dockerignore$|\.config/release-validation-plan.json$|\.github/workflows/(audit|build|docker|package|helm-package|release-[^.]+)\.yml$)'
changed = any(re.match(pattern, path) for path in paths)
with open(os.environ['GITHUB_OUTPUT'], 'a') as output:
output.write(f'release_changed={str(changed).lower()}\n')
PY
- name: Report unpinned GitHub Actions
run: ./scripts/security/check_workflow_pins.sh --enforce
@@ -150,8 +190,35 @@ jobs:
- name: Check checkouts clear their credentials
run: ./scripts/security/check_persist_credentials.sh
- name: Check preview release workflow policy
run: ./scripts/security/check_preview_release_workflow.sh
- name: Check release candidate and promotion contracts
run: python3 -m unittest discover -s scripts/release -p 'test_*.py'
- name: Prepare artifact transport rehearsal
run: |
mkdir -p "$RUNNER_TEMP/release-transport"
printf '{"purpose":"transport contract, not release evidence"}\n' > "$RUNNER_TEMP/release-transport/transport.json"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-transport-contract-${{ github.run_attempt }}
path: ${{ runner.temp }}/release-transport/transport.json
if-no-files-found: error
retention-days: 1
- name: Verify actual GitHub artifact metadata and downloaded bytes
env:
GH_TOKEN: ${{ github.token }}
run: |
python3 - <<'PY'
import os, sys
from pathlib import Path
sys.path.insert(0, 'scripts/release')
import candidate as c
run = c.github(f"repos/{c.REPOSITORY}/actions/runs/{os.environ['GITHUB_RUN_ID']}")
destination = Path(os.environ['RUNNER_TEMP']) / 'release-transport-downloaded'
c.download_artifact(run, f"release-transport-contract-{os.environ['GITHUB_RUN_ATTEMPT']}", destination)
c.require(c.read_json(destination / 'transport.json') == {'purpose': 'transport contract, not release evidence'}, 'artifact round trip changed')
PY
- name: Check tier evidence workflow isolation
run: ./scripts/security/check_tier_artifact_workflow.sh
@@ -162,6 +229,80 @@ jobs:
- name: Check package service lifecycle contract
run: ./scripts/test_package_service_scripts.sh
release-package-contract:
name: Release package contract
needs: workflow-pin-report
if: needs.workflow-pin-report.outputs.release_changed == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Install real package managers
run: |
sudo apt-get update
sudo apt-get install -y ruby ruby-dev build-essential rpm fakeroot cpio
sudo gem install fpm --version 1.17.0 --no-document
- name: Exercise accepted and substituted payloads through both recipes
run: python3 scripts/test_release_packages.py
release-image-contract:
name: Release image contract (${{ matrix.variant }})
needs: workflow-pin-report
if: needs.workflow-pin-report.outputs.release_changed == 'true'
runs-on: ubuntu-latest
timeout-minutes: 35
strategy:
fail-fast: false
matrix:
variant: [musl, gnu]
env:
RELEASE_IMAGE_VARIANT: ${{ matrix.variant }}
NO_PROXY: 127.0.0.1,localhost,::1
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Install OCI tools
run: |
echo "RELEASE_IMAGE_OUTPUT=$RUNNER_TEMP/image-contract" >> "$GITHUB_ENV"
echo 'no_proxy=127.0.0.1,localhost,::1' >> "$GITHUB_ENV"
sudo apt-get update
sudo apt-get install -y skopeo
go version
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Exercise real OCI packaging, startup, registry retry and conflicts
run: python3 scripts/test_release_images.py
- name: Exercise amd64 OCI archive scanning
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25
env:
TRIVY_PLATFORM: linux/amd64
with:
input: ${{ runner.temp }}/image-contract-scan/image-${{ matrix.variant }}-amd64.tar
format: sarif
output: ${{ runner.temp }}/image-contract/scan-${{ matrix.variant }}-amd64.sarif
exit-code: "0"
- name: Exercise arm64 OCI archive scanning
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25
env:
TRIVY_PLATFORM: linux/arm64
with:
input: ${{ runner.temp }}/image-contract-scan/image-${{ matrix.variant }}-arm64.tar
format: sarif
output: ${{ runner.temp }}/image-contract/scan-${{ matrix.variant }}-arm64.sarif
exit-code: "0"
- name: Verify scan receipts before recording the archive
run: |
python3 - <<'PY'
import os, sys
from pathlib import Path
sys.path.insert(0, 'scripts/release')
import deliver, test_candidate
deliver.image_record(test_candidate.inputs(), os.environ['RELEASE_IMAGE_VARIANT'], Path(os.environ['RELEASE_IMAGE_OUTPUT']))
PY
dependency-review:
name: Dependency Review
runs-on: ubuntu-latest
+192 -467
View File
@@ -12,51 +12,32 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# Build and Release Workflow
#
# This workflow builds RustFS binaries and automatically triggers Docker image builds.
#
# Flow:
# 1. Build binaries for multiple platforms
# 2. Upload binaries to OSS storage
# 3. Trigger docker.yml to build and push images using the uploaded binaries
#
# Platform scope:
# - Development builds come from the nightly schedule and manual dispatch and
# cover the full platform matrix; they feed the dev download channel
# (artifacts/rustfs/dev on R2, the *-main-latest packages). Tags build the
# release matrix. Main pushes do not build: at the usual merge rate most
# per-merge builds were cancelled by the next merge before publishing
# anything, and the dev channel does not need per-merge granularity.
#
# Manual Parameters:
# - publish: Publish binaries/releases for manual runs (default: false)
# - build_docker: Advisory only; manual runs do not trigger Docker publishing
# - platforms: Comma-separated platform IDs or 'all' (default: all)
# Nightly development artifacts and reusable immutable-candidate builds.
# Public release writes live exclusively in release-publish.yml. Candidate callers
# select Linux or desktop and pin release inputs before any matrix starts.
name: Build and Release
on:
push:
tags: [ "*.*.*" ]
workflow_call:
inputs:
candidate_manifest:
description: Frozen candidate inputs; candidates never publish from this workflow
required: true
type: string
platforms:
description: linux, desktop, all, or an explicit platform list
required: true
type: string
schedule:
- cron: "13 1 * * *" # Nightly 01:13 UTC
workflow_dispatch:
inputs:
publish:
description: "Publish to download channels and GitHub Releases (false keeps only workflow artifacts)"
description: "Publish the development download channel (never a release)"
required: false
default: false
type: boolean
build_docker:
# Advisory only. docker.yml triggers on workflow_run and its job-level
# condition requires the triggering event to be a tag push, so a manual
# dispatch of this workflow never produces images regardless of this
# value. Publishing remains controlled by the explicit publish input.
description: "Build and push Docker images after binary build (ignored: dispatch runs never reach docker.yml)"
required: false
default: true
type: boolean
platforms:
description: "Comma-separated targets or 'all' (e.g. linux-x86_64-musl,macos-aarch64)"
required: false
@@ -65,10 +46,11 @@ on:
permissions:
contents: read
actions: read
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'push' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'push' }}
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
@@ -84,19 +66,39 @@ jobs:
timeout-minutes: 30
outputs:
should_build: ${{ steps.check.outputs.should_build }}
should_publish: ${{ github.event_name != 'workflow_dispatch' || inputs.publish == true }}
should_publish: ${{ inputs.candidate_manifest == '' && github.ref == 'refs/heads/main' && (github.event_name == 'schedule' || inputs.publish == true) }}
build_type: ${{ steps.check.outputs.build_type }}
version: ${{ steps.check.outputs.version }}
short_sha: ${{ steps.check.outputs.short_sha }}
is_prerelease: ${{ steps.check.outputs.is_prerelease }}
candidate_id: ${{ steps.candidate.outputs.candidate_id }}
steps:
- name: Checkout repository
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
ref: ${{ inputs.candidate_manifest != '' && fromJson(inputs.candidate_manifest).source_sha || github.sha }}
- name: Validate frozen candidate
if: inputs.candidate_manifest != ''
id: candidate
env:
CANDIDATE_MANIFEST: ${{ inputs.candidate_manifest }}
run: |
python3 - <<'PYINPUT'
import json, os, sys
sys.path.insert(0, 'scripts/release')
import candidate
value = candidate.validate_inputs(json.loads(os.environ['CANDIDATE_MANIFEST']))
candidate.write_json('candidate-inputs.json', value)
candidate.emit_output('candidate_id', value['candidate_id'])
PYINPUT
python3 scripts/release/candidate.py validate-inputs --inputs candidate-inputs.json --check-checkout
- name: Determine build strategy
id: check
env:
CANDIDATE_MANIFEST: ${{ inputs.candidate_manifest }}
run: |
should_build=false
build_type="none"
@@ -107,8 +109,14 @@ jobs:
# Get short SHA for all builds
short_sha=$(git rev-parse --short HEAD)
# Candidates contain the final product version but have no release
# authority. Their only publication is immutable workflow artifacts.
if [[ -n "$CANDIDATE_MANIFEST" ]]; then
should_build=true
build_type="candidate"
version=$(python3 -c 'import json; print(json.load(open("candidate-inputs.json"))["version"])')
# Determine build type based on trigger
if [[ "${{ startsWith(github.ref, 'refs/tags/') }}" == "true" ]]; then
elif [[ "${{ startsWith(github.ref, 'refs/tags/') }}" == "true" ]]; then
# Tag push - preview, release, or prerelease
should_build=true
tag_name="${GITHUB_REF#refs/tags/}"
@@ -155,6 +163,46 @@ jobs:
echo " - Short SHA: $short_sha"
echo " - Is prerelease: $is_prerelease"
prepare-build-inputs:
needs: build-check
if: needs.build-check.outputs.should_build == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
console: ${{ steps.inputs.outputs.console }}
toolchain: ${{ steps.inputs.outputs.toolchain }}
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
ref: ${{ inputs.candidate_manifest != '' && fromJson(inputs.candidate_manifest).source_sha || github.sha }}
- name: Resolve one Console and toolchain identity
id: inputs
env:
GH_TOKEN: ${{ github.token }}
CANDIDATE_MANIFEST: ${{ inputs.candidate_manifest }}
shell: bash
run: |
set -euo pipefail
if [[ -z "$CANDIDATE_MANIFEST" ]]; then
gh api repos/rustfs/console/releases/latest > console-release.json
rustup update stable --no-self-update
fi
python3 - <<'PYINPUT'
import json, os, subprocess, sys
sys.path.insert(0, 'scripts/release')
import candidate
raw = os.environ['CANDIDATE_MANIFEST']
if raw:
frozen = candidate.validate_inputs(json.loads(raw))
console, toolchain = frozen['console'], frozen['toolchain']
else:
console = candidate.console_asset(candidate.read_json('console-release.json'))
toolchain = subprocess.check_output(['rustc', '+stable', '--version'], text=True).split()[1]
candidate.emit_output('console', json.dumps(console, separators=(',', ':')))
candidate.emit_output('toolchain', toolchain)
PYINPUT
# Build RustFS binaries
prepare-platform-matrix:
name: Prepare Platform Matrix
@@ -171,7 +219,9 @@ jobs:
env:
# via env, not interpolation: a dispatch input is free-form text and
# would otherwise be pasted into the script for bash to evaluate.
RAW_PLATFORMS: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.platforms || 'all' }}
# Daily development builds cover Linux. Desktop remains available on
# manual dispatch and is required after candidate field acceptance.
RAW_PLATFORMS: ${{ inputs.platforms || 'linux' }}
run: |
set -euo pipefail
@@ -193,10 +243,14 @@ jobs:
{"target_id":"linux-aarch64-musl","os":"ubicloud-standard-16-arm","target":"aarch64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":"","glibc":""},
{"target_id":"linux-x86_64-gnu","os":"sm-standard-4","target":"x86_64-unknown-linux-gnu","cross":true,"platform":"linux","rustflags":"","features":"pyroscope","glibc":"2.31"},
{"target_id":"linux-aarch64-gnu","os":"ubicloud-standard-16-arm","target":"aarch64-unknown-linux-gnu","cross":true,"platform":"linux","rustflags":"","features":"pyroscope","glibc":"2.31"},
{"target_id":"macos-aarch64","os":"macos-latest","target":"aarch64-apple-darwin","cross":false,"platform":"macos","rustflags":"","features":"pyroscope","glibc":""},
{"target_id":"macos-aarch64","os":"macos-latest-xlarge","target":"aarch64-apple-darwin","cross":false,"platform":"macos","rustflags":"","features":"pyroscope","glibc":""},
{"target_id":"windows-x86_64","os":"windows-latest","target":"x86_64-pc-windows-msvc","cross":false,"platform":"windows","rustflags":"","features":"","glibc":""}
]}'
if [[ "$selected" == "linux" || "$selected" == "desktop" ]]; then
selected=$(jq -r --arg group "$selected" '.include[] | select(if $group == "linux" then .platform == "linux" else .platform != "linux" end) | .target_id' <<<"$all" | paste -sd ',' -)
fi
if [[ "${selected}" == "all" ]]; then
matrix="$(jq -c . <<<"${all}")"
else
@@ -224,12 +278,13 @@ jobs:
build-rustfs:
# Acceptance workflows select artifacts by this stable platform name.
name: Build RustFS (${{ matrix.target_id }})
needs: [ build-check, prepare-platform-matrix ]
needs: [ build-check, prepare-platform-matrix, prepare-build-inputs ]
if: needs.build-check.outputs.should_build == 'true' && needs.prepare-platform-matrix.result == 'success'
runs-on: ${{ matrix.os }}
timeout-minutes: 180
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
RUSTUP_TOOLCHAIN: ${{ needs.prepare-build-inputs.outputs.toolchain }}
# Release binaries ship without dial9 telemetry and therefore do not need
# --cfg tokio_unstable. Telemetry builds opt in explicitly (see
# `make build-profiling`); crates/obs/build.rs fails the compile if the
@@ -244,11 +299,33 @@ jobs:
with:
persist-credentials: false
fetch-depth: 0
ref: ${{ inputs.candidate_manifest != '' && fromJson(inputs.candidate_manifest).source_sha || github.sha }}
- name: Ensure Python for artifact verification
if: runner.os == 'Linux'
shell: bash
run: |
if ! command -v python3 >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y python3
fi
- name: Reuse completed candidate platform
id: reuse
if: needs.build-check.outputs.build_type == 'candidate'
env:
GH_TOKEN: ${{ github.token }}
CANDIDATE_MANIFEST: ${{ inputs.candidate_manifest }}
TARGET: ${{ matrix.target_id }}
shell: bash
run: |
python3 scripts/release/pipeline.py reuse-platform --target "$TARGET"
- name: Setup Rust environment
if: steps.reuse.outputs.reused != 'true'
uses: ./.github/actions/setup
with:
rust-version: stable
rust-version: ${{ needs.prepare-build-inputs.outputs.toolchain }}
target: ${{ matrix.target }}
# rust-cache keys include OS and architecture but not the runner image.
# The Ubicloud aarch64 legs therefore use their own suffix so they
@@ -268,128 +345,35 @@ jobs:
install-test-tools: 'false'
- name: Download static console assets
if: steps.reuse.outputs.reused != 'true'
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ github.token }}
CONSOLE_IDENTITY: ${{ needs.prepare-build-inputs.outputs.console }}
run: |
mkdir -p ./rustfs/static
verify_sha256() {
local expected="$1"
local file="$2"
local actual=""
local actual_line=""
if command -v sha256sum >/dev/null 2>&1; then
actual_line=$(sha256sum -- "$file") || return 1
elif command -v shasum >/dev/null 2>&1; then
actual_line=$(shasum -a 256 -- "$file") || return 1
else
echo "No SHA-256 verification tool found" >&2
return 1
fi
actual="${actual_line%%[[:space:]]*}"
if [ "$actual" = "$expected" ]; then
echo "SHA256 verified OK: $actual"
return 0
else
echo "SHA256 mismatch: expected=$expected actual=$actual" >&2
return 1
fi
}
download_console_assets() {
local curl_bin="curl"
local python_bin="python3"
local console_api="https://api.github.com/repos/rustfs/console/releases/latest"
local console_json="console-release.json"
local console_url
local console_sha256
local curl_auth_args=()
console_tag=""
if [[ "${{ matrix.platform }}" == "windows" ]]; then
curl_bin="curl.exe"
else
chmod +w ./rustfs/static/LICENSE || true
fi
if ! command -v "$python_bin" >/dev/null 2>&1; then
python_bin="python"
fi
if ! command -v "$python_bin" >/dev/null 2>&1; then
echo "No Python interpreter found for release metadata parsing" >&2
return 1
fi
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
curl_auth_args=(-H "Authorization: Bearer ${GITHUB_TOKEN}" -H "X-GitHub-Api-Version: 2022-11-28")
fi
"$curl_bin" "${curl_auth_args[@]}" --fail -L "$console_api" \
-o "$console_json" --retry 3 --retry-delay 5 --max-time 300 || return 1
read -r console_tag console_url console_sha256 < <("$python_bin" - "$console_json" <<'PY'
import json
import re
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
release = json.load(handle)
tag = release.get("tag_name", "") or "unknown"
for asset in release.get("assets", []):
name = asset.get("name", "")
digest = asset.get("digest", "")
url = asset.get("browser_download_url", "")
if name.startswith("rustfs-console-") and name.endswith(".zip") and digest.startswith("sha256:") and url:
sha256 = digest.split(":", 1)[1]
if not re.fullmatch(r"[0-9a-fA-F]{64}", sha256):
raise SystemExit(f"console zip asset has invalid sha256 digest: {sha256}")
sys.stdout.buffer.write(f"{tag} {url} {sha256}\n".encode("utf-8"))
break
else:
raise SystemExit("no console zip asset with sha256 digest found")
PY
) || return 1
if [[ -z "$console_url" || -z "$console_sha256" ]]; then
echo "Console release metadata is missing URL or SHA-256 digest" >&2
return 1
fi
echo "Console release: ${console_tag}"
echo "Downloading console asset: ${console_url}"
"$curl_bin" --fail -L "$console_url" -o console.zip --retry 3 --retry-delay 5 --max-time 300 || return 1
verify_sha256 "$console_sha256" console.zip || return 2
unzip -o console.zip -d ./rustfs/static || return 2
}
if download_console_assets; then
rm -f console.zip console-release.json
else
status=$?
rm -f console.zip console-release.json
if [[ "$status" -eq 2 ]]; then
echo "Console asset integrity verification failed" >&2
fi
echo "Failed to download verified console assets" >&2
exit 1
fi
if [[ ! -s ./rustfs/static/index.html ]]; then
echo "Console asset archive is missing static/index.html" >&2
exit 1
fi
asset_count=$(find ./rustfs/static -type f | wc -l | tr -d '[:space:]')
echo "Console assets ready: version=${console_tag:-unknown}, ${asset_count} files extracted to ./rustfs/static"
python3 - <<'PYCONSOLE'
import json, os, pathlib, subprocess, zipfile
value = json.loads(os.environ['CONSOLE_IDENTITY'])
subprocess.run(['python3', '.github/actions/setup/download_release_asset.py',
'--repository', 'rustfs/console', '--tag', value['tag'], '--asset', value['name'],
'--sha256', value['sha256'], '--output', 'console.zip'], check=True)
destination = pathlib.Path('rustfs/static')
destination.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile('console.zip') as archive:
for member in archive.infolist():
path = pathlib.PurePosixPath(member.filename)
if path.is_absolute() or '..' in path.parts or '\\' in member.filename:
raise SystemExit('unsafe Console archive member')
archive.extractall(destination)
if not (destination / 'index.html').is_file():
raise SystemExit('Console archive has no index.html')
PYCONSOLE
- name: Build RustFS
if: steps.reuse.outputs.reused != 'true'
shell: bash
env:
RUSTFS_BUILD_VERSION: ${{ needs.build-check.outputs.build_type == 'candidate' && needs.build-check.outputs.version || '' }}
BUILD_TARGET: ${{ matrix.target }}
BUILD_OS: ${{ matrix.platform }}-${{ startsWith(matrix.target, 'aarch64-') && 'aarch64' || 'x86_64' }}
run: |
@@ -440,7 +424,7 @@ jobs:
fi
- name: Verify macOS binary has no dynamic liblzma dependency
if: matrix.platform == 'macos'
if: steps.reuse.outputs.reused != 'true' && (matrix.platform == 'macos')
shell: bash
run: |
binary="target/${{ matrix.target }}/release/rustfs"
@@ -451,7 +435,7 @@ jobs:
"$binary" --version
- name: Generate the CPU symbol catalog
if: endsWith(matrix.target, '-linux-gnu') && matrix.features == 'pyroscope'
if: steps.reuse.outputs.reused != 'true' && (endsWith(matrix.target, '-linux-gnu') && matrix.features == 'pyroscope')
shell: bash
run: |
cargo metadata --locked --format-version 1 --filter-platform '${{ matrix.target }}' \
@@ -468,6 +452,7 @@ jobs:
"$release_dir/rustfs.cpu-symbol-catalog.json" > "$release_dir/rustfs.cpu-symbol-catalog.sha256"
- name: Create release package
if: steps.reuse.outputs.reused != 'true'
id: package
shell: bash
run: |
@@ -668,7 +653,7 @@ jobs:
echo "📊 Version: ${VERSION}"
- name: Verify packaged Linux binary
if: matrix.platform == 'linux'
if: steps.reuse.outputs.reused != 'true' && (matrix.platform == 'linux')
shell: bash
run: |
set -euo pipefail
@@ -697,7 +682,7 @@ jobs:
"$binary_dir/rustfs" --version
- name: Verify packaged console
if: matrix.target == 'x86_64-unknown-linux-gnu' || matrix.target == 'aarch64-unknown-linux-gnu'
if: steps.reuse.outputs.reused != 'true' && (matrix.target == 'x86_64-unknown-linux-gnu' || matrix.target == 'aarch64-unknown-linux-gnu')
shell: bash
run: |
set -euo pipefail
@@ -750,15 +735,49 @@ jobs:
cat "$log_file"
exit 1
- name: Verify native candidate and retain receipts
if: needs.build-check.outputs.build_type == 'candidate' && steps.reuse.outputs.reused != 'true'
env:
CANDIDATE_MANIFEST: ${{ inputs.candidate_manifest }}
TARGET: ${{ matrix.target_id }}
ARCHIVE: ${{ steps.package.outputs.package_file }}
shell: bash
run: |
python3 -c 'import os; from pathlib import Path; Path("inputs.json").write_text(os.environ["CANDIDATE_MANIFEST"])'
python3 scripts/release/verify.py native --inputs inputs.json --archive "$ARCHIVE" --target "$TARGET" --output .
- name: Record candidate artifact identity
if: steps.reuse.outputs.reused != 'true' && (needs.build-check.outputs.build_type == 'candidate')
env:
CANDIDATE_MANIFEST: ${{ inputs.candidate_manifest }}
TARGET_ID: ${{ matrix.target_id }}
PACKAGE_FILE: ${{ steps.package.outputs.package_file }}
shell: bash
run: |
python3 - <<'PYRECORD'
import json, os, sys
sys.path.insert(0, 'scripts/release')
import candidate
value = candidate.validate_inputs(json.loads(os.environ['CANDIDATE_MANIFEST']))
record = candidate.archive_record(os.environ['PACKAGE_FILE'], os.environ['TARGET_ID'], value)
candidate.write_json('record-' + os.environ['TARGET_ID'] + '.json', record)
PYRECORD
- name: Upload to GitHub artifacts
if: steps.reuse.outputs.reused != 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ steps.package.outputs.package_name }}
path: "rustfs-*.zip"
retention-days: ${{ startsWith(github.ref, 'refs/tags/') && 30 || 7 }}
name: ${{ needs.build-check.outputs.build_type == 'candidate' && format('candidate-{0}-{1}', needs.build-check.outputs.candidate_id, matrix.target_id) || steps.package.outputs.package_name }}
path: |
rustfs-*.zip
record-${{ matrix.target_id }}.json
identity-${{ matrix.target_id }}*
startup-${{ matrix.target_id }}.log
if-no-files-found: error
retention-days: ${{ (startsWith(github.ref, 'refs/tags/') || needs.build-check.outputs.build_type == 'candidate') && 30 || 7 }}
- name: Upload to Cloudflare R2
if: needs.build-check.outputs.should_publish == 'true' && 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')
if: steps.reuse.outputs.reused != 'true' && (needs.build-check.outputs.should_publish == 'true' && 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'))
env:
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
@@ -814,13 +833,18 @@ jobs:
export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY"
export AWS_DEFAULT_REGION="auto"
# Determine upload path based on build type
# Determine upload path based on build type. Release and prerelease
# builds only stage their bytes here: the official
# artifacts/rustfs/release/ path is written solely by the
# certification-gated promote-release-artifacts job, from these same
# staged objects. Development builds keep the uncertified dev channel
# and never touch official or latest channels.
if [[ "$BUILD_TYPE" == "development" ]]; then
R2_PATH="s3://${R2_BUCKET}/artifacts/rustfs/dev/"
echo "📤 Uploading development build to R2 dev directory"
else
R2_PATH="s3://${R2_BUCKET}/artifacts/rustfs/release/"
echo "📤 Uploading release build to R2 release directory"
R2_PATH="s3://${R2_BUCKET}/artifacts/rustfs/staging/${GITHUB_RUN_ID}/${GITHUB_RUN_ATTEMPT}/"
echo "📤 Staging release build for certification-gated promotion"
fi
# Upload all rustfs zip files to R2 using S3-compatible API
@@ -835,6 +859,7 @@ jobs:
echo "✅ Upload completed successfully"
# Build summary
build-summary:
name: Build Summary
@@ -869,309 +894,9 @@ jobs:
echo "This run does not trigger Docker publishing."
fi
# Create GitHub Release for every valid release tag, including previews
create-release:
name: Create GitHub Release
needs: [ build-check, build-rustfs ]
if: needs.build-check.outputs.should_publish == 'true' && 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 # gh CLI required; the sm-standard-2 fleet does not ship it
timeout-minutes: 30
permissions:
contents: write
outputs:
release_id: ${{ steps.create.outputs.release_id }}
release_url: ${{ steps.create.outputs.release_url }}
steps:
- name: Checkout repository
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
fetch-depth: 0
- name: Create GitHub Release
id: create
env:
GH_TOKEN: ${{ github.token }}
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 }}"
TARGET_COMMITISH=$(git rev-parse --verify "refs/tags/${TAG}^{commit}")
# Determine release type for title
if [[ "$BUILD_TYPE" == "preview" ]]; then
RELEASE_TYPE="preview"
elif [[ "$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
else
RELEASE_TYPE="release"
fi
# Create release title
if [[ "$IS_PRERELEASE" == "true" ]]; then
TITLE="RustFS $VERSION (${RELEASE_TYPE})"
else
TITLE="RustFS $VERSION"
fi
./scripts/release/create_or_update_release.sh \
"$TAG" \
"$TARGET_COMMITISH" \
"$TITLE" \
"$IS_PRERELEASE"
# Prepare and upload release assets
upload-release-assets:
name: Upload Release Assets
needs: [ build-check, build-rustfs, create-release ]
if: needs.build-check.outputs.should_publish == 'true' && 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 # gh CLI required; the sm-standard-2 fleet does not ship it
timeout-minutes: 30
permissions:
contents: write
actions: read
steps:
- name: Checkout repository
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Download all build artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: ./artifacts
pattern: rustfs-*
merge-multiple: true
- name: Prepare release assets
id: prepare
shell: bash
run: |
VERSION="${{ needs.build-check.outputs.version }}"
TAG="${{ needs.build-check.outputs.version }}"
mkdir -p ./release-assets
# Copy and verify artifacts (including latest files created during build)
ASSETS_COUNT=0
for file in ./artifacts/*.zip; do
if [[ -f "$file" ]]; then
cp "$file" ./release-assets/
ASSETS_COUNT=$((ASSETS_COUNT + 1))
fi
done
if [[ $ASSETS_COUNT -eq 0 ]]; then
echo "❌ No artifacts found!"
exit 1
fi
cd ./release-assets
# Generate checksums for all files (including latest versions)
if compgen -G "*.zip" >/dev/null; then
sha256sum -- *.zip > SHA256SUMS
sha512sum -- *.zip > SHA512SUMS
fi
cd ..
python3 scripts/security/generate_release_supply_chain_assets.py \
--asset-dir ./release-assets \
--version "$VERSION" \
--tag "$TAG" \
--build-type "${{ needs.build-check.outputs.build_type }}" \
--repository "${{ github.repository }}" \
--ref "${{ github.ref }}" \
--sha "${{ github.sha }}" \
--run-id "${{ github.run_id }}" \
--run-attempt "${{ github.run_attempt }}"
cd ./release-assets
echo "📦 Prepared assets:"
ls -la
echo "🔢 Total asset count: $ASSETS_COUNT"
- name: Upload to GitHub Release
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
TAG="${{ needs.build-check.outputs.version }}"
cd ./release-assets
# Upload all files
for file in *; do
if [[ -f "$file" ]]; then
echo "📤 Uploading $file..."
gh release upload "$TAG" "$file" --clobber
fi
done
echo "✅ All assets uploaded successfully"
# Update latest.json for every release tag (stable and prerelease): the
# project currently ships prerelease tags only, so gating this to stable
# left the version pointer permanently stale. release_type records whether
# the pointed-to version is a prerelease.
update-latest-version:
name: Update Latest Version
needs: [ build-check, publish-release ]
if: needs.build-check.outputs.should_publish == 'true' && startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Update latest.json
env:
OSS_ACCESS_KEY_ID: ${{ secrets.ALICLOUDOSS_KEY_ID }}
OSS_ACCESS_KEY_SECRET: ${{ secrets.ALICLOUDOSS_KEY_SECRET }}
OSS_REGION: cn-beijing
OSS_ENDPOINT: https://oss-cn-beijing.aliyuncs.com
shell: bash
run: |
if [[ -z "$OSS_ACCESS_KEY_ID" ]]; then
echo "⚠️ OSS credentials not available, skipping latest.json update"
exit 0
fi
VERSION="${{ needs.build-check.outputs.version }}"
TAG="${{ needs.build-check.outputs.version }}"
if [[ "${{ needs.build-check.outputs.build_type }}" == "prerelease" ]]; then
RELEASE_TYPE="prerelease"
else
RELEASE_TYPE="stable"
fi
# Install ossutil
OSSUTIL_VERSION="2.1.1"
OSSUTIL_ZIP="ossutil-${OSSUTIL_VERSION}-linux-amd64.zip"
OSSUTIL_DIR="ossutil-${OSSUTIL_VERSION}-linux-amd64"
OSSUTIL_SHA256="60f3a808cbbdfd4b5269b8f967c6a66f564c9dacff52d93a5d21a588976ab5a2"
curl --fail -L -o "$OSSUTIL_ZIP" "https://gosspublic.alicdn.com/ossutil/v2/${OSSUTIL_VERSION}/${OSSUTIL_ZIP}"
printf '%s %s\n' "$OSSUTIL_SHA256" "$OSSUTIL_ZIP" | sha256sum -c -
unzip "$OSSUTIL_ZIP"
OSSUTIL_BIN="${RUNNER_TEMP}/ossutil"
mv "${OSSUTIL_DIR}/ossutil" "$OSSUTIL_BIN"
rm -rf "$OSSUTIL_DIR" "$OSSUTIL_ZIP"
chmod +x "$OSSUTIL_BIN"
# Create latest.json
cat > latest.json << EOF
{
"version": "${VERSION}",
"tag": "${TAG}",
"release_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"release_type": "${RELEASE_TYPE}",
"download_url": "https://github.com/${{ github.repository }}/releases/tag/${TAG}"
}
EOF
# Upload to OSS
"$OSSUTIL_BIN" cp latest.json oss://rustfs-version/latest.json --force
echo "✅ Updated latest.json for ${RELEASE_TYPE} release $VERSION"
# Publish release (remove draft status)
publish-release:
name: Publish Release
needs: [ build-check, create-release, upload-release-assets ]
if: needs.build-check.outputs.should_publish == 'true' && 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 # gh CLI required; the sm-standard-2 fleet does not ship it
timeout-minutes: 30
permissions:
contents: write
steps:
- name: Publish release
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
TAG="${{ needs.build-check.outputs.version }}"
BUILD_TYPE="${{ needs.build-check.outputs.build_type }}"
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
# 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
gh api --method PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" \
-F draft=false \
-F prerelease=true \
-f make_latest=false >/dev/null
fi
echo "🎉 Released $TAG successfully!"
echo "📄 Release URL: ${{ needs.create-release.outputs.release_url }}"
# Remove the internal preview releases once the deliverable release is live.
# Only the Releases are deleted; the -preview.N tags stay so the validated
# commit remains traceable.
cleanup-preview-releases:
name: Cleanup Preview Releases
needs: [ build-check, publish-release ]
if: needs.build-check.outputs.should_publish == 'true' && startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease')
runs-on: ubuntu-latest # gh CLI required; the sm-standard-2 fleet does not ship it
timeout-minutes: 30
permissions:
contents: write
steps:
- name: Delete preview releases for this target
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
TAG="${{ needs.build-check.outputs.version }}"
RELEASES_JSON="${RUNNER_TEMP}/releases.json"
# Fetch before filtering: a failed listing must abort here instead of
# looking like "nothing to clean up".
gh api --paginate "repos/${GITHUB_REPOSITORY}/releases?per_page=100" > "$RELEASES_JSON"
# Match only <target>-preview.<digits>. String operations, not a
# regex over the tag, so dots in the version cannot widen the match.
DELETED=0
while IFS= read -r preview_tag; do
[[ -n "$preview_tag" ]] || continue
echo "🧹 Deleting preview release $preview_tag (tag kept)"
gh release delete "$preview_tag" --repo "${GITHUB_REPOSITORY}" --yes
DELETED=$((DELETED + 1))
done < <(
jq -r --arg tag "$TAG" '
.[]
| select(.tag_name | startswith($tag + "-preview."))
| select(.tag_name | ltrimstr($tag + "-preview.") | test("^[0-9]+$"))
| .tag_name
' "$RELEASES_JSON"
)
if [[ "$DELETED" -eq 0 ]]; then
echo "ℹ️ No preview releases to clean up for $TAG"
else
echo "✅ Removed $DELETED preview release(s) for $TAG"
fi
alert-on-failure:
name: Alert on scheduled failure
needs: [build-check, prepare-platform-matrix, build-rustfs, build-summary]
needs: [build-check, prepare-build-inputs, prepare-platform-matrix, build-rustfs, build-summary]
if: >-
always() && github.event_name == 'schedule' &&
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
+71 -571
View File
@@ -1,599 +1,99 @@
# Copyright 2024 RustFS Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Docker Images Workflow
#
# This workflow builds Docker images using pre-built binaries from the build workflow.
#
# Trigger Types:
# 1. workflow_run: Automatically triggered when "Build and Release" workflow completes
# 2. workflow_dispatch: Manual trigger for standalone Docker builds
#
# Key Features:
# - Only triggers when Linux builds (x86_64 + aarch64) are successful
# - Independent of macOS/Windows build status
# - Uses workflow_run event for precise control
# - Only builds Docker images for releases and prereleases (development builds are skipped)
name: Docker Images
# Permissions needed for workflow_run event and Docker registry access
# Copyright 2026 RustFS Team
# SPDX-License-Identifier: Apache-2.0
name: Build candidate images
on:
workflow_call:
inputs:
candidate_manifest:
type: string
required: true
permissions:
contents: read
packages: write
on:
# Automatically triggered when build workflow completes
workflow_run:
workflows: [ "Build and Release" ]
types: [ completed ]
# Manual trigger with same parameters for consistency
workflow_dispatch:
inputs:
push_images:
description: "Push images to registries"
required: false
default: true
type: boolean
version:
description: "Version to build (latest, v1.0.0, v1.0.0-alpha1, or v1.0.1-preview.1)"
required: false
default: "latest"
type: string
force_rebuild:
description: "Force rebuild even if binary exists (useful for testing)"
required: false
default: false
type: boolean
env:
CONCLUSION: ${{ github.event.workflow_run.conclusion }}
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
TRIGGERING_EVENT: ${{ github.event.workflow_run.event }}
DOCKERHUB_USERNAME: rustfs
CARGO_TERM_COLOR: always
REGISTRY_DOCKERHUB: rustfs/rustfs
REGISTRY_GHCR: ghcr.io/${{ github.repository }}
REGISTRY_QUAY: quay.io/rustfs/rustfs
DOCKER_PLATFORMS: linux/amd64,linux/arm64
actions: read
jobs:
# Check if we should build Docker images.
# Short-circuit at job level so per-merge development builds (workflow_run
# from a push to main) skip the whole run without spawning a checkout job:
# images are only published for tag builds — build.yml push triggers cover
# only main and tags, so a non-main head_branch is a tag name — and for
# manual dispatch.
build-check:
name: Docker Build Check
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')
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
should_build: ${{ steps.check.outputs.should_build }}
should_push: ${{ steps.check.outputs.should_push }}
build_type: ${{ steps.check.outputs.build_type }}
version: ${{ steps.check.outputs.version }}
short_sha: ${{ steps.check.outputs.short_sha }}
is_prerelease: ${{ steps.check.outputs.is_prerelease }}
create_latest: ${{ steps.check.outputs.create_latest }}
source_ref: ${{ steps.check.outputs.source_ref }}
steps:
- name: Checkout repository
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
# For workflow_run events, checkout the specific commit that triggered the workflow
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- name: Check build conditions
id: check
env:
# dispatch inputs via env, not `${{ }}` interpolation: they are
# free-form strings and would otherwise be evaluated by bash.
INPUT_VERSION: ${{ github.event.inputs.version }}
INPUT_PUSH_IMAGES: ${{ github.event.inputs.push_images }}
INPUT_FORCE_REBUILD: ${{ github.event.inputs.force_rebuild }}
run: |
should_build=false
should_push=false
build_type="none"
version=""
short_sha=""
is_prerelease=false
create_latest=false
source_ref="$GITHUB_SHA"
# Pre-GA policy: until the first stable (vX.Y.Z) tag exists, every
# prerelease (alpha/beta/rc) also moves `latest`, so users pulling
# `latest` get the newest test build. Once a stable tag is published
# this returns false and `latest` follows stable releases only.
prerelease_moves_latest() {
local stable_tags
stable_tags=$(git ls-remote --tags --refs origin 2>/dev/null \
| awk '{print $2}' \
| grep -E '^refs/tags/v?[0-9]+\.[0-9]+\.[0-9]+$' || true)
if [[ -z "$stable_tags" ]]; then
return 0
fi
echo "ℹ️ Stable release tag(s) already exist; prereleases no longer update latest"
return 1
}
if [[ "${{ github.event_name }}" == "workflow_run" ]]; then
# Triggered by build workflow completion
echo "🔗 Triggered by build workflow completion"
# Check if the triggering workflow was successful
# If the workflow succeeded, it means ALL builds (including Linux x86_64 and aarch64) succeeded
if [[ "$CONCLUSION" == "success" ]]; then
echo "✅ Build workflow succeeded, all builds including Linux are successful"
should_build=true
should_push=true
else
echo "❌ Build workflow failed (conclusion: $CONCLUSION), skipping Docker build"
should_build=false
fi
# Extract version info from commit message or use commit SHA
# Use Git to generate consistent short SHA (ensures uniqueness like build.yml)
short_sha=$(git rev-parse --short "$HEAD_SHA")
source_ref="$HEAD_SHA"
# Determine build type based on triggering workflow event and ref
triggering_event="$TRIGGERING_EVENT"
head_branch="$HEAD_BRANCH"
echo "🔍 Analyzing triggering workflow:"
echo " 📋 Event: $triggering_event"
echo " 🌿 Head branch: $head_branch"
echo " 📎 Head SHA: $HEAD_SHA"
# Check if this was triggered by a tag push
if [[ "$triggering_event" == "push" ]]; then
# For tag pushes, head_branch will be like "refs/tags/v1.0.0" or just "v1.0.0"
if [[ "$head_branch" == refs/tags/* ]]; then
# Extract tag name from refs/tags/TAG_NAME
tag_name="${head_branch#refs/tags/}"
version="$tag_name"
elif [[ "$head_branch" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+ ]]; then
# Direct tag name like "v1.0.0" or "1.0.0-alpha.1"
version="$head_branch"
elif [[ "$head_branch" == "main" ]]; then
# Regular branch push to main
build_type="development"
version="dev-${short_sha}"
should_build=false
echo "⏭️ Skipping Docker build for development version (main branch push)"
else
# Other branch push
build_type="development"
version="dev-${short_sha}"
should_build=false
echo "⏭️ Skipping Docker build for development version (branch: $head_branch)"
fi
# If we extracted a version (tag), determine release type
if [[ -n "$version" ]] && [[ "$version" != "dev-${short_sha}" ]]; then
# Remove 'v' prefix if present for consistent version format
if [[ "$version" == v* ]]; then
version="${version#v}"
fi
if [[ "$version" =~ -preview\.[0-9]+$ ]]; then
build_type="preview"
is_prerelease=true
echo "🔍 Building Docker image for preview: $version (version tags only)"
elif [[ "$version" == *"-preview"* ]]; then
echo "❌ Invalid preview tag: $version (expected suffix: -preview.<number>)" >&2
exit 1
elif [[ "$version" == *"alpha"* ]] || [[ "$version" == *"beta"* ]] || [[ "$version" == *"rc"* ]]; then
build_type="prerelease"
is_prerelease=true
# Pre-GA policy: prereleases update latest until the first stable tag exists.
if prerelease_moves_latest; then
create_latest=true
echo "🧪 Building Docker image for prerelease: $version (creating latest tag)"
else
echo "🧪 Building Docker image for prerelease: $version"
fi
else
build_type="release"
create_latest=true
echo "🚀 Building Docker image for release: $version"
fi
fi
else
# Non-push events
build_type="development"
version="dev-${short_sha}"
should_build=false
echo "⏭️ Skipping Docker build for development version (event: $triggering_event)"
fi
echo "🔄 Build triggered by workflow_run:"
echo " 📋 Conclusion: $CONCLUSION"
echo " 🌿 Branch: $HEAD_BRANCH"
echo " 📎 SHA: $HEAD_SHA"
echo " 🎯 Event: $TRIGGERING_EVENT"
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
# Manual trigger
input_version="$INPUT_VERSION"
version="${input_version}"
should_push="$INPUT_PUSH_IMAGES"
should_build=true
# Get short SHA
short_sha=$(git rev-parse --short HEAD)
echo "🎯 Manual Docker build triggered:"
echo " 📋 Requested version: $input_version"
echo " 🔧 Force rebuild: $INPUT_FORCE_REBUILD"
echo " 🚀 Push images: $should_push"
case "$input_version" in
"latest")
build_type="release"
create_latest=true
echo "🚀 Building with latest stable release version"
;;
*-preview*)
if [[ ! "$input_version" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?-preview\.[0-9]+$ ]]; then
echo "❌ Invalid preview version: $input_version" >&2
exit 1
fi
build_type="preview"
is_prerelease=true
version="${input_version#v}"
echo "🔍 Building with preview version: $version (version tags only)"
;;
# Prerelease versions (must match first, more specific)
v*alpha*|v*beta*|v*rc*|*alpha*|*beta*|*rc*)
build_type="prerelease"
is_prerelease=true
# Pre-GA policy: prereleases update latest until the first stable tag exists.
if prerelease_moves_latest; then
create_latest=true
echo "🧪 Building with prerelease version: $input_version (creating latest tag)"
else
echo "🧪 Building with prerelease version: $input_version"
fi
;;
# Release versions (match after prereleases, more general)
v[0-9]*|[0-9]*.*.*)
build_type="release"
create_latest=true
echo "📦 Building with specific release version: $input_version"
;;
*)
# Invalid version for Docker build
should_build=false
echo "❌ Invalid version for Docker build: $input_version"
echo "⚠️ Only release versions (latest, v1.0.0, 1.0.0) and prereleases (v1.0.0-alpha1, 1.0.0-beta2) are supported"
;;
esac
if [[ "$should_build" == true && "$input_version" != "latest" ]]; then
tag_ref="refs/tags/$input_version"
if ! git ls-remote --exit-code origin "$tag_ref" >/dev/null 2>&1; then
if [[ "$input_version" == v* ]]; then
tag_ref="refs/tags/${input_version#v}"
else
tag_ref="refs/tags/v$input_version"
fi
fi
if ! git ls-remote --exit-code origin "$tag_ref" >/dev/null 2>&1; then
echo "❌ Release tag not found for Docker build: $input_version"
exit 1
fi
source_ref="$tag_ref"
fi
fi
{
echo "should_build=$should_build"
echo "should_push=$should_push"
echo "build_type=$build_type"
echo "version=$version"
echo "short_sha=$short_sha"
echo "is_prerelease=$is_prerelease"
echo "create_latest=$create_latest"
echo "source_ref=$source_ref"
} >> "$GITHUB_OUTPUT"
echo "🐳 Docker Build Summary:"
echo " - Should build: $should_build"
echo " - Should push: $should_push"
echo " - Build type: $build_type"
echo " - Version: $version"
echo " - Short SHA: $short_sha"
echo " - Is prerelease: $is_prerelease"
echo " - Create latest: $create_latest"
echo " - Source ref: $source_ref"
# Build multi-arch Docker images
# Strategy: Build images using pre-built binaries from dl.rustfs.com
# Supports both release and dev channel binaries based on build context
# Only runs when should_build is true (which includes workflow success check)
build-docker:
name: Build Docker Images
needs: build-check
if: needs.build-check.outputs.should_build == 'true'
runs-on: ubuntu-latest
timeout-minutes: 60
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
include:
- variant: musl
file: Dockerfile
suffix: ""
- variant: glibc
- variant: gnu
file: Dockerfile.glibc
suffix: "-glibc"
env:
CANDIDATE_MANIFEST: ${{ inputs.candidate_manifest }}
VARIANT: ${{ matrix.variant }}
steps:
- name: Checkout repository
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
ref: ${{ fromJson(inputs.candidate_manifest).source_sha }}
persist-credentials: false
ref: ${{ needs.build-check.outputs.source_ref }}
- name: Login to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ghcr.io
username: ${{ secrets.GHCR_USERNAME }}
password: ${{ secrets.GHCR_PASSWORD }}
- name: Login to Quay.io
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_PASSWORD }}
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Extract metadata and generate tags
id: meta
- name: Reuse completed image
id: reuse
env:
GH_TOKEN: ${{ github.token }}
run: python3 scripts/release/deliver.py reuse --kind image --target "$VARIANT"
- name: Install OCI tools
if: steps.reuse.outputs.reused != 'true'
run: |
BUILD_TYPE="${{ needs.build-check.outputs.build_type }}"
VERSION="${{ needs.build-check.outputs.version }}"
CREATE_LATEST="${{ needs.build-check.outputs.create_latest }}"
VARIANT_SUFFIX="${{ matrix.suffix }}"
# Convert version format for Dockerfile compatibility. The former
# DOCKER_CHANNEL was "release" down every branch and was passed as a
# build-arg no Dockerfile declares, so it is gone.
case "$VERSION" in
"latest")
DOCKER_RELEASE="latest"
;;
v*)
# For versioned releases (v1.0.0), remove 'v' prefix for Dockerfile
DOCKER_RELEASE="${VERSION#v}"
;;
*)
# For other versions, pass as-is
DOCKER_RELEASE="${VERSION}"
;;
esac
echo "docker_release=$DOCKER_RELEASE" >> "$GITHUB_OUTPUT"
echo "🐳 Docker build parameters:"
echo " - Original version: $VERSION"
echo " - Docker RELEASE: $DOCKER_RELEASE"
# Generate tags based on build type
# Only support release and prerelease builds (no development builds)
TAG_BASE="${VERSION}${VARIANT_SUFFIX}"
TAGS="${{ env.REGISTRY_DOCKERHUB }}:$TAG_BASE,${{ env.REGISTRY_GHCR }}:$TAG_BASE,${{ env.REGISTRY_QUAY }}:$TAG_BASE"
# Add latest when requested (stable releases, and prereleases before GA)
if [[ "$CREATE_LATEST" == "true" ]]; then
TAGS="$TAGS,${{ env.REGISTRY_DOCKERHUB }}:latest${VARIANT_SUFFIX},${{ env.REGISTRY_GHCR }}:latest${VARIANT_SUFFIX},${{ env.REGISTRY_QUAY }}:latest${VARIANT_SUFFIX}"
fi
# Always add the channel tag for prereleases, independent of latest
if [[ "$BUILD_TYPE" == "prerelease" ]]; then
# Prerelease channel tags (alpha, beta, rc)
if [[ "$VERSION" == *"alpha"* ]]; then
CHANNEL="alpha"
elif [[ "$VERSION" == *"beta"* ]]; then
CHANNEL="beta"
elif [[ "$VERSION" == *"rc"* ]]; then
CHANNEL="rc"
fi
if [[ -n "$CHANNEL" ]]; then
TAGS="$TAGS,${{ env.REGISTRY_DOCKERHUB }}:${CHANNEL}${VARIANT_SUFFIX},${{ env.REGISTRY_GHCR }}:${CHANNEL}${VARIANT_SUFFIX},${{ env.REGISTRY_QUAY }}:${CHANNEL}${VARIANT_SUFFIX}"
fi
fi
# Output tags
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
# Generate labels
LABELS="org.opencontainers.image.title=RustFS"
LABELS="$LABELS,org.opencontainers.image.description=RustFS distributed object storage system"
LABELS="$LABELS,org.opencontainers.image.version=$VERSION"
SOURCE_REVISION="$(git rev-parse HEAD)"
LABELS="$LABELS,org.opencontainers.image.revision=$SOURCE_REVISION"
LABELS="$LABELS,org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}"
LABELS="$LABELS,org.opencontainers.image.created=$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
LABELS="$LABELS,org.opencontainers.image.build-type=$BUILD_TYPE"
echo "labels=$LABELS" >> "$GITHUB_OUTPUT"
echo "🐳 Generated Docker tags:"
echo "$TAGS" | tr ',' '\n' | sed 's/^/ - /'
echo "📋 Build type: $BUILD_TYPE"
echo "🔖 Version: $VERSION"
- name: Build and push Docker image
sudo apt-get update
sudo apt-get install -y skopeo
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
if: steps.reuse.outputs.reused != 'true'
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
if: steps.reuse.outputs.reused != 'true'
- name: Supply accepted binaries and pinned CLI
if: steps.reuse.outputs.reused != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: python3 scripts/release/deliver.py image-prepare --target "$VARIANT"
- name: Build OCI archive without registry writes
if: steps.reuse.outputs.reused != 'true'
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
file: ${{ matrix.file }}
platforms: ${{ env.DOCKER_PLATFORMS }}
push: ${{ needs.build-check.outputs.should_push == 'true' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# No layer cache. This build compiles nothing — it downloads a
# release zip and runs apk/apt — so the cache could only save the
# minute or two those take, while creating a correctness problem: with
# RELEASE=latest the binary URL is resolved by curl *inside* a RUN
# layer, and the layer key does not include what that resolved to. A
# rebuild at the same RELEASE value (dispatch with version=latest, or
# a re-run of the same version) would hit the old layer and ship the
# previous release's binary. mode=max also consumed the same 10GB
# Actions cache quota the Rust lanes are fighting over.
#
# Only RELEASE is passed: it is the sole build-arg the Dockerfiles
# declare besides TARGETARCH. BUILDTIME, VERSION, BUILD_TYPE, REVISION
# and CHANNEL were never read by any stage (and BUILDTIME's $(date ...)
# was a literal here, not a shell substitution). BUILD_DATE and VCS_REF
# are declared by the Dockerfiles but deliberately left unset —
# supplying them would change the published image labels.
platforms: linux/amd64,linux/arm64
push: false
provenance: false
outputs: type=oci,dest=delivery/image-${{ matrix.variant }}.tar
build-args: |
RELEASE=${{ steps.meta.outputs.docker_release }}
provenance: true
sbom: true
# Add retry mechanism by splitting the build process
no-cache: false
pull: true
# Note: Manifest creation is no longer needed as we only build one variant
# Multi-arch manifests are automatically created by docker/build-push-action
# Report-only container image vulnerability scan
scan-docker-image:
name: Scan Docker Images
needs: [ build-check, build-docker ]
if: needs.build-check.outputs.should_build == 'true' && needs.build-check.outputs.should_push == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
include:
- variant: musl
suffix: ""
- variant: glibc
suffix: "-glibc"
steps:
- name: Login to GitHub Container Registry
# docker/login-action v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
with:
registry: ghcr.io
username: ${{ secrets.GHCR_USERNAME }}
password: ${{ secrets.GHCR_PASSWORD }}
- name: Scan image with Trivy
# aquasecurity/trivy-action v0.36.0
BINARY_SOURCE=supplied
RELEASE=${{ fromJson(inputs.candidate_manifest).version }}
VCS_REF=${{ fromJson(inputs.candidate_manifest).source_sha }}
- name: Verify wrapped binaries and startup on both architectures
if: steps.reuse.outputs.reused != 'true'
run: python3 scripts/release/deliver.py image-smoke --target "$VARIANT"
- name: Scan amd64 image before sealing
if: steps.reuse.outputs.reused != 'true'
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25
env:
TRIVY_PLATFORM: linux/amd64
with:
image-ref: ${{ env.REGISTRY_GHCR }}:${{ needs.build-check.outputs.version }}${{ matrix.suffix }}
input: delivery-scan/image-${{ matrix.variant }}-amd64.tar
format: sarif
output: trivy-${{ matrix.variant }}.sarif
ignore-unfixed: true
vuln-type: os,library
severity: CRITICAL,HIGH
output: delivery/scan-${{ matrix.variant }}-amd64.sarif
exit-code: "0" # Keep the repository's report-only vulnerability policy.
- name: Scan arm64 image before sealing
if: steps.reuse.outputs.reused != 'true'
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25
env:
TRIVY_PLATFORM: linux/arm64
with:
input: delivery-scan/image-${{ matrix.variant }}-arm64.tar
format: sarif
output: delivery/scan-${{ matrix.variant }}-arm64.sarif
exit-code: "0"
# Surface findings in the Security tab; an artifact alone is write-only
# reporting nobody reviews.
- name: Upload scan results to code scanning
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
- name: Record verified OCI identity
if: steps.reuse.outputs.reused != 'true'
run: python3 scripts/release/deliver.py image-record --target "$VARIANT"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: steps.reuse.outputs.reused != 'true'
with:
sarif_file: trivy-${{ matrix.variant }}.sarif
category: container-image-${{ matrix.variant }}
- name: Upload container scan report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: container-image-scan-${{ matrix.variant }}
path: trivy-${{ matrix.variant }}.sarif
name: image-${{ matrix.variant }}
path: delivery/*
if-no-files-found: error
retention-days: 30
# Docker build summary
docker-summary:
name: Docker Build Summary
needs: [ build-check, build-docker ]
if: always() && needs.build-check.outputs.should_build == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Docker build completion summary
run: |
BUILD_TYPE="${{ needs.build-check.outputs.build_type }}"
VERSION="${{ needs.build-check.outputs.version }}"
CREATE_LATEST="${{ needs.build-check.outputs.create_latest }}"
echo "🐳 Docker build completed successfully!"
echo "📦 Build type: $BUILD_TYPE"
echo "🔢 Version: $VERSION"
echo "🚀 Strategy: Images using pre-built binaries (release channel only)"
echo ""
case "$BUILD_TYPE" in
"preview")
echo "🔍 Preview Docker image has been built with ${VERSION} tags"
echo "⏭️ Preview images do not update latest or prerelease channels"
;;
"release")
echo "🚀 Release Docker image has been built with ${VERSION} tags"
echo "✅ This image is ready for production use"
if [[ "$CREATE_LATEST" == "true" ]]; then
echo "🏷️ Latest tag has been created for stable release"
fi
;;
"prerelease")
echo "🧪 Prerelease Docker image has been built with ${VERSION} tags"
echo "⚠️ This is a prerelease image - use with caution"
# Prereleases move latest until the first stable tag exists (pre-GA policy).
if [[ "$CREATE_LATEST" == "true" ]]; then
echo "🏷️ Latest tag has been created for prerelease: $VERSION"
else
echo "🚫 Latest tag NOT created for prerelease"
fi
;;
*)
echo "❌ Unexpected build type: $BUILD_TYPE"
;;
esac
compression-level: 0
+51 -184
View File
@@ -1,136 +1,26 @@
# Copyright 2024 RustFS Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
name: Publish helm chart to artifacthub
# Copyright 2026 RustFS Team
# SPDX-License-Identifier: Apache-2.0
name: Build candidate Helm chart
on:
workflow_run:
workflows: [ "Build and Release" ]
types: [ completed ]
workflow_dispatch:
workflow_call:
inputs:
version:
description: "Release version to publish, e.g. 1.0.0-beta.1 or v1.0.0-beta.1"
required: true
default: "1.0.0-beta.1"
candidate_manifest:
type: string
required: true
permissions:
contents: read
actions: read
jobs:
build-helm-package:
package:
runs-on: ubuntu-latest
timeout-minutes: 30
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')
)
outputs:
raw_tag: ${{ steps.version.outputs.raw_tag }}
app_version: ${{ steps.version.outputs.app_version }}
chart_version: ${{ steps.version.outputs.chart_version }}
timeout-minutes: 15
steps:
- name: Checkout release source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
ref: ${{ github.event.workflow_run.head_sha || (startsWith(inputs.version, 'refs/tags/') && inputs.version || format('refs/tags/{0}', inputs.version)) }}
ref: ${{ fromJson(inputs.candidate_manifest).source_sha }}
persist-credentials: false
# Both inputs reach the shell through env rather than `${{ }}`
# interpolation. A git ref name may contain `$(...)` — anything without a
# space is a legal tag — and interpolation pastes it into the script
# verbatim, where bash would run it. Reading "$RAW_INPUT" instead makes it
# data.
- name: Normalize release version
id: version
env:
RAW_INPUT: ${{ github.event.inputs.version }}
RAW_BRANCH: ${{ github.event.workflow_run.head_branch }}
run: |
set -eux
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
RAW="$RAW_INPUT"
else
RAW="$RAW_BRANCH"
fi
case "$RAW" in
refs/tags/*)
RAW_TAG="${RAW#refs/tags/}"
;;
*)
RAW_TAG="$RAW"
;;
esac
./scripts/helm_chart_version.sh "$RAW_TAG"
# Docker and Helm share the binary-build trigger. Do not publish a chart
# until the image selected by its default appVersion is pullable.
- name: Wait for release image
timeout-minutes: 20
env:
APP_VERSION: ${{ steps.version.outputs.app_version }}
run: |
set -euo pipefail
if [[ ! "$APP_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] ||
[[ "$APP_VERSION" == *-preview* ]]; then
echo "Invalid deliverable image version: $APP_VERSION" >&2
exit 1
fi
for attempt in {1..60}; do
if token=$(curl --fail --silent --show-error --connect-timeout 10 --max-time 20 \
'https://auth.docker.io/token?service=registry.docker.io&scope=repository:rustfs/rustfs:pull' \
| jq -er '.token // empty') &&
curl --fail --silent --show-error --head --connect-timeout 10 --max-time 20 \
--header "Authorization: Bearer $token" \
--header 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' \
"https://registry-1.docker.io/v2/rustfs/rustfs/manifests/$APP_VERSION" > /dev/null; then
echo "Release image is available: rustfs/rustfs:$APP_VERSION"
exit 0
fi
echo "Waiting for rustfs/rustfs:$APP_VERSION ($attempt/60)"
if (( attempt < 60 )); then
sleep 15
fi
done
echo "Release image is unavailable; refusing to publish Helm chart" >&2
exit 1
- name: Replace chart version and app version
env:
CHART_VERSION: ${{ steps.version.outputs.chart_version }}
APP_VERSION: ${{ steps.version.outputs.app_version }}
run: |
set -eux
sed -i -E "s/^version:.*/version: \"${CHART_VERSION}\"/" helm/rustfs/Chart.yaml
sed -i -E "s/^appVersion:.*/appVersion: \"${APP_VERSION}\"/" helm/rustfs/Chart.yaml
- name: Set up Helm
uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0
- name: Install yq
- uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0
- name: Install pinned yq
run: |
set -euo pipefail
yq_version="4.54.1"
@@ -145,66 +35,43 @@ jobs:
echo "$tools_dir" >> "$GITHUB_PATH"
"$tools_dir/yq" --version
- name: Test Helm Chart Templates
run: ./scripts/test_helm_templates.sh
- name: Package Helm Chart
run: |
set -eux
cp helm/README.md helm/rustfs/
helm package ./helm/rustfs \
--destination helm/rustfs/ \
--version "${{ steps.version.outputs.chart_version }}"
- name: Upload helm package as artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: helm-package
path: |
helm/rustfs/*.tgz
helm/rustfs/README.md
retention-days: 1
publish-helm-package:
runs-on: ubuntu-latest
timeout-minutes: 30
needs: [ build-helm-package ]
if: needs.build-helm-package.result == 'success'
steps:
- name: Checkout helm package repo
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
# persist-credentials-exempt: this checkout's token IS the push credential —
# the job git-pushes to rustfs/helm below. Clearing it breaks chart publishing.
repository: rustfs/helm
token: ${{ secrets.RUSTFS_HELM_PACKAGE }}
- name: Download helm package
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: helm-package
path: ./
- name: Set up helm
uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0
- name: Generate index
run: helm repo index . --url https://charts.rustfs.com
# app_version is derived from the triggering tag name, and this job holds
# the cross-repository push token with rustfs/helm already checked out —
# the worst place in the repo to paste an attacker-influenced string into
# a shell line. Passed through env so bash treats it as data.
- name: Push helm package and index file
- name: Test and package the exact application version
id: chart
env:
GIT_USERNAME: ${{ secrets.USERNAME }}
GIT_EMAIL: ${{ secrets.EMAIL_ADDRESS }}
APP_VERSION: ${{ needs.build-helm-package.outputs.app_version }}
GH_TOKEN: ${{ github.token }}
CANDIDATE_MANIFEST: ${{ inputs.candidate_manifest }}
run: |
set -eux
git config --global user.name "${GIT_USERNAME}"
git config --global user.email "${GIT_EMAIL}"
git add .
git commit -m "Update rustfs helm package with ${APP_VERSION}." || echo "No changes to commit"
git push origin main
python3 - <<'PYCHART'
import sys, subprocess, shutil
from pathlib import Path
sys.path.insert(0, 'scripts/release')
import candidate as c, pipeline
inputs = pipeline.manifest_env()
current = pipeline.current_run('release-complete.yml')
if any(a['name'] == 'helm-package' for a in c.artifact_list(current['id'])):
c.download_artifact(current, 'helm-package', 'chart')
record = c.read_json('chart/chart.json')
c.require(record['inputs_sha256'] == c.digest(inputs), 'chart belongs to another candidate')
c.emit_output('reused', 'true')
else:
chart = Path('helm/rustfs/Chart.yaml')
import re
value = chart.read_text()
for key in ('version', 'appVersion'):
value, count = re.subn(rf'(?m)^{key}:.*$', f'{key}: "{inputs["version"]}"', value)
c.require(count == 1, 'ambiguous chart metadata')
chart.write_text(value)
subprocess.run(['bash', 'scripts/test_helm_templates.sh'], check=True)
shutil.copyfile('helm/README.md', 'helm/rustfs/README.md')
subprocess.run(['helm', 'package', 'helm/rustfs', '--destination', 'chart'], check=True)
path = Path('chart') / f"rustfs-{inputs['version']}.tgz"
c.write_json('chart/chart.json', {'version': inputs['version'], 'inputs_sha256': c.digest(inputs),
'name': path.name, 'sha256': c.file_digest(path), 'size': path.stat().st_size})
PYCHART
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: steps.chart.outputs.reused != 'true'
with:
name: helm-package
path: chart/*
if-no-files-found: error
retention-days: 30
+2 -2
View File
@@ -47,8 +47,8 @@ permissions:
contents: read
concurrency:
group: oidc-keycloak-live-${{ github.ref }}
cancel-in-progress: true
group: oidc-keycloak-live-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name != 'schedule' }}
jobs:
oidc-keycloak-live:
+38 -629
View File
@@ -1,644 +1,53 @@
# Copyright 2024 RustFS Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Package Workflow - Build DEB/RPM packages
#
# This workflow builds DEB and RPM packages from pre-built Linux binaries
# and uploads them to Cloudflare R2 and the GitHub release.
#
# Trigger:
# - workflow_run: automatically package after "Build and Release" completes
# for a release tag (the mac/windows/linux binaries are already uploaded
# to the GitHub release before packaging starts)
# - workflow_dispatch: manual fallback with a release tag and/or exact build run ID
#
# Flow:
# 1. Resolve and validate the selected Build workflow run and source identity
# 2. Download Linux binaries (x86_64-gnu, aarch64-gnu, x86_64-musl,
# aarch64-musl) from build artifacts
# 3. Build DEB packages for amd64 and arm64, gnu and musl variants
# 4. Build RPM packages for x86_64 and aarch64, gnu and musl variants
# 5. Upload all packages to Cloudflare R2 and the GitHub release
name: Package DEB/RPM
permissions:
# contents: write is required to upload packages to the GitHub release
contents: write
actions: read
# Copyright 2026 RustFS Team
# SPDX-License-Identifier: Apache-2.0
name: Build candidate packages
on:
# Follows the same pattern as docker.yml: run after the release build
# workflow completes, so packaging is triggered only by release tags
# (e.g. 1.0.0-rc.2, 1.0.0-rc.3), never by development builds.
workflow_run:
workflows: [ "Build and Release" ]
types: [ completed ]
workflow_dispatch:
workflow_call:
inputs:
tag:
description: "Release tag to package (e.g. 1.0.0-beta.12). Leave empty for latest main build."
required: false
candidate_manifest:
type: string
build_run_id:
description: "Build workflow run ID (when combined with tag, both must identify the same release commit)"
required: false
type: string
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.event.inputs.tag || github.run_id }}
cancel-in-progress: true
env:
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }}
required: true
permissions:
contents: read
actions: read
jobs:
# Resolve which build run to use and extract version info
resolve:
name: Resolve Build
# Auto-trigger only from successful tag builds of "Build and Release".
# Tag pushes arrive as event == push with head_branch != main (a
# non-main push head_branch is the release tag name). Manual dispatch
# stays available as a fallback for backfills and re-runs.
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')
runs-on: ubuntu-latest # gh CLI required; the sm-standard-2 fleet does not ship it
timeout-minutes: 10
outputs:
version: ${{ steps.resolve.outputs.version }}
build_type: ${{ steps.resolve.outputs.build_type }}
build_run_id: ${{ steps.resolve.outputs.build_run_id }}
build_run_number: ${{ steps.resolve.outputs.build_run_number }}
head_sha: ${{ steps.resolve.outputs.head_sha }}
dev_sequence: ${{ steps.resolve.outputs.dev_sequence }}
tag: ${{ steps.resolve.outputs.tag }}
steps:
- name: Resolve build run
id: resolve
shell: bash
env:
GH_TOKEN: ${{ github.token }}
EVENT_NAME: ${{ github.event_name }}
REPOSITORY: ${{ github.repository }}
INPUT_TAG: ${{ github.event.inputs.tag }}
INPUT_RUN_ID: ${{ github.event.inputs.build_run_id }}
run: |
set -euo pipefail
fail() {
echo "❌ $1" >&2
exit 1
}
TAG=""
BUILD_RUN_ID=""
case "$EVENT_NAME" in
workflow_run)
TAG="$HEAD_BRANCH"
BUILD_RUN_ID="$WORKFLOW_RUN_ID"
;;
workflow_dispatch)
TAG="$INPUT_TAG"
BUILD_RUN_ID="$INPUT_RUN_ID"
;;
*) fail "unsupported event: $EVENT_NAME" ;;
esac
# Validate and classify tags before using them in API paths or logs.
semver_core='(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)'
prerelease_id='(alpha|beta|rc)\.(0|[1-9][0-9]*)'
if [[ -n "$TAG" ]]; then
if [[ "$TAG" =~ ^${semver_core}(-${prerelease_id})?-preview\.(0|[1-9][0-9]*)$ ]]; then
BUILD_TYPE=preview
elif [[ "$TAG" =~ ^${semver_core}-${prerelease_id}$ ]]; then
BUILD_TYPE=prerelease
elif [[ "$TAG" =~ ^${semver_core}$ ]]; then
BUILD_TYPE=release
else
fail "tag is not a supported strict package version"
fi
else
BUILD_TYPE=development
fi
if [[ -n "$BUILD_RUN_ID" ]]; then
[[ "$BUILD_RUN_ID" =~ ^[1-9][0-9]*$ ]] || fail "build run ID must be a positive decimal integer"
echo "Using selected build run: $BUILD_RUN_ID"
elif [[ -n "$TAG" ]]; then
echo "Looking for build run for tag: $TAG"
BUILD_RUN_ID=$(gh api --method GET \
"repos/${REPOSITORY}/actions/workflows/build.yml/runs" \
-f branch="$TAG" -f status=success -F per_page=1 \
--jq '.workflow_runs[0].id // empty' 2>/dev/null || true)
if [[ -z "$BUILD_RUN_ID" ]]; then
BUILD_RUN_ID=$(gh api --method GET \
"repos/${REPOSITORY}/actions/workflows/build.yml/runs" \
-f event=push -f status=success -F per_page=100 2>/dev/null |
jq -r --arg tag "$TAG" \
'[.workflow_runs[] | select(.head_branch == $tag)][0].id // empty' || true)
fi
[[ "$BUILD_RUN_ID" =~ ^[1-9][0-9]*$ ]] || fail "no successful build run found for tag"
echo "Found build run: $BUILD_RUN_ID"
else
echo "No tag specified, looking for latest main build"
BUILD_RUN_ID=$(gh api --method GET \
"repos/${REPOSITORY}/actions/workflows/build.yml/runs" \
-f branch=main -f status=success -F per_page=1 \
--jq '.workflow_runs[0].id // empty' 2>/dev/null || true)
[[ "$BUILD_RUN_ID" =~ ^[1-9][0-9]*$ ]] || fail "no successful main build found"
echo "Latest main build: $BUILD_RUN_ID"
fi
# Fetch once and use the same immutable run metadata for identity,
# ordering, workflow provenance, and release-channel validation.
RUN_JSON=$(gh api "repos/${REPOSITORY}/actions/runs/${BUILD_RUN_ID}") ||
fail "cannot read selected build run"
RUN_ID=$(jq -r '.id // empty' <<<"$RUN_JSON")
RUN_NUMBER=$(jq -r '.run_number // empty' <<<"$RUN_JSON")
RUN_STATUS=$(jq -r '.status // empty' <<<"$RUN_JSON")
RUN_CONCLUSION=$(jq -r '.conclusion // empty' <<<"$RUN_JSON")
RUN_PATH=$(jq -r '.path // empty' <<<"$RUN_JSON")
HEAD_SHA=$(jq -r '.head_sha // empty' <<<"$RUN_JSON")
RUN_HEAD_BRANCH=$(jq -r '.head_branch // empty' <<<"$RUN_JSON")
[[ "$RUN_ID" == "$BUILD_RUN_ID" ]] || fail "run metadata ID mismatch"
[[ "$RUN_NUMBER" =~ ^[1-9][0-9]*$ ]] || fail "build run number must be a positive decimal integer"
[[ "$RUN_STATUS" == completed && "$RUN_CONCLUSION" == success ]] || fail "selected build run is not successful"
[[ "$RUN_PATH" == .github/workflows/build.yml ]] || fail "selected run is not Build and Release"
[[ "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]] || fail "selected build run has an invalid head SHA"
[[ "$RUN_HEAD_BRANCH" != *$'\n'* && -n "$RUN_HEAD_BRANCH" ]] || fail "selected build run has an invalid head branch"
if [[ -n "$TAG" ]]; then
[[ "$RUN_HEAD_BRANCH" == "$TAG" ]] || fail "tag and build run head branch do not match"
TAG_REF_JSON=$(gh api "repos/${REPOSITORY}/git/ref/tags/${TAG}") ||
fail "cannot resolve release tag ref"
TAG_OBJECT_TYPE=$(jq -r '.object.type // empty' <<<"$TAG_REF_JSON")
TAG_OBJECT_SHA=$(jq -r '.object.sha // empty' <<<"$TAG_REF_JSON")
depth=0
while [[ "$TAG_OBJECT_TYPE" == tag && $depth -lt 5 ]]; do
TAG_OBJECT_JSON=$(gh api "repos/${REPOSITORY}/git/tags/${TAG_OBJECT_SHA}") ||
fail "cannot peel annotated release tag"
TAG_OBJECT_TYPE=$(jq -r '.object.type // empty' <<<"$TAG_OBJECT_JSON")
TAG_OBJECT_SHA=$(jq -r '.object.sha // empty' <<<"$TAG_OBJECT_JSON")
depth=$((depth + 1))
done
[[ "$TAG_OBJECT_TYPE" == commit && "$TAG_OBJECT_SHA" =~ ^[0-9a-f]{40}$ ]] ||
fail "release tag does not resolve to a commit"
[[ "$TAG_OBJECT_SHA" == "$HEAD_SHA" ]] || fail "release tag commit and build run head SHA do not match"
VERSION="$TAG"
DEV_SEQUENCE=""
else
VERSION="dev-${HEAD_SHA}"
DEV_SEQUENCE="$RUN_NUMBER"
fi
{
echo "version=$VERSION"
echo "build_type=$BUILD_TYPE"
echo "build_run_id=$BUILD_RUN_ID"
echo "build_run_number=$RUN_NUMBER"
echo "head_sha=$HEAD_SHA"
echo "dev_sequence=$DEV_SEQUENCE"
echo "tag=${TAG}"
} >> "$GITHUB_OUTPUT"
echo "📊 Resolved:"
echo " Version: $VERSION"
echo " Build type: $BUILD_TYPE"
echo " Build run ID: $BUILD_RUN_ID"
echo " Build run number: $RUN_NUMBER"
# Build DEB and RPM packages for each architecture and libc variant
package:
name: Package (${{ matrix.arch }}-${{ matrix.libc }})
needs: resolve
runs-on: ubuntu-latest # gh CLI required; the sm-standard-2 fleet does not ship it
timeout-minutes: 30
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
fail-fast: false
max-parallel: 1
matrix:
include:
- arch: x86_64
libc: gnu
deb_arch: amd64
rpm_arch: x86_64
artifact_name: "rustfs-linux-x86_64-gnu"
# gnu binaries dynamically link glibc; musl binaries are static
# and run without a libc dependency.
deb_depends: "libc6 (>= 2.31)"
rpm_depends: "glibc >= 2.31"
- arch: aarch64
libc: gnu
deb_arch: arm64
rpm_arch: aarch64
artifact_name: "rustfs-linux-aarch64-gnu"
deb_depends: "libc6 (>= 2.31)"
rpm_depends: "glibc >= 2.31"
- arch: x86_64
libc: musl
deb_arch: amd64
rpm_arch: x86_64
artifact_name: "rustfs-linux-x86_64-musl"
deb_depends: ""
rpm_depends: ""
- arch: aarch64
libc: musl
deb_arch: arm64
rpm_arch: aarch64
artifact_name: "rustfs-linux-aarch64-musl"
deb_depends: ""
rpm_depends: ""
target: [linux-x86_64-musl, linux-aarch64-musl, linux-x86_64-gnu, linux-aarch64-gnu]
steps:
- name: Checkout repository
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
ref: ${{ fromJson(inputs.candidate_manifest).source_sha }}
persist-credentials: false
- name: Normalize package metadata
id: versions
shell: bash
env:
BUILD_TYPE: ${{ needs.resolve.outputs.build_type }}
SOURCE_VERSION: ${{ needs.resolve.outputs.version }}
DEV_SEQUENCE: ${{ needs.resolve.outputs.dev_sequence }}
ARCH: ${{ matrix.rpm_arch }}
LIBC: ${{ matrix.libc }}
run: |
set -euo pipefail
normalized=$(./scripts/release/package_versions.sh \
"$BUILD_TYPE" "$SOURCE_VERSION" "$DEV_SEQUENCE" "$ARCH" "$LIBC")
printf '%s\n' "$normalized" >> "$GITHUB_OUTPUT"
- name: Download binary artifact from build run
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: ${{ matrix.artifact_name }}*
path: ./binary-artifact
run-id: ${{ needs.resolve.outputs.build_run_id }}
github-token: ${{ github.token }}
merge-multiple: true
- name: Extract binary
id: binary
shell: bash
run: |
set -euo pipefail
ZIP_FILE=$(find ./binary-artifact -name "*.zip" -type f | head -1)
if [[ -z "$ZIP_FILE" ]]; then
echo "❌ No binary artifact found"
find ./binary-artifact -mindepth 1 -maxdepth 1 -print 2>/dev/null || true
exit 1
fi
echo "Found artifact: $ZIP_FILE"
mkdir -p ./bin
unzip -o "$ZIP_FILE" -d ./bin
if [[ ! -f ./bin/rustfs ]]; then
echo "❌ rustfs binary not found in archive"
exit 1
fi
chmod +x ./bin/rustfs
stat --printf='%n %s bytes\n' ./bin/rustfs
echo "✅ Binary extracted"
- name: Build DEB package
id: deb
shell: bash
env:
DEB_VERSION: ${{ steps.versions.outputs.deb_version }}
DEB_ARCH: ${{ matrix.deb_arch }}
DEB_FILE: ${{ steps.versions.outputs.deb_file }}
DEB_DEPENDS: ${{ matrix.deb_depends }}
LIBC: ${{ matrix.libc }}
run: |
set -euo pipefail
PKG_DIR="${DEB_FILE%.deb}"
echo "Building DEB: ${DEB_FILE}"
mkdir -p "${PKG_DIR}/DEBIAN"
mkdir -p "${PKG_DIR}/usr/bin"
mkdir -p "${PKG_DIR}/etc/default"
mkdir -p "${PKG_DIR}/lib/systemd/system"
mkdir -p "${PKG_DIR}/usr/share/doc/rustfs"
cp ./bin/rustfs "${PKG_DIR}/usr/bin/"
chmod 755 "${PKG_DIR}/usr/bin/rustfs"
cp deploy/build/rustfs.service "${PKG_DIR}/lib/systemd/system/"
cat > "${PKG_DIR}/etc/default/rustfs" << 'ENVEOF'
# RustFS Environment Configuration
# See https://docs.rustfs.com for more information
# RUSTFS_ACCESS_KEY=<changeme>
# RUSTFS_SECRET_KEY=<changeme>
# RUSTFS_VOLUMES=""
# RUSTFS_ADDRESS=":9000"
# RUSTFS_CONSOLE_ADDRESS=":9001"
# RUSTFS_CONSOLE_ENABLE=true
# RUSTFS_OBS_LOGGER_LEVEL=error
# RUSTFS_OBS_LOG_DIRECTORY="/var/log/rustfs/"
ENVEOF
cat > "${PKG_DIR}/DEBIAN/control" << EOF
Package: rustfs
Version: ${DEB_VERSION}
Section: utils
Priority: optional
Architecture: ${DEB_ARCH}
EOF
# gnu binaries dynamically link glibc and must declare it; musl
# binaries are statically linked and need no libc dependency.
if [[ -n "$DEB_DEPENDS" ]]; then
echo "Depends: ${DEB_DEPENDS}" >> "${PKG_DIR}/DEBIAN/control"
fi
cat >> "${PKG_DIR}/DEBIAN/control" << EOF
Maintainer: RustFS Team <support@rustfs.com>
Description: High-performance distributed object storage (${LIBC} build)
RustFS is a high-performance distributed object storage software
built using Rust. It is compatible with MinIO and S3 API.
Homepage: https://rustfs.com
EOF
# Declare /etc/default/rustfs as a conffile so dpkg preserves user
# modifications on upgrade instead of silently overwriting them.
cat > "${PKG_DIR}/DEBIAN/conffiles" << 'CONFFILES'
/etc/default/rustfs
CONFFILES
./scripts/release/package_service_scripts.sh after-install > "${PKG_DIR}/DEBIAN/postinst"
chmod 755 "${PKG_DIR}/DEBIAN/postinst"
./scripts/release/package_service_scripts.sh before-remove > "${PKG_DIR}/DEBIAN/prerm"
chmod 755 "${PKG_DIR}/DEBIAN/prerm"
./scripts/release/package_service_scripts.sh after-remove > "${PKG_DIR}/DEBIAN/postrm"
chmod 755 "${PKG_DIR}/DEBIAN/postrm"
cp LICENSE "${PKG_DIR}/usr/share/doc/rustfs/"
cp README.md "${PKG_DIR}/usr/share/doc/rustfs/"
fakeroot dpkg-deb --build "${PKG_DIR}" "$DEB_FILE"
[[ $(dpkg-deb -f "$DEB_FILE" Package) == rustfs ]]
[[ $(dpkg-deb -f "$DEB_FILE" Version) == "$DEB_VERSION" ]]
[[ $(dpkg-deb -f "$DEB_FILE" Architecture) == "$DEB_ARCH" ]]
dpkg-deb --fsys-tarfile "$DEB_FILE" | tar -tf - | grep -Fx './usr/bin/rustfs' >/dev/null
stat --printf='%n %s bytes\n' "$DEB_FILE"
echo "deb_file=$DEB_FILE" >> "$GITHUB_OUTPUT"
echo "✅ DEB built: $DEB_FILE"
- name: Build RPM package
id: rpm
shell: bash
env:
RPM_VERSION: ${{ steps.versions.outputs.rpm_version }}
RPM_RELEASE: ${{ steps.versions.outputs.rpm_release }}
RPM_ARCH: ${{ matrix.rpm_arch }}
RPM_FILE: ${{ steps.versions.outputs.rpm_file }}
RPM_DEPENDS: ${{ matrix.rpm_depends }}
LIBC: ${{ matrix.libc }}
run: |
set -euo pipefail
echo "Building RPM for ${RPM_ARCH} (${LIBC})"
sudo apt-get update && sudo apt-get install -y ruby ruby-dev build-essential rpm
sudo gem install fpm
./scripts/test_package_versions.sh --require-package-managers
# gnu binaries dynamically link glibc and must declare it; musl
# binaries are statically linked and need no libc dependency.
depends_args=()
if [[ -n "$RPM_DEPENDS" ]]; then
depends_args+=(--depends "$RPM_DEPENDS")
fi
# Create config file for fpm (DEB build creates it in its package dir structure,
# but fpm needs the file to exist before packaging)
mkdir -p ./tmp-pkg/etc/default
cat > ./tmp-pkg/etc/default/rustfs << 'ENVEOF'
# RustFS Environment Configuration
# See https://docs.rustfs.com for more information
# RUSTFS_ACCESS_KEY=<changeme>
# RUSTFS_SECRET_KEY=<changeme>
# RUSTFS_VOLUMES=""
# RUSTFS_ADDRESS=":9000"
# RUSTFS_CONSOLE_ADDRESS=":9001"
# RUSTFS_CONSOLE_ENABLE=true
# RUSTFS_OBS_LOGGER_LEVEL=error
# RUSTFS_OBS_LOG_DIRECTORY="/var/log/rustfs/"
ENVEOF
fpm -s dir -t rpm \
--name rustfs \
--version "$RPM_VERSION" \
--iteration "$RPM_RELEASE" \
--architecture "$RPM_ARCH" \
--package "$RPM_FILE" \
"${depends_args[@]}" \
--description "High-performance distributed object storage (${LIBC} build)" \
--maintainer "RustFS Team <support@rustfs.com>" \
--url "https://rustfs.com" \
--license "Apache-2.0" \
--before-install <(./scripts/release/package_service_scripts.sh rpm-before-install) \
--before-upgrade <(./scripts/release/package_service_scripts.sh rpm-before-upgrade) \
--after-install <(./scripts/release/package_service_scripts.sh after-install) \
--after-upgrade <(./scripts/release/package_service_scripts.sh after-install) \
--before-remove <(./scripts/release/package_service_scripts.sh before-remove) \
--after-remove <(./scripts/release/package_service_scripts.sh after-remove) \
--rpm-posttrans <(./scripts/release/package_service_scripts.sh rpm-posttrans) \
--config-files /etc/default/rustfs \
./bin/rustfs=/usr/bin/rustfs \
./tmp-pkg/etc/default/rustfs=/etc/default/rustfs \
deploy/build/rustfs.service=/lib/systemd/system/rustfs.service \
LICENSE=/usr/share/doc/rustfs/LICENSE \
README.md=/usr/share/doc/rustfs/README.md
if [[ ! -f "$RPM_FILE" ]]; then
echo "❌ RPM build failed"
exit 1
fi
RPM_METADATA=$(rpm -qp --qf '%{NAME}\n%{VERSION}\n%{RELEASE}\n%{ARCH}\n' "$RPM_FILE")
EXPECTED_METADATA=$(printf 'rustfs\n%s\n%s\n%s' "$RPM_VERSION" "$RPM_RELEASE" "$RPM_ARCH")
[[ "$RPM_METADATA" == "$EXPECTED_METADATA" ]]
rpm -qpl "$RPM_FILE" | grep -Fx '/usr/bin/rustfs' >/dev/null
stat --printf='%n %s bytes\n' "$RPM_FILE"
echo "rpm_file=$RPM_FILE" >> "$GITHUB_OUTPUT"
echo "✅ RPM built: $RPM_FILE"
- name: Upload packages to artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: packages-${{ matrix.arch }}-${{ matrix.libc }}
path: |
*.deb
*.rpm
retention-days: 30
- name: Upload packages to Cloudflare R2
if: needs.resolve.outputs.build_type != 'preview' && env.R2_ACCESS_KEY_ID != ''
env:
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
AWS_EC2_METADATA_DISABLED: true
BUILD_TYPE: ${{ needs.resolve.outputs.build_type }}
DEB_FILE: ${{ steps.deb.outputs.deb_file }}
RPM_FILE: ${{ steps.rpm.outputs.rpm_file }}
shell: bash
run: |
set -euo pipefail
if [[ -z "$R2_ACCESS_KEY_ID" || -z "$R2_SECRET_ACCESS_KEY" || -z "$R2_ENDPOINT" || -z "$R2_BUCKET" ]]; then
echo "⚠️ R2 credentials missing, skipping upload"
exit 0
fi
if ! command -v aws >/dev/null 2>&1; then
sudo apt-get update && sudo apt-get install -y awscli
fi
export AWS_ACCESS_KEY_ID="$R2_ACCESS_KEY_ID"
export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY"
export AWS_DEFAULT_REGION="auto"
if [[ "$BUILD_TYPE" == "development" ]]; then
R2_PREFIX="artifacts/rustfs/packages/dev"
else
R2_PREFIX="artifacts/rustfs/packages/release"
fi
R2_PATH="s3://${R2_BUCKET}/${R2_PREFIX}/"
echo "📤 Uploading to $R2_PATH"
for f in "$DEB_FILE" "$RPM_FILE"; do
if [[ -n "$f" && -f "$f" ]]; then
echo "Uploading: $f"
aws s3 cp "$f" "$R2_PATH" --endpoint-url "$R2_ENDPOINT" --only-show-errors
fi
done
echo "✅ Upload complete"
# Also upload as latest for release/prerelease
if [[ "$BUILD_TYPE" == "release" || "$BUILD_TYPE" == "prerelease" ]]; then
LATEST_PATH="s3://${R2_BUCKET}/artifacts/rustfs/packages/latest/"
for f in "$DEB_FILE" "$RPM_FILE"; do
if [[ -n "$f" && -f "$f" ]]; then
echo "Uploading latest: $(basename "$f")"
aws s3 cp "$f" "$LATEST_PATH" --endpoint-url "$R2_ENDPOINT" --only-show-errors
fi
done
echo "✅ Latest packages updated"
fi
- name: Upload packages to GitHub Release
if: needs.resolve.outputs.tag != ''
- name: Reuse completed package set
id: reuse
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.resolve.outputs.tag }}
DEB_FILE: ${{ steps.deb.outputs.deb_file }}
RPM_FILE: ${{ steps.rpm.outputs.rpm_file }}
shell: bash
CANDIDATE_MANIFEST: ${{ inputs.candidate_manifest }}
TARGET: ${{ matrix.target }}
run: python3 scripts/release/deliver.py reuse --kind packages --target "$TARGET"
- name: Install packaging tools
if: steps.reuse.outputs.reused != 'true'
run: |
set -euo pipefail
# Upload the packages, then refresh the release checksums so the new
# assets are covered, matching the binary release flow.
for f in "$DEB_FILE" "$RPM_FILE"; do
if [[ -n "$f" && -f "$f" ]]; then
echo "📤 Uploading $(basename "$f") to GitHub release ${TAG}..."
gh release upload "$TAG" "$f" --clobber
fi
done
CHECKSUM_DIR="$(mktemp -d)"
gh release download "$TAG" -p 'SHA256SUMS' -p 'SHA512SUMS' \
-D "$CHECKSUM_DIR" --clobber
for spec in "SHA256SUMS:sha256sum" "SHA512SUMS:sha512sum"; do
asset="${spec%%:*}"
checksum_cmd="${spec##*:}"
checksum_file="${CHECKSUM_DIR}/${asset}"
for f in "$DEB_FILE" "$RPM_FILE"; do
if [[ -n "$f" && -f "$f" ]]; then
base="$(basename "$f")"
# GitHub stores release asset names with '~' normalized to '.'.
# Current package file names embed the raw tag and contain no
# '~', but older releases may still ship such assets, so the
# checksum entries must reference the name as stored on the
# release.
github_base="${base//\~/.}"
# Remove any stale entry (both naming variants), then append
grep -Fv -- "$base" "$checksum_file" > "${checksum_file}.tmp" || true
grep -Fv -- "$github_base" "${checksum_file}.tmp" > "${checksum_file}.tmp2" || true
mv "${checksum_file}.tmp2" "$checksum_file"
digest=$("$checksum_cmd" -- "$f" | awk '{print $1}')
printf '%s %s\n' "$digest" "$github_base" >> "$checksum_file"
fi
done
echo "📤 Updating ${asset} for release ${TAG}..."
gh release upload "$TAG" "$checksum_file" --clobber
done
echo "✅ GitHub release assets updated"
# Summary
summary:
name: Summary
needs: [ resolve, package ]
# Every completed Build and Release run triggers this workflow; when
# resolve declined it (not a release tag), there is nothing to summarise.
if: always() && needs.resolve.result != 'skipped'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Print summary
shell: bash
sudo apt-get update
sudo apt-get install -y ruby ruby-dev build-essential rpm fakeroot cpio
sudo gem install fpm --version 1.17.0 --no-document
- name: Package accepted executable and verify contents
if: steps.reuse.outputs.reused != 'true'
env:
SUMMARY_VERSION: ${{ needs.resolve.outputs.version }}
SUMMARY_BUILD_TYPE: ${{ needs.resolve.outputs.build_type }}
SUMMARY_BUILD_RUN_ID: ${{ needs.resolve.outputs.build_run_id }}
SUMMARY_PACKAGE_STATUS: ${{ needs.package.result }}
run: |
{
echo "## 📦 Package Summary"
echo ""
echo "| Item | Value |"
echo "|------|-------|"
echo "| Version | \`${SUMMARY_VERSION}\` |"
echo "| Build Type | ${SUMMARY_BUILD_TYPE} |"
echo "| Build Run | #${SUMMARY_BUILD_RUN_ID} |"
echo "| Package Status | ${SUMMARY_PACKAGE_STATUS} |"
} >> "$GITHUB_STEP_SUMMARY"
GH_TOKEN: ${{ github.token }}
CANDIDATE_MANIFEST: ${{ inputs.candidate_manifest }}
TARGET: ${{ matrix.target }}
run: python3 scripts/release/deliver.py package --target "$TARGET" --output delivery
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: steps.reuse.outputs.reused != 'true'
with:
name: packages-${{ matrix.target }}
path: delivery/*
if-no-files-found: error
compression-level: 0
retention-days: 30
+133
View File
@@ -0,0 +1,133 @@
# Copyright 2026 RustFS Team
# SPDX-License-Identifier: Apache-2.0
name: Release candidate (Linux)
on:
workflow_dispatch:
inputs:
version:
description: Final product version (no preview suffix)
type: string
required: true
source_sha:
description: Full reviewed source commit
type: string
required: true
permissions:
contents: read
actions: read
concurrency:
group: release-candidate-${{ inputs.version }}
cancel-in-progress: false
jobs:
freeze:
if: github.repository == 'rustfs/rustfs' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
manifest: ${{ steps.freeze.outputs.manifest }}
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
fetch-depth: 0
persist-credentials: false
- name: Freeze or recover original inputs
id: freeze
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
SOURCE_SHA: ${{ inputs.source_sha }}
run: python3 scripts/release/pipeline.py prepare --version "$VERSION" --source "$SOURCE_SHA" --directory frozen
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: steps.freeze.outputs.reused != 'true'
with:
name: release-inputs
path: frozen/inputs.json
if-no-files-found: error
retention-days: 30
linux:
needs: freeze
uses: ./.github/workflows/build.yml
with:
candidate_manifest: ${{ needs.freeze.outputs.manifest }}
platforms: linux
upgrade:
needs: [freeze, linux]
runs-on: ubuntu-latest
timeout-minutes: 150
env:
RUSTUP_TOOLCHAIN: ${{ fromJson(needs.freeze.outputs.manifest).toolchain }}
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
ref: ${{ fromJson(needs.freeze.outputs.manifest).workflow_sha }}
persist-credentials: false
- uses: ./.github/actions/setup
with:
rust-version: ${{ fromJson(needs.freeze.outputs.manifest).toolchain }}
cache-shared-key: release-upgrade-harness-hosted
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
install-build-packaging-tools: "false"
install-test-tools: "false"
- name: Verify archived server against previous releases
env:
GH_TOKEN: ${{ github.token }}
CANDIDATE_MANIFEST: ${{ needs.freeze.outputs.manifest }}
run: |
python3 - <<'PYVERIFY'
import sys
from pathlib import Path
sys.path.insert(0, 'scripts/release')
import candidate as c, pipeline, verify
inputs = pipeline.manifest_env()
run = pipeline.current_run('release-candidate.yml')
if any(a['name'] == 'release-upgrade' for a in c.artifact_list(run['id'])):
c.download_artifact(run, 'release-upgrade', 'receipts')
pipeline.verify_evidence(inputs, Path('receipts'), {run['id']: run})
c.emit_output('reused', 'true')
else:
target = 'linux-x86_64-gnu'
c.download_artifact(run, f"candidate-{inputs['candidate_id']}-{target}", 'binary')
verify.upgrade(inputs, Path('binary') / f"rustfs-{target}-v{inputs['version']}.zip", Path('receipts'))
PYVERIFY
id: verify
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: steps.verify.outputs.reused != 'true'
with:
name: release-upgrade
path: receipts/*
if-no-files-found: error
retention-days: 30
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: failure()
with:
name: release-upgrade-failure-${{ github.run_attempt }}
path: receipts/*
retention-days: 7
seal-linux:
needs: [freeze, linux, upgrade]
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Collect trusted receipts and seal Linux bytes
id: seal
env:
GH_TOKEN: ${{ github.token }}
run: |
python3 scripts/release/pipeline.py linux-seal --directory sealed
{
echo '## Linux acceptance'
echo 'Use this run ID and manifest SHA-256 when starting Release candidate (complete).'
sha256sum sealed/linux-manifest.json
echo 'Record cluster, performance, integrity and upgrade acceptance in backlog before continuing.'
} >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: steps.seal.outputs.reused != 'true'
with:
name: release-linux
path: sealed/*
if-no-files-found: error
compression-level: 0
retention-days: 30
+97
View File
@@ -0,0 +1,97 @@
# Copyright 2026 RustFS Team
# SPDX-License-Identifier: Apache-2.0
name: Release candidate (complete)
on:
workflow_dispatch:
inputs:
candidate_run_id:
description: Successful Linux candidate run ID
type: string
required: true
linux_manifest_sha256:
description: Exact accepted Linux manifest SHA-256
type: string
required: true
acceptance_report:
description: Backlog report accepting cluster, performance, integrity and upgrade results
type: string
required: true
permissions:
contents: read
actions: read
concurrency:
group: release-complete-${{ inputs.candidate_run_id }}
cancel-in-progress: false
jobs:
acceptance:
if: github.repository == 'rustfs/rustfs' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 30
environment: release-linux-acceptance
outputs:
manifest: ${{ steps.accept.outputs.manifest }}
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
fetch-depth: 0
persist-credentials: false
- name: Bind acceptance to the exact Linux manifest
id: accept
env:
GH_TOKEN: ${{ github.token }}
CANDIDATE_RUN: ${{ inputs.candidate_run_id }}
MANIFEST_SHA: ${{ inputs.linux_manifest_sha256 }}
REPORT: ${{ inputs.acceptance_report }}
run: python3 scripts/release/pipeline.py complete-prepare --run-id "$CANDIDATE_RUN" --digest "$MANIFEST_SHA" --report "$REPORT" --directory linux
desktop:
needs: acceptance
uses: ./.github/workflows/build.yml
with:
candidate_manifest: ${{ needs.acceptance.outputs.manifest }}
platforms: desktop
packages:
needs: acceptance
uses: ./.github/workflows/package.yml
with:
candidate_manifest: ${{ needs.acceptance.outputs.manifest }}
images:
needs: acceptance
uses: ./.github/workflows/docker.yml
with:
candidate_manifest: ${{ needs.acceptance.outputs.manifest }}
chart:
needs: acceptance
uses: ./.github/workflows/helm-package.yml
with:
candidate_manifest: ${{ needs.acceptance.outputs.manifest }}
seal:
needs: [acceptance, desktop, packages, images, chart]
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
fetch-depth: 0
persist-credentials: false
- name: Verify every deliverable and seal the candidate
id: seal
env:
GH_TOKEN: ${{ github.token }}
CANDIDATE_RUN: ${{ inputs.candidate_run_id }}
MANIFEST_SHA: ${{ inputs.linux_manifest_sha256 }}
REPORT: ${{ inputs.acceptance_report }}
run: |
python3 scripts/release/pipeline.py complete-seal --run-id "$CANDIDATE_RUN" --digest "$MANIFEST_SHA" --report "$REPORT" --directory sealed
{
echo '## Final publication approval'
echo 'Review this manifest, then explicitly dispatch Publish accepted release with its SHA-256.'
sha256sum sealed/release-manifest.json
} >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: steps.seal.outputs.reused != 'true'
with:
name: release-seal
path: sealed/*
if-no-files-found: error
compression-level: 0
retention-days: 30
+193
View File
@@ -0,0 +1,193 @@
# Copyright 2026 RustFS Team
# SPDX-License-Identifier: Apache-2.0
name: Publish accepted release
on:
workflow_dispatch:
inputs:
complete_run_id:
description: Successful completed candidate run ID
type: string
required: true
manifest_sha256:
description: Exact approved final manifest SHA-256 (authorizes publication)
type: string
required: true
permissions:
contents: read
actions: read
concurrency:
group: release-publish-${{ inputs.complete_run_id }}
cancel-in-progress: false
jobs:
verify:
if: github.repository == 'rustfs/rustfs' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 45
outputs:
version: ${{ steps.verify.outputs.version }}
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
fetch-depth: 0
persist-credentials: false
- name: Recheck approved bytes and trusted certification
id: verify
env:
GH_TOKEN: ${{ github.token }}
COMPLETE_RUN: ${{ inputs.complete_run_id }}
MANIFEST_SHA: ${{ inputs.manifest_sha256 }}
run: python3 scripts/release/publish.py verify --run-id "$COMPLETE_RUN" --digest "$MANIFEST_SHA" --directory sealed
version:
needs: verify
runs-on: ubuntu-latest
timeout-minutes: 90
concurrency:
group: release-version-${{ needs.verify.outputs.version }}
cancel-in-progress: false
environment: release-publication
permissions:
contents: write
actions: read
packages: write
security-events: write
env:
GH_TOKEN: ${{ github.token }}
COMPLETE_RUN: ${{ inputs.complete_run_id }}
MANIFEST_SHA: ${{ inputs.manifest_sha256 }}
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
AWS_REQUEST_CHECKSUM_CALCULATION: when_required
AWS_RESPONSE_CHECKSUM_VALIDATION: when_required
AWS_EC2_METADATA_DISABLED: "true"
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
fetch-depth: 0
persist-credentials: false
- name: Install promotion tools
run: |
sudo apt-get update
sudo apt-get install -y skopeo
aws --version
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
username: rustfs
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ghcr.io
username: ${{ secrets.GHCR_USERNAME }}
password: ${{ secrets.GHCR_PASSWORD }}
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_PASSWORD }}
- name: Share registry credentials with the OCI copier
run: |
install -m 600 "$HOME/.docker/config.json" "$RUNNER_TEMP/registry-auth.json"
echo "REGISTRY_AUTH_FILE=$RUNNER_TEMP/registry-auth.json" >> "$GITHUB_ENV"
- name: Promote verified version
run: python3 scripts/release/publish.py version --run-id "$COMPLETE_RUN" --digest "$MANIFEST_SHA" --directory sealed
- name: Publish accepted musl amd64 scan
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4
with:
sarif_file: sealed/scan-musl-amd64.sarif
category: container-image-musl-amd64
- name: Publish accepted musl arm64 scan
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4
with:
sarif_file: sealed/scan-musl-arm64.sarif
category: container-image-musl-arm64
- name: Publish accepted GNU amd64 scan
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4
with:
sarif_file: sealed/scan-gnu-amd64.sarif
category: container-image-gnu-amd64
- name: Publish accepted GNU arm64 scan
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4
with:
sarif_file: sealed/scan-gnu-arm64.sarif
category: container-image-gnu-arm64
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-version-receipt-${{ github.run_attempt }}
path: publication.json
if-no-files-found: error
retention-days: 90
channels:
needs: [verify, version]
runs-on: ubuntu-latest
timeout-minutes: 90
concurrency:
group: rustfs-release-channels
cancel-in-progress: false
permissions:
contents: write
actions: read
packages: write
env:
GH_TOKEN: ${{ github.token }}
COMPLETE_RUN: ${{ inputs.complete_run_id }}
MANIFEST_SHA: ${{ inputs.manifest_sha256 }}
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
AWS_REQUEST_CHECKSUM_CALCULATION: when_required
AWS_RESPONSE_CHECKSUM_VALIDATION: when_required
AWS_EC2_METADATA_DISABLED: "true"
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
fetch-depth: 0
persist-credentials: false
- name: Install promotion tools
run: |
sudo apt-get update
sudo apt-get install -y skopeo
aws --version
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
username: rustfs
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ghcr.io
username: ${{ secrets.GHCR_USERNAME }}
password: ${{ secrets.GHCR_PASSWORD }}
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_PASSWORD }}
- name: Share registry credentials with the OCI copier
run: |
install -m 600 "$HOME/.docker/config.json" "$RUNNER_TEMP/registry-auth.json"
echo "REGISTRY_AUTH_FILE=$RUNNER_TEMP/registry-auth.json" >> "$GITHUB_ENV"
- uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0
- name: Install pinned OSS client
run: |
curl --fail --location --retry 3 --output "$RUNNER_TEMP/ossutil.zip" https://gosspublic.alicdn.com/ossutil/v2/2.1.1/ossutil-2.1.1-linux-amd64.zip
printf '%s %s\n' 60f3a808cbbdfd4b5269b8f967c6a66f564c9dacff52d93a5d21a588976ab5a2 "$RUNNER_TEMP/ossutil.zip" | sha256sum --check -
unzip -q "$RUNNER_TEMP/ossutil.zip" -d "$RUNNER_TEMP/ossutil-extract"
install -m 755 "$RUNNER_TEMP/ossutil-extract/ossutil-2.1.1-linux-amd64/ossutil" "$RUNNER_TEMP/ossutil"
echo "OSSUTIL_BIN=$RUNNER_TEMP/ossutil" >> "$GITHUB_ENV"
- name: Promote verified channels
env:
HELM_TOKEN: ${{ secrets.RUSTFS_HELM_PACKAGE }}
OSS_ACCESS_KEY_ID: ${{ secrets.ALICLOUDOSS_KEY_ID }}
OSS_ACCESS_KEY_SECRET: ${{ secrets.ALICLOUDOSS_KEY_SECRET }}
OSS_REGION: cn-beijing
OSS_ENDPOINT: https://oss-cn-beijing.aliyuncs.com
run: python3 scripts/release/publish.py channels --run-id "$COMPLETE_RUN" --digest "$MANIFEST_SHA" --directory sealed
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-channels-receipt-${{ github.run_attempt }}
path: publication.json
if-no-files-found: error
retention-days: 90
@@ -26,7 +26,13 @@ permissions:
jobs:
alert-on-incomplete-run:
name: Alert on incomplete scheduled run
if: github.repository == 'rustfs/rustfs'
# The registry still owns responsibility and cadence selection. Successful
# and PR runs can never produce an incomplete scheduled-run alert.
if: >-
github.repository == 'rustfs/rustfs' &&
github.event.workflow_run.conclusion != 'success' &&
(github.event.workflow_run.event == 'schedule' ||
github.event.workflow_run.event == 'workflow_run')
# Stay on GitHub-hosted runners: this monitor must keep running when the
# self-hosted sm-standard-* pool is down, which is exactly when the
# scheduled validations it watches stall.
+11 -2
View File
@@ -12,7 +12,9 @@
# See the License for the specific language governing permissions and
# limitations under the License.
FROM alpine:3.24.2 AS build
ARG BINARY_SOURCE=download
FROM alpine:3.24.2 AS download
ARG TARGETARCH
ARG RELEASE=latest
@@ -74,6 +76,14 @@ COPY scripts/download_rc.sh /usr/local/bin/download_rc.sh
RUN sh /usr/local/bin/download_rc.sh "$TARGETARCH" musl "$RC_RELEASE" /build/rc
# Candidate packaging supplies the accepted executables from verified archives.
# The default download stage remains available for local image builds.
FROM scratch AS supplied
ARG TARGETARCH
COPY .release-inputs/${TARGETARCH}/ /build/
FROM ${BINARY_SOURCE} AS build
FROM alpine:3.24.2
ARG RELEASE=latest
@@ -103,7 +113,6 @@ RUN apk upgrade --no-cache && \
tzdata \
&& test "$(TZ=Asia/Kolkata date +%z)" = "+0530"
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /build/rustfs /usr/bin/rustfs
COPY --from=build /build/rc /usr/bin/rc
COPY entrypoint.sh /etc/rustfs/entrypoint.sh
+11 -1
View File
@@ -12,7 +12,9 @@
# See the License for the specific language governing permissions and
# limitations under the License.
FROM ubuntu:26.04 AS build
ARG BINARY_SOURCE=download
FROM ubuntu:26.04 AS download
ARG TARGETARCH
ARG RELEASE=latest
@@ -80,6 +82,14 @@ RUN set -eux; \
COPY scripts/download_rc.sh /usr/local/bin/download_rc.sh
RUN sh /usr/local/bin/download_rc.sh "$TARGETARCH" gnu "$RC_RELEASE" /build/rc
# Candidate packaging supplies the accepted executables from verified archives.
# The default download stage remains available for local image builds.
FROM scratch AS supplied
ARG TARGETARCH
COPY .release-inputs/${TARGETARCH}/ /build/
FROM ${BINARY_SOURCE} AS build
FROM ubuntu:26.04
ARG RELEASE=latest
+3 -3
View File
@@ -145,7 +145,7 @@ chown -R 10001:10001 data logs
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
# Using specific version
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.4
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.5
```
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
@@ -224,7 +224,7 @@ For developers who want to build RustFS Docker images from source with multi-arc
./docker-buildx.sh --push
# Build specific version
./docker-buildx.sh --release 1.0.4 --push
./docker-buildx.sh --release 1.0.5 --push
# Build for custom registry
./docker-buildx.sh --registry your-registry.com --namespace yourname --push
@@ -242,7 +242,7 @@ You can also use Make targets for convenience:
```bash
make docker-buildx # Build locally
make docker-buildx-push # Build and push
make docker-buildx-version VERSION=1.0.4 # Build specific version
make docker-buildx-version VERSION=1.0.5 # Build specific version
make help-docker # Show all Docker-related commands
```
+3 -3
View File
@@ -138,7 +138,7 @@ chown -R 10001:10001 data logs
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
# 使用指定版本运行
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.4
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.5
```
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
@@ -176,7 +176,7 @@ docker compose -f docker-compose-simple.yml up -d
./docker-buildx.sh --push
# 构建指定版本
./docker-buildx.sh --release 1.0.4 --push
./docker-buildx.sh --release 1.0.5 --push
# 构建并推送到自定义仓库
./docker-buildx.sh --registry your-registry.com --namespace yourname --push
@@ -193,7 +193,7 @@ docker compose -f docker-compose-simple.yml up -d
```bash
make docker-buildx # 本地构建
make docker-buildx-push # 构建并推送
make docker-buildx-version VERSION=1.0.4 # 构建指定版本
make docker-buildx-version VERSION=1.0.5 # 构建指定版本
make help-docker # 显示所有 Docker 相关命令
```
+100
View File
@@ -902,4 +902,104 @@ mod tests {
assert_eq!(raw.len(), 8);
assert_eq!(&raw[..], reference.digest().to_be_bytes().as_slice());
}
#[test]
fn checksums_streaming_chunks_and_header_encoding_match_oneshot_across_unaligned_boundaries() {
use crate::http::HttpChecksum;
let all = [
ChecksumAlgorithm::Crc32,
ChecksumAlgorithm::Crc32c,
ChecksumAlgorithm::Crc64Nvme,
ChecksumAlgorithm::Sha1,
ChecksumAlgorithm::Sha256,
ChecksumAlgorithm::Sha512,
ChecksumAlgorithm::Xxhash3,
ChecksumAlgorithm::Xxhash64,
ChecksumAlgorithm::Xxhash128,
];
for &payload_len in &[0usize, 1, 31, 33, 1024, 4096 + 37, 64 * 1024 + 19] {
let payload: Vec<u8> = (0..payload_len).map(|i| ((i * 19 + 5) % 253) as u8).collect();
for algorithm in all {
let mut one_shot = algorithm.into_impl();
one_shot.update(&payload);
let expected_raw = one_shot.finalize();
assert_eq!(expected_raw.len(), algorithm.raw_len());
let mut one_shot_hdr = algorithm.into_impl();
one_shot_hdr.update(&payload);
let expected_headers = one_shot_hdr.headers();
for &chunk_size in &[1usize, 31, 1021, 4096] {
if payload_len > 4096 && chunk_size == 1 {
continue;
}
let mut streaming: Box<dyn HttpChecksum> = algorithm.into_impl();
for chunk in payload.chunks(chunk_size) {
streaming.update(chunk);
}
let actual_headers = streaming.headers();
assert_eq!(
actual_headers.get(algorithm.http_header_name()),
expected_headers.get(algorithm.http_header_name()),
"{algorithm:?} streaming mismatch at payload_len={payload_len}, chunk_size={chunk_size}"
);
}
}
}
}
#[test]
#[ignore = "utility throughput microbenchmark; run via cargo test -p rustfs-checksums --lib --release -- --ignored --nocapture"]
fn microbench_checksums_streaming_chunks_and_header_encoding() {
use crate::http::HttpChecksum;
use std::hint::black_box;
use std::time::Instant;
let all = [
ChecksumAlgorithm::Crc32,
ChecksumAlgorithm::Crc32c,
ChecksumAlgorithm::Crc64Nvme,
ChecksumAlgorithm::Sha1,
ChecksumAlgorithm::Sha256,
ChecksumAlgorithm::Sha512,
ChecksumAlgorithm::Xxhash3,
ChecksumAlgorithm::Xxhash64,
ChecksumAlgorithm::Xxhash128,
];
let payload: Vec<u8> = (0..(256 * 1024)).map(|i| ((i * 19 + 5) % 253) as u8).collect();
for &chunk_size in &[4 * 1024usize, 64 * 1024, 256 * 1024] {
for algorithm in all {
let mut one_shot = algorithm.into_impl();
one_shot.update(&payload);
let expected_headers = one_shot.headers();
let iters = 8usize;
let t0 = Instant::now();
let mut last_headers = http::HeaderMap::new();
for _ in 0..iters {
let mut streaming: Box<dyn HttpChecksum> = algorithm.into_impl();
for chunk in payload.chunks(chunk_size) {
streaming.update(black_box(chunk));
}
last_headers = streaming.headers();
black_box(&last_headers);
}
let elapsed = t0.elapsed();
assert_eq!(
last_headers.get(algorithm.http_header_name()),
expected_headers.get(algorithm.http_header_name()),
"{algorithm:?} streaming chunk_size={chunk_size} header mismatch"
);
let total_mib = (payload.len() * iters) as f64 / (1024.0 * 1024.0);
let mibs = total_mib / elapsed.as_secs_f64().max(1e-9);
println!(
"[microbench][checksums] algo={algorithm:?} chunk_size={chunk_size}B throughput={mibs:.1} MiB/s (size={}B)",
algorithm.raw_len()
);
}
}
}
}
+255 -2
View File
@@ -21,12 +21,15 @@
#[cfg(test)]
mod tests {
use crate::common::{RustFSTestEnvironment, init_logging};
use crate::common::{RustFSTestEnvironment, admin_create_user, init_logging};
use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{BucketVersioningStatus, Delete, ObjectIdentifier, VersioningConfiguration};
use std::error::Error;
use tracing::info;
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
async fn create_bucket(client: &Client, bucket: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
client.create_bucket().bucket(bucket).send().await?;
Ok(())
@@ -90,7 +93,7 @@ mod tests {
Ok(())
}
/// Duplicate and repeated slashes after a leading slash collapse MinIO-style.
/// Duplicate and repeated slashes after a leading slash collapse MinIO-style across single and bulk operations.
#[tokio::test]
async fn test_put_object_with_duplicate_slashes_normalized() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -144,8 +147,258 @@ mod tests {
"GET after DELETE with raw key must return HTTP 404, got {error:?}"
);
assert_bulk_delete_normalizes_keys(&client).await?;
assert_bulk_delete_authorizes_normalized_keys(&env, &client).await?;
assert_bulk_delete_preserves_version_identity(&client).await?;
env.stop_server();
info!("Test completed successfully");
Ok(())
}
async fn assert_bulk_delete_normalizes_keys(client: &Client) -> TestResult {
let bucket = "test-bulk-leading-slash";
create_bucket(client, bucket).await?;
let cases = [
("/foo/bar", "foo/bar"),
("//keyname", "keyname"),
("/dir///sub//file", "dir/sub/file"),
("/folder///", "folder/"),
("/literal%2F+名", "literal%2F+名"),
("plain", "plain"),
];
let invalid = ["///", "/dir/../keep", "dir//keep"];
client
.put_object()
.bucket(bucket)
.key("keep")
.body(ByteStream::from_static(b"keep"))
.send()
.await?;
client
.put_object()
.bucket(bucket)
.key("dir/keep")
.body(ByteStream::from_static(b"keep"))
.send()
.await?;
for quiet in [false, true] {
for (raw, _) in cases {
client
.put_object()
.bucket(bucket)
.key(raw)
.body(ByteStream::from_static(b""))
.send()
.await?;
}
let listing = client.list_objects_v2().bucket(bucket).send().await?;
let stored: Vec<_> = listing.contents().iter().filter_map(|object| object.key()).collect();
let mut expected: Vec<_> = cases
.iter()
.map(|(_, normalized)| *normalized)
.chain(["dir/keep", "keep"])
.collect();
expected.sort_unstable();
assert_eq!(stored, expected, "PUT must store the exact normalized targets before bulk deletion");
let objects = cases
.iter()
.map(|(raw, _)| *raw)
.chain(invalid)
.map(|key| ObjectIdentifier::builder().key(key).build())
.collect::<Result<Vec<_>, _>>()?;
let delete = Delete::builder().set_objects(Some(objects)).quiet(quiet).build()?;
// A second identical batch also pins idempotent deletion of absent normalized keys.
for _ in 0..2 {
let output = client.delete_objects().bucket(bucket).delete(delete.clone()).send().await?;
let errors: Vec<_> = output.errors().iter().map(|error| (error.key(), error.code())).collect();
assert_eq!(
errors,
invalid.map(|key| (Some(key), Some("InvalidArgument"))),
"unexpected per-key errors: {output:?}"
);
let deleted: Vec<_> = output.deleted().iter().filter_map(|object| object.key()).collect();
let expected = if quiet {
vec![]
} else {
cases.iter().map(|(raw, _)| *raw).collect()
};
assert_eq!(deleted, expected, "responses must retain request keys, including directory slashes");
}
let listing = client.list_objects_v2().bucket(bucket).send().await?;
let remaining: Vec<_> = listing.contents().iter().filter_map(|object| object.key()).collect();
assert_eq!(
remaining,
["dir/keep", "keep"],
"bulk delete must remove normalized objects without resolving dot segments"
);
}
Ok(())
}
async fn assert_bulk_delete_authorizes_normalized_keys(env: &RustFSTestEnvironment, admin: &Client) -> TestResult {
let bucket = "test-bulk-slash-policy";
let user = "bulk-slash-user";
admin_create_user(env, user, "bulkSlashTestSecret123!").await?;
create_bucket(admin, bucket).await?;
for key in ["allowed/item", "protected/item"] {
admin
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"keep"))
.send()
.await?;
}
let policy = serde_json::json!({
"Version": "2012-10-17",
"Statement": [
{"Effect": "Allow", "Principal": {"AWS": [user]}, "Action": ["s3:DeleteObject"],
"Resource": [format!("arn:aws:s3:::{bucket}/*")]},
{"Effect": "Deny", "Principal": {"AWS": [user]}, "Action": ["s3:DeleteObject"],
"Resource": [format!("arn:aws:s3:::{bucket}/protected/*")]}
]
});
admin
.put_bucket_policy()
.bucket(bucket)
.policy(policy.to_string())
.send()
.await?;
let client = env.create_s3_client_with_credentials(user, "bulkSlashTestSecret123!");
let output = client
.delete_objects()
.bucket(bucket)
.delete(
Delete::builder()
.objects(ObjectIdentifier::builder().key("/allowed///item").build()?)
.objects(ObjectIdentifier::builder().key("/protected///item").build()?)
.build()?,
)
.send()
.await?;
assert_eq!(output.deleted().len(), 1, "allowed normalized key must be deleted: {output:?}");
assert_eq!(output.deleted()[0].key(), Some("/allowed///item"));
assert_eq!(output.errors().len(), 1, "normalized resource denial must apply: {output:?}");
assert_eq!(output.errors()[0].key(), Some("/protected///item"));
assert_eq!(output.errors()[0].code(), Some("AccessDenied"));
let listing = admin.list_objects_v2().bucket(bucket).send().await?;
let remaining: Vec<_> = listing.contents().iter().filter_map(|object| object.key()).collect();
assert_eq!(remaining, ["protected/item"], "only the authorized object may be removed");
let protected = admin.get_object().bucket(bucket).key("protected/item").send().await?;
assert_eq!(protected.body.collect().await?.into_bytes().as_ref(), b"keep");
Ok(())
}
async fn assert_bulk_delete_preserves_version_identity(client: &Client) -> TestResult {
let bucket = "test-bulk-slash-versions";
let raw = "/history///file";
let normalized = "history/file";
create_bucket(client, bucket).await?;
client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled)
.build(),
)
.send()
.await?;
let first = client
.put_object()
.bucket(bucket)
.key(raw)
.body(ByteStream::from_static(b"old"))
.send()
.await?;
let first_id = first.version_id().ok_or("first PUT omitted version ID")?;
let latest = client
.put_object()
.bucket(bucket)
.key(raw)
.body(ByteStream::from_static(b"latest"))
.send()
.await?;
let latest_id = latest.version_id().ok_or("latest PUT omitted version ID")?;
let invalid = client
.delete_objects()
.bucket(bucket)
.delete(
Delete::builder()
.objects(ObjectIdentifier::builder().key("///").build()?)
.build()?,
)
.send()
.await?;
assert!(invalid.deleted().is_empty(), "slash-only keys must not delete a versioned object");
assert_eq!(
invalid.errors().len(),
1,
"slash-only keys must remain invalid in versioned buckets: {invalid:?}"
);
assert_eq!(invalid.errors()[0].key(), Some("///"));
assert_eq!(invalid.errors()[0].code(), Some("InvalidArgument"));
let deleted = client
.delete_objects()
.bucket(bucket)
.delete(
Delete::builder()
.objects(ObjectIdentifier::builder().key(raw).version_id(first_id).build()?)
.build()?,
)
.send()
.await?;
assert!(deleted.errors().is_empty(), "historical delete failed: {deleted:?}");
assert_eq!(deleted.deleted().len(), 1);
assert_eq!(deleted.deleted()[0].key(), Some(raw));
assert_eq!(deleted.deleted()[0].version_id(), Some(first_id));
let listing = client.list_object_versions().bucket(bucket).send().await?;
assert_eq!(listing.versions().len(), 1, "only the latest data version should remain: {listing:?}");
assert_eq!(listing.versions()[0].version_id(), Some(latest_id));
let deleted = client
.delete_objects()
.bucket(bucket)
.delete(
Delete::builder()
.objects(ObjectIdentifier::builder().key(raw).build()?)
.build()?,
)
.send()
.await?;
assert!(deleted.errors().is_empty(), "delete marker creation failed: {deleted:?}");
assert_eq!(deleted.deleted().len(), 1);
assert_eq!(deleted.deleted()[0].key(), Some(raw));
assert_eq!(deleted.deleted()[0].delete_marker(), Some(true));
let marker = deleted.deleted()[0]
.delete_marker_version_id()
.ok_or("missing delete marker ID")?;
let listing = client.list_object_versions().bucket(bucket).send().await?;
assert_eq!(listing.delete_markers().len(), 1);
assert_eq!(listing.delete_markers()[0].key(), Some(normalized));
assert_eq!(listing.delete_markers()[0].version_id(), Some(marker));
let deleted = client
.delete_objects()
.bucket(bucket)
.delete(
Delete::builder()
.objects(ObjectIdentifier::builder().key(raw).version_id(marker).build()?)
.build()?,
)
.send()
.await?;
assert!(deleted.errors().is_empty(), "explicit marker removal failed: {deleted:?}");
assert_eq!(deleted.deleted().len(), 1);
assert_eq!(deleted.deleted()[0].key(), Some(raw));
assert_eq!(deleted.deleted()[0].version_id(), Some(marker));
let listing = client.list_object_versions().bucket(bucket).send().await?;
assert!(listing.delete_markers().is_empty());
let restored = client.get_object().bucket(bucket).key(normalized).send().await?;
assert_eq!(restored.version_id(), Some(latest_id));
assert_eq!(restored.body.collect().await?.into_bytes().as_ref(), b"latest");
Ok(())
}
}
+137 -56
View File
@@ -579,6 +579,7 @@ struct PreparedPartTxnReceipt {
dst_meta_path: PathBuf,
transaction_path: PathBuf,
meta: Bytes,
had_old_data: bool,
had_old_meta: bool,
}
@@ -586,6 +587,7 @@ struct PublishedPartTxnReceipt {
current_data_path: PathBuf,
current_meta_path: PathBuf,
transaction_path: PathBuf,
had_old_data: bool,
had_old_meta: bool,
}
@@ -11502,9 +11504,17 @@ impl LocalDisk {
admission: Option<multipart_staging::MultipartSourceAdmission>,
) -> Result<()> {
multipart_staging::reject_reserved_anchor(path)?;
if volume == RUSTFS_META_MULTIPART_BUCKET {
self.multipart_index.record_head(path, &buf).await?;
}
let fuse_with_admission = admission.is_some() && !crash_inject::is_armed(CrashPoint::MetaWriteAfterTmpBeforeRename, path);
let prepared_index = if volume == RUSTFS_META_MULTIPART_BUCKET {
if fuse_with_admission {
self.multipart_index.prepare_record_head(path, &buf).await
} else {
self.multipart_index.record_head(path, &buf).await?;
None
}
} else {
None
};
let volume_dir = self.io_get_bucket_path(volume)?;
let file_path = self.io_get_object_path(volume, path)?;
check_path_length(file_path.to_string_lossy().as_ref())?;
@@ -11520,11 +11530,14 @@ impl LocalDisk {
{
multipart_source_state::invalidate_active_multipart_source_head(admission.source());
}
if admission.is_some() && !crash_inject::is_armed(CrashPoint::MetaWriteAfterTmpBeforeRename, path) {
if fuse_with_admission {
let precondition = admission.as_ref().map(|a| a.precondition());
let root = self.publication_root.clone();
let lease = os::acquire_namespace_mutation_lease_with_owner(&file_path, namespace_owner).await;
os::run_blocking_namespace_operation_with_sync(lease, do_sync, move || {
if let Some(record) = prepared_index {
record.run_std()?;
}
super::fs::create_dir_all_std(&tmp_volume_dir)?;
#[cfg(test)]
run_owned_file_before_open(&tmp_file_path);
@@ -14425,6 +14438,7 @@ impl DiskAPI for LocalDisk {
if bucket == RUSTFS_META_MULTIPART_BUCKET && !paths.is_empty() {
let mut cached_parts = Vec::with_capacity(paths.len());
let mut all_cached = true;
let mut verified_source: Option<(&str, PathBuf)> = None;
for path_str in paths {
let Some(num) = Path::new(path_str)
.file_name()
@@ -14436,12 +14450,21 @@ impl DiskAPI for LocalDisk {
all_cached = false;
break;
};
let Ok(source) = self.io_get_object_open_path(bucket, multipart_staging::upload_scope_root(path_str, true))
else {
all_cached = false;
break;
let scope_root = multipart_staging::upload_scope_root(path_str, true);
let (source, head_already_verified) = match verified_source.as_ref() {
Some((prev_root, prev_source)) if *prev_root == scope_root => (prev_source, true),
_ => {
let Ok(source) = self.io_get_object_open_path(bucket, scope_root) else {
all_cached = false;
break;
};
verified_source = Some((scope_root, source));
(&verified_source.as_ref().expect("just initialized").1, false)
}
};
let Some(part) = multipart_source_state::get_active_multipart_source_part(&source, num) else {
let Some(part) =
multipart_source_state::get_active_multipart_source_part_with_head(source, num, head_already_verified)
else {
all_cached = false;
break;
};
@@ -14625,6 +14648,7 @@ impl DiskAPI for LocalDisk {
Ok(_) => return Ok(Err(DiskError::FileAccessDenied)),
Err(err) => return Ok(Err(to_file_error(err).into())),
}
let mut created_transaction_parent = false;
if let Some((integrity, source, destination, expected_len)) = integrity_prep {
let stat = match std::fs::symlink_metadata(&source) {
Ok(stat) => stat,
@@ -14633,6 +14657,7 @@ impl DiskAPI for LocalDisk {
if !stat.is_file() || stat.len() != expected_len {
return Ok(Err(DiskError::FileCorrupt));
}
let presynced_proof = take_presynced_tmp_file(&source);
let verify_and_sync = (|| -> std::io::Result<bool> {
let mut file = os::open_file_for_sync_std(&source)?;
let mut header = [0u8; rustfs_filemeta::shard_integrity::INDEX_HEADER_SIZE];
@@ -14640,7 +14665,7 @@ impl DiskAPI for LocalDisk {
if header != integrity.index_header() {
return Ok(false);
}
if durability.syncs_data_shards() && !take_presynced_tmp_file(&source) {
if durability.syncs_data_shards() && !presynced_proof {
file.sync_all()?;
}
Ok(true)
@@ -14650,7 +14675,13 @@ impl DiskAPI for LocalDisk {
Ok(false) => return Ok(Err(DiskError::FileCorrupt)),
Err(err) => return Ok(Err(to_file_error(err).into())),
}
let precondition = admission.as_ref().map(|check| check.precondition());
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
let has_hooks = os::prepared_publication_test_hooks::has_any_hooks();
#[cfg(not(all(any(test, feature = "test-util"), not(windows))))]
let has_hooks = false;
let precondition = has_hooks
.then(|| admission.as_ref().map(|check| check.precondition()))
.flatten();
// Publish the immutable generation before preparing the part switch.
// Rollback retains the old generation; an unreferenced new index is
// reclaimed with the upload directory if preparation is interrupted.
@@ -14667,12 +14698,13 @@ impl DiskAPI for LocalDisk {
) {
return Ok(Err(to_file_error(err).into()));
}
created_transaction_parent = same_parent;
}
let Some(transaction_parent) = transaction_path.parent() else {
return Ok(Err(DiskError::InvalidPath));
};
if let Err(err) = std::fs::create_dir_all(transaction_parent) {
if !created_transaction_parent && let Err(err) = std::fs::create_dir_all(transaction_parent) {
return Ok(Err(to_file_error(err).into()));
}
let staging_path = transaction_parent.join(format!(".part-txn-{}", Uuid::new_v4()));
@@ -14680,17 +14712,35 @@ impl DiskAPI for LocalDisk {
return Ok(Err(to_file_error(err).into()));
}
let prepare_result = (|| -> std::io::Result<bool> {
let prepare_result = (|| -> std::io::Result<(bool, bool)> {
let old_data_absent = staging_path.join(PART_TRANSACTION_OLD_DATA_ABSENT);
let old_meta_absent = staging_path.join(PART_TRANSACTION_OLD_META_ABSENT);
let had_old_data = snapshot_part_transaction_file(
&dst_file_path,
&staging_path.join(PART_TRANSACTION_OLD_DATA),
&staging_path.join(PART_TRANSACTION_OLD_DATA_ABSENT),
)?;
let had_old_meta = snapshot_part_transaction_file(
&dst_meta_path,
&staging_path.join(PART_TRANSACTION_OLD_META),
&staging_path.join(PART_TRANSACTION_OLD_META_ABSENT),
&old_data_absent,
)?;
let had_old_meta = match std::fs::symlink_metadata(&dst_meta_path) {
Ok(metadata) if metadata.is_file() => {
std::fs::hard_link(&dst_meta_path, staging_path.join(PART_TRANSACTION_OLD_META))?;
true
}
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(std::io::Error::new(ErrorKind::InvalidInput, "multipart transaction source is a symlink"));
}
Ok(_) => {
return Err(std::io::Error::new(ErrorKind::InvalidData, "multipart transaction source is not a file"));
}
Err(err) if err.kind() == ErrorKind::NotFound => {
if !had_old_data && std::fs::hard_link(&old_data_absent, &old_meta_absent).is_ok() {
false
} else {
std::fs::write(&old_meta_absent, [])?;
false
}
}
Err(err) => return Err(err),
};
let new_meta_path = staging_path.join(PART_TRANSACTION_NEW_META);
let mut new_meta = std::fs::OpenOptions::new()
@@ -14713,11 +14763,11 @@ impl DiskAPI for LocalDisk {
if sync_overwrite_backup {
os::fsync_dir_std(transaction_parent)?;
}
Ok(had_old_meta)
Ok((had_old_data, had_old_meta))
})();
let had_old_meta = match prepare_result {
Ok(had_old_meta) => had_old_meta,
let (had_old_data, had_old_meta) = match prepare_result {
Ok(flags) => flags,
Err(err) => {
let _ = remove_dir_all_if_exists(&staging_path);
return Ok(Err(to_file_error(err).into()));
@@ -14733,6 +14783,7 @@ impl DiskAPI for LocalDisk {
dst_meta_path,
transaction_path: transaction_path.clone(),
meta,
had_old_data,
had_old_meta,
},
);
@@ -14759,23 +14810,25 @@ impl DiskAPI for LocalDisk {
.io_get_object_open_path(volume, &txn_rel_path)
.ok()
.and_then(|txn_open| take_published_part_txn_receipt(&txn_open));
let (current_data_path, current_meta_path, transaction_path, skip_obsolete_read_on_commit) = match cached_published {
Some(entry) => (
entry.current_data_path,
entry.current_meta_path,
entry.transaction_path,
!entry.had_old_meta,
),
None => {
let (_, current_data_path) = self.io_get_bucket_and_object_path(volume, path)?;
let current_meta_path = self.io_get_object_path(volume, &format!("{path}.meta"))?;
let transaction_path = self.io_get_object_path(volume, &txn_rel_path)?;
for candidate in [&current_data_path, &current_meta_path, &transaction_path] {
check_path_length(candidate.to_string_lossy().as_ref())?;
let (current_data_path, current_meta_path, transaction_path, skip_obsolete_read_on_commit, cached_old_flags) =
match cached_published {
Some(entry) => (
entry.current_data_path,
entry.current_meta_path,
entry.transaction_path,
!entry.had_old_meta,
Some((entry.had_old_data, entry.had_old_meta)),
),
None => {
let (_, current_data_path) = self.io_get_bucket_and_object_path(volume, path)?;
let current_meta_path = self.io_get_object_path(volume, &format!("{path}.meta"))?;
let transaction_path = self.io_get_object_path(volume, &txn_rel_path)?;
for candidate in [&current_data_path, &current_meta_path, &transaction_path] {
check_path_length(candidate.to_string_lossy().as_ref())?;
}
(current_data_path, current_meta_path, transaction_path, false, None)
}
(current_data_path, current_meta_path, transaction_path, false)
}
};
};
let durability = effective_durability(volume);
let sync = durability.syncs_commit_metadata();
let part_number = current_data_path
@@ -14874,7 +14927,26 @@ impl DiskAPI for LocalDisk {
{
return Ok(Err(to_file_error(err).into()));
}
if let Err(err) = remove_dir_all_if_exists(&cleanup_path) {
if let Some((had_old_data, had_old_meta)) = cached_old_flags.filter(|_| action == PartTransactionAction::Commit) {
let old_data_name = if had_old_data {
PART_TRANSACTION_OLD_DATA
} else {
PART_TRANSACTION_OLD_DATA_ABSENT
};
let old_meta_name = if had_old_meta {
PART_TRANSACTION_OLD_META
} else {
PART_TRANSACTION_OLD_META_ABSENT
};
for name in [PART_TRANSACTION_NEW_META, old_data_name, old_meta_name] {
let _ = std::fs::remove_file(cleanup_path.join(name));
}
if std::fs::remove_dir(&cleanup_path).is_err()
&& let Err(err) = remove_dir_all_if_exists(&cleanup_path)
{
return Ok(Err(to_file_error(err).into()));
}
} else if let Err(err) = remove_dir_all_if_exists(&cleanup_path) {
return Ok(Err(to_file_error(err).into()));
}
Ok(Ok(true))
@@ -14946,7 +15018,7 @@ impl DiskAPI for LocalDisk {
dst_file_path,
dst_meta_path,
transaction_path,
preverified_meta_and_old_flag,
preverified_meta_and_old_flags,
) = match cached_prepared {
Some(entry) => (
entry.src_volume_dir,
@@ -14955,7 +15027,7 @@ impl DiskAPI for LocalDisk {
entry.dst_file_path,
Some(entry.dst_meta_path),
Some(entry.transaction_path),
Some(entry.had_old_meta),
Some((entry.had_old_data, entry.had_old_meta)),
),
None => {
let (src_volume_dir, src_file_path) = self.io_get_bucket_and_object_path(src_volume, src_path)?;
@@ -15052,7 +15124,7 @@ impl DiskAPI for LocalDisk {
let transaction_meta_path = transaction_path.join(PART_TRANSACTION_NEW_META);
let publish_meta_path = transaction_path.join(PART_TRANSACTION_PUBLISH_META);
if !cfg!(test)
&& preverified_meta_and_old_flag.is_some()
&& preverified_meta_and_old_flags.is_some()
&& publish_meta_path.is_file()
&& std::fs::metadata(&transaction_meta_path).is_ok_and(|m| m.is_file() && m.len() == meta.len() as u64)
{
@@ -15096,7 +15168,13 @@ impl DiskAPI for LocalDisk {
os::sync_file(&src_file_path).map_err(to_file_error)?;
}
let precondition = admission.as_ref().map(|check| check.precondition());
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
let has_hooks = os::prepared_publication_test_hooks::has_any_hooks();
#[cfg(not(all(any(test, feature = "test-util"), not(windows))))]
let has_hooks = false;
let precondition = has_hooks
.then(|| admission.as_ref().map(|check| check.precondition()))
.flatten();
os::rename_all_with_precondition_and_parent_sync_std(
&src_file_path,
@@ -15108,17 +15186,19 @@ impl DiskAPI for LocalDisk {
)
.map_err(to_file_error)?;
let dst_meta = lstat_std(&dst_file_path).map_err(to_file_error)?;
if src_is_dir != dst_meta.is_dir() {
warn!(
event = EVENT_DISK_LOCAL_RENAME_REJECTED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
reason = "dst_type_changed_after_rename",
path = ?dst_file_path,
"Disk local rename rejected"
);
return Err(DiskError::FileAccessDenied.into());
if cfg!(test) || src_is_dir {
let dst_meta = lstat_std(&dst_file_path).map_err(to_file_error)?;
if src_is_dir != dst_meta.is_dir() {
warn!(
event = EVENT_DISK_LOCAL_RENAME_REJECTED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
reason = "dst_type_changed_after_rename",
path = ?dst_file_path,
"Disk local rename rejected"
);
return Err(DiskError::FileAccessDenied.into());
}
}
if let Some(transaction_publish_meta) = transaction_publish_meta {
@@ -15149,8 +15229,8 @@ impl DiskAPI for LocalDisk {
}
}
}
if let (Some(had_old_meta), Some(txn_path), Some(meta_path)) =
(preverified_meta_and_old_flag, transaction_path.as_ref(), dst_meta_path.as_path().into())
if let (Some((had_old_data, had_old_meta)), Some(txn_path), Some(meta_path)) =
(preverified_meta_and_old_flags, transaction_path.as_ref(), dst_meta_path.as_path().into())
{
put_published_part_txn_receipt(
txn_path,
@@ -15158,6 +15238,7 @@ impl DiskAPI for LocalDisk {
current_data_path: dst_file_path,
current_meta_path: meta_path.to_path_buf(),
transaction_path: txn_path.clone(),
had_old_data,
had_old_meta,
},
);
+6 -1
View File
@@ -1461,7 +1461,12 @@ impl LocalDisk {
}
}
}
match publication_owned.finish_on_thread(sync_commit_metadata, !cfg!(test), staging_cleanup) {
match publication_owned.finish_on_thread(
sync_commit_metadata,
!cfg!(test),
staging_cleanup,
Some(&directory_guard_owned),
) {
Ok(result) => Ok(MultipartFusedCommitOutcome::Committed(Box::new(RenameDataResp {
multipart_source: Some(Box::new(result)),
old_data_dir: has_old_data_dir,
@@ -1829,7 +1829,9 @@ impl LocalDisk {
) -> Result<DeleteRecoveryInspection> {
self.reject_multipart_delete_recovery_mutation(volume, object)?;
if let Ok(root) = self.io_get_object_path(volume, object) {
let _ = take_prepared_delete_receipt(&root.join(transaction.to_string()));
let tx_dir = root.join(transaction.to_string());
let _ = take_prepared_delete_receipt(&tx_dir);
let _ = take_published_delete_receipt(&tx_dir);
}
let (mut observed, mut has_data_dir_work) = self
@@ -2493,8 +2495,8 @@ impl LocalDisk {
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(Ok(None)),
Err(err) => return Err(err),
};
type PreparedDeleteTuple = (Option<Uuid>, Option<Vec<u8>>, Vec<u8>, DeleteRecoveryJournal, bool);
let (old_dir, target, journal_raw, journal, sole_transaction) =
type PreparedDeleteTuple = (Option<Uuid>, Option<Vec<u8>>, Vec<u8>, DeleteRecoveryJournal, bool, bool, bool);
let (old_dir, target, journal_raw, journal, sole_transaction, destination_missing, old_data_dir_present) =
match (|| -> Result<Option<PreparedDeleteTuple>> {
let mut meta = FileMeta::load(&buf)?;
let mut deleted_headers: Vec<_> = meta
@@ -2529,7 +2531,11 @@ impl LocalDisk {
let target = (!meta.versions.is_empty()).then(|| meta.marshal_msg()).transpose()?;
let mut predecessors = BTreeSet::new();
let mut sole_transaction = true;
let mut destination_exists = false;
let mut old_data_dir_exists = false;
let mut scanned_file_path = false;
if let Ok(entries) = std::fs::read_dir(&file_path) {
scanned_file_path = true;
for (visited, entry) in
recovery_entries(entries, recovery_scope.as_ref().map(|scope| scope.budget.as_ref())).enumerate()
{
@@ -2542,13 +2548,19 @@ impl LocalDisk {
sole_transaction = false;
continue;
};
let Some(id) = Uuid::parse_str(name_str).ok().filter(|id| !id.is_nil() && *id != transaction)
else {
let Some(id) = Uuid::parse_str(name_str).ok().filter(|id| !id.is_nil()) else {
continue;
};
if id == transaction {
destination_exists = true;
continue;
}
if !entry.file_type().map_err(to_file_error)?.is_dir() {
return Err(DiskError::InvalidPath);
}
if old_dir == Some(id) && name_str == id.hyphenated().to_string() {
old_data_dir_exists = true;
}
let dir_path = entry.path();
let prepared = regular_bytes(&dir_path.join(PREPARED))?;
let receipt = regular_bytes(&dir_path.join(DELETE_QUORUM_COMMITTED_FILE))?;
@@ -2607,7 +2619,15 @@ impl LocalDisk {
predecessors: predecessors.into_iter().collect(),
};
let raw = journal_bytes(&journal)?;
Ok(Some((old_dir, target, raw, journal, sole_transaction)))
Ok(Some((
old_dir,
target,
raw,
journal,
sole_transaction,
scanned_file_path && !destination_exists,
scanned_file_path && old_data_dir_exists,
)))
})() {
Ok(Some(tuple)) => tuple,
Ok(None) => return Ok(Ok(None)),
@@ -2641,23 +2661,27 @@ impl LocalDisk {
if sync {
os::fsync_dir_std(&staged_dir)?;
}
match std::fs::symlink_metadata(&destination) {
Ok(metadata) if metadata.is_dir() => match std::fs::remove_dir(&destination) {
Ok(()) => {}
if !destination_missing {
match std::fs::symlink_metadata(&destination) {
Ok(metadata) if metadata.is_dir() => match std::fs::remove_dir(&destination) {
Ok(()) => {}
Err(err) if err.kind() == ErrorKind::NotFound => {}
Err(err) if is_dir_not_empty_error(&err) => return Ok(Err(DiskError::OutdatedXLMeta)),
Err(err) => return Err(err),
},
Ok(_) => return Ok(Err(DiskError::OutdatedXLMeta)),
Err(err) if err.kind() == ErrorKind::NotFound => {}
Err(err) if is_dir_not_empty_error(&err) => return Ok(Err(DiskError::OutdatedXLMeta)),
Err(err) => return Err(err),
},
Ok(_) => return Ok(Err(DiskError::OutdatedXLMeta)),
Err(err) if err.kind() == ErrorKind::NotFound => {}
Err(err) => return Err(err),
}
}
let staged_dir = staged.keep();
if let Err(err) = std::fs::rename(&staged_dir, &destination) {
let _ = std::fs::remove_dir_all(&staged_dir);
return Err(err);
}
let old_data_path = old_dir.map(|uuid| file_path.join(uuid.to_string())).filter(|p| p.is_dir());
let old_data_path = old_dir
.map(|uuid| file_path.join(uuid.to_string()))
.filter(|p| old_data_dir_present || p.is_dir());
if sync && let Err(err) = os::fsync_dir_std(&file_path) {
let _ = std::fs::remove_dir_all(&destination);
return Err(err);
@@ -2686,8 +2710,15 @@ impl LocalDisk {
// A provisional head must stay outside the published recovery transaction.
let target_in_rollback = destination.join(TARGET);
let linked_xl = staging_root.join(format!("delete-xl-{transaction}"));
let _ = std::fs::remove_file(&linked_xl);
let write_res = if std::fs::hard_link(&target_in_rollback, &linked_xl).is_ok() {
let linked = match std::fs::hard_link(&target_in_rollback, &linked_xl) {
Ok(()) => true,
Err(err) if err.kind() == ErrorKind::AlreadyExists => {
let _ = std::fs::remove_file(&linked_xl);
std::fs::hard_link(&target_in_rollback, &linked_xl).is_ok()
}
Err(_) => false,
};
let write_res = if linked {
let rename_res = std::fs::rename(&linked_xl, &xl_path);
if rename_res.is_err() {
let _ = std::fs::remove_file(&linked_xl);
@@ -3043,14 +3074,19 @@ impl LocalDisk {
}
let data_dirs = journal.data_dirs.clone();
let has_original = journal.original_digest.is_some();
let has_target = journal.target_digest.is_some();
let journal_for_receipt = cached_journal_raw.is_none().then(|| journal.clone());
let lease = os::acquire_namespace_mutation_lease_with_owner(&receipt_path, owner).await;
let prune_paths: Vec<_> = rollback
.ancestors()
.take_while(|dir| *dir != bucket_path.as_path() && dir.starts_with(&bucket_path))
.map(Path::to_path_buf)
.collect();
let prune_paths: Vec<_> = if has_target {
vec![rollback.clone()]
} else {
rollback
.ancestors()
.take_while(|dir| *dir != bucket_path.as_path() && dir.starts_with(&bucket_path))
.map(Path::to_path_buf)
.collect()
};
let res = os::run_blocking_namespace_operation_with_sync(lease, sync, move || -> std::io::Result<()> {
let persist_receipt = |finished: bool| -> std::io::Result<()> {
@@ -3131,23 +3167,53 @@ impl LocalDisk {
if had_physical_data_dirs && sync && root.is_dir() {
os::fsync_dir_std(&root)?;
}
for name in [STORAGE_FORMAT_FILE_BACKUP, DELETE_MARKER_ROLLBACK_FILE] {
if let Err(err) = ignore_not_found(std::fs::remove_file(rollback.join(name))) {
let fast_cleanup = cached_journal_raw.is_some();
if fast_cleanup {
let backup_or_marker = if has_original {
Some(STORAGE_FORMAT_FILE_BACKUP)
} else if has_target {
Some(DELETE_MARKER_ROLLBACK_FILE)
} else {
None
};
if let Some(name) = backup_or_marker
&& let Err(err) = ignore_not_found(std::fs::remove_file(rollback.join(name)))
{
if has_target || had_physical_data_dirs {
let _ = persist_receipt(true);
}
return Err(err);
}
}
for name in [PREPARED, TARGET, FINISHED] {
let path = rollback.join(name);
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
os::prepared_publication_test_hooks::run(os::prepared_publication_test_hooks::Stage::Remove, &path);
if let Err(err) = ignore_not_found(std::fs::remove_file(path)) {
if has_target || had_physical_data_dirs {
let _ = persist_receipt(true);
for name in [PREPARED, FINISHED].into_iter().chain(has_target.then_some(TARGET)) {
let path = rollback.join(name);
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
os::prepared_publication_test_hooks::run(os::prepared_publication_test_hooks::Stage::Remove, &path);
if let Err(err) = ignore_not_found(std::fs::remove_file(path)) {
if has_target || had_physical_data_dirs {
let _ = persist_receipt(true);
}
return Err(err);
}
}
} else {
for name in [STORAGE_FORMAT_FILE_BACKUP, DELETE_MARKER_ROLLBACK_FILE] {
if let Err(err) = ignore_not_found(std::fs::remove_file(rollback.join(name))) {
if has_target || had_physical_data_dirs {
let _ = persist_receipt(true);
}
return Err(err);
}
}
for name in [PREPARED, TARGET, FINISHED] {
let path = rollback.join(name);
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
os::prepared_publication_test_hooks::run(os::prepared_publication_test_hooks::Stage::Remove, &path);
if let Err(err) = ignore_not_found(std::fs::remove_file(path)) {
if has_target || had_physical_data_dirs {
let _ = persist_receipt(true);
}
return Err(err);
}
return Err(err);
}
}
if sync && let Err(err) = os::fsync_dir_std(&rollback) {
@@ -429,7 +429,7 @@ impl IndexOwner {
os::replace_multipart_source_metadata(&marker, b"1", &self.base, &root)?;
os::sync_multipart_owned_record_parents(&self.base.join(".rustfs.sys"), &directory, true, &self.base, &root)?;
}
self.ensure_current_root()
Ok(())
}
fn ensure_current_root(&self) -> io::Result<()> {
@@ -547,13 +547,10 @@ impl IndexOwner {
Ok(())
}
pub(super) async fn record_head(self: &Arc<Self>, path: &str, bytes: &[u8]) -> Result<()> {
let Some(upload) = path
pub(super) async fn prepare_record_head(self: &Arc<Self>, path: &str, bytes: &[u8]) -> Option<PreparedHeadRecord> {
let upload = path
.strip_suffix("/xl.meta")
.filter(|upload| canonical_upload(upload).is_some())
else {
return Ok(());
};
.filter(|upload| canonical_upload(upload).is_some())?;
let info = match get_file_info(
bytes,
RUSTFS_META_MULTIPART_BUCKET,
@@ -568,10 +565,33 @@ impl IndexOwner {
Ok(info) => info,
Err(_) => {
self.disabled.store(true, Ordering::Release);
return Ok(());
return None;
}
};
self.record_info(upload, &info).await
let marker = info
.metadata
.get(crate::disk::multipart_index::RUSTFS_MULTIPART_BUCKET_KEY)
.map(|bucket| {
self.base
.join(INDEX)
.join(sha(bucket))
.join(upload.split('/').next().unwrap_or_default())
})
.unwrap_or_else(|| self.base.join(INDEX));
let lease = os::acquire_namespace_mutation_lease_with_owner(&marker, None).await;
Some(PreparedHeadRecord {
owner: Arc::clone(self),
upload: upload.to_owned(),
info,
_lease: lease,
})
}
pub(super) async fn record_head(self: &Arc<Self>, path: &str, bytes: &[u8]) -> Result<()> {
let Some(prepared) = self.prepare_record_head(path, bytes).await else {
return Ok(());
};
prepared.run().await
}
async fn page(self: &Arc<Self>, bucket: &str, marker: Option<&str>, disk_id: Uuid) -> Result<MultipartIndexPage> {
@@ -693,6 +713,37 @@ impl IndexOwner {
}
}
pub(super) struct PreparedHeadRecord {
owner: Arc<IndexOwner>,
upload: String,
info: FileInfo,
_lease: Arc<os::NamespaceMutationLease>,
}
impl PreparedHeadRecord {
pub(super) fn run_std(self) -> io::Result<()> {
let Self {
owner,
upload,
info,
_lease,
} = self;
match owner.record(&upload, &info) {
Err(error) if error.kind() == io::ErrorKind::InvalidData => {
owner.disabled.store(true, Ordering::Release);
Ok(())
}
result => result,
}
}
async fn run(self) -> Result<()> {
let lease = Arc::clone(&self._lease);
os::run_blocking_namespace_operation_with_sync(lease, true, move || self.run_std()).await?;
Ok(())
}
}
impl LocalDisk {
#[cfg(any(test, feature = "test-util"))]
pub(in crate::disk) async fn wait_for_multipart_index_bootstrap(&self) {
@@ -428,7 +428,6 @@ impl LocalDisk {
let root = store.publication_root.clone();
let lease = os::acquire_namespace_mutation_lease_with_owner(&destination, Some(store.lease.clone())).await;
os::run_blocking_namespace_operation_with_sync(lease, true, move || {
permit.validate()?;
os::rename_multipart_source_owned_std(&source, &destination, &base, &root, || permit.validate())
})
.await?;
@@ -295,10 +295,17 @@ pub(super) fn put_active_multipart_source_head(source: impl AsRef<Path>, raw: By
}
fn stamp_matches_active_disk(source: &Path, expected_head_stamp: FileStamp) -> bool {
FileStamp::from_path(&source.join(STORAGE_FORMAT_FILE)) == Some(expected_head_stamp)
&& !source.join(STATE).exists()
&& !source.join(crate::disk::MULTIPART_RECOVERY_DIR).exists()
&& !source.join(".mpu-completions").exists()
if FileStamp::from_path(&source.join(STORAGE_FORMAT_FILE)) != Some(expected_head_stamp) {
return false;
}
#[cfg(test)]
if source.join(STATE).exists()
|| source.join(crate::disk::MULTIPART_RECOVERY_DIR).exists()
|| source.join(".mpu-completions").exists()
{
return false;
}
true
}
pub(super) fn get_active_multipart_source_head(source: &Path) -> Option<(Bytes, FileInfo)> {
@@ -375,7 +382,11 @@ pub(super) fn finish_active_multipart_part_transaction(
ACTIVE_MULTIPART_SOURCE_HEADS.remove_if_mut(source, |entry| !entry.unsettled_parts.remove(&part_number));
}
pub(super) fn get_active_multipart_source_part(source: &Path, part_number: usize) -> Option<rustfs_filemeta::ObjectPartInfo> {
pub(super) fn get_active_multipart_source_part_with_head(
source: &Path,
part_number: usize,
head_already_verified: bool,
) -> Option<rustfs_filemeta::ObjectPartInfo> {
let (head_stamp, data_dir, expected_stamp, part) = ACTIVE_MULTIPART_SOURCE_HEADS
.with_get(source, |entry| {
if !entry.unsettled_parts.is_empty() {
@@ -386,7 +397,7 @@ pub(super) fn get_active_multipart_source_part(source: &Path, part_number: usize
Some((entry.head_stamp, data_dir, *expected_stamp, part.clone()))
})
.flatten()?;
if !stamp_matches_active_disk(source, head_stamp) {
if !head_already_verified && !stamp_matches_active_disk(source, head_stamp) {
ACTIVE_MULTIPART_SOURCE_HEADS.remove(source);
return None;
}
@@ -609,25 +620,45 @@ impl SourceStore {
raw: &[u8],
terminal: Option<&Path>,
sync_source_before: bool,
) -> std::io::Result<()> {
Self::persist_state_with_guard_std(source, base, root, raw, terminal, sync_source_before, None)
}
fn persist_state_with_guard_std(
source: &Path,
base: &Path,
root: &os::PublicationRoot,
raw: &[u8],
terminal: Option<&Path>,
sync_source_before: bool,
directory_guard: Option<&os::RenameCommitGuard>,
) -> std::io::Result<()> {
invalidate_active_multipart_source_head(source);
#[cfg(unix)]
let mut pinned = os::pin_multipart_source_directory(source, base, root)?;
#[cfg(not(unix))]
#[cfg(not(target_os = "linux"))]
let _pinned = os::pin_multipart_source_directory(source, base, root)?;
if sync_source_before {
#[cfg(unix)]
#[cfg(target_os = "linux")]
{
let mut pinned = os::pin_multipart_source_directory(source, base, root)?;
let opened = std::fs::File::from(pinned.pop().ok_or_else(|| IoError::other("empty source directory guard"))?);
os::fsync_directory_handle_std(source, Some(&opened))?;
}
#[cfg(all(unix, not(target_os = "linux")))]
{
let opened = std::fs::File::from(
_pinned
.last()
.ok_or_else(|| IoError::other("empty source directory guard"))?
.try_clone()?,
);
os::fsync_directory_handle_std(source, Some(&opened))?;
}
#[cfg(not(unix))]
os::fsync_dir_std(source)?;
}
if let Some(path) = terminal {
os::replace_multipart_source_metadata(path, raw, base, root)?;
}
os::replace_multipart_source_metadata(&source.join(STATE), raw, base, root)
os::replace_multipart_source_metadata_with_guard(&source.join(STATE), raw, base, root, directory_guard)
}
async fn write_owned_backup_and_persist(&self, owner: Uuid, target: bool, bytes: &[u8], state: &SourceState) -> Result<()> {
if owner.is_nil() {
@@ -724,17 +755,39 @@ impl SourceStore {
bytes: &[u8],
sync_source_root: bool,
) -> std::io::Result<()> {
Self::write_owned_backup_inner_std(source, base, root, owner, target, bytes, sync_source_root, false, true, false)
}
#[expect(clippy::too_many_arguments)]
fn write_owned_backup_inner_std(
source: &Path,
base: &Path,
root: &os::PublicationRoot,
owner: Uuid,
target: bool,
bytes: &[u8],
sync_source_root: bool,
fresh_owner: bool,
sync_parents: bool,
source_already_pinned: bool,
) -> std::io::Result<()> {
invalidate_active_multipart_source_head(source);
let path = if target {
target_backup_path(source, owner)
} else {
original_path(source, owner)
};
let _pin = os::pin_multipart_source_directory(source, base, root)?;
let _pin = if source_already_pinned {
None
} else {
Some(os::pin_multipart_source_directory(source, base, root)?)
};
let parent = path
.parent()
.ok_or_else(|| std::io::Error::other("source backup has no parent"))?;
let _parents = os::mkdir_all_below_existing_base_std(parent, base, root)?;
let existed = if target {
let existed = if fresh_owner {
false
} else if target {
match os::observe_multipart_source_metadata(&path, base, root, bytes.len() as u64) {
Ok((length, sha)) if length == bytes.len() as u64 && sha == raw_sha(bytes) => true,
Ok(_) => {
@@ -754,7 +807,10 @@ impl SourceStore {
if !existed {
os::replace_multipart_source_metadata(&path, bytes, base, root)?;
}
os::sync_multipart_owned_record_parents_options(source, parent, existed, sync_source_root, base, root)
if sync_parents {
os::sync_multipart_owned_record_parents_options(source, parent, existed, sync_source_root, base, root)?;
}
Ok(())
}
pub async fn replace_head(&self, bytes: &[u8]) -> Result<()> {
self.multipart_index
@@ -2024,11 +2080,41 @@ impl NativeMultipartPublication {
}
let setup_res: std::io::Result<()> = (|| {
if let Some((original_bytes, directory, anchor_bytes)) = deferred_initial {
SourceStore::write_owned_backup_std(source, base, root, backup_owner, false, &original_bytes, false)?;
SourceStore::write_owned_backup_inner_std(
source,
base,
root,
backup_owner,
false,
&original_bytes,
false,
true,
old_metadata.is_none(),
true,
)?;
if let Some(previous) = old_metadata {
SourceStore::write_owned_backup_std(source, base, root, backup_owner, true, previous, false)?;
SourceStore::write_owned_backup_inner_std(
source,
base,
root,
backup_owner,
true,
previous,
false,
true,
true,
true,
)?;
}
SourceStore::persist_state_std(source, base, root, &next_state_raw, terminal.as_deref(), false)?;
SourceStore::persist_state_with_guard_std(
source,
base,
root,
&next_state_raw,
terminal.as_deref(),
false,
Some(directory_guard),
)?;
let _parent = os::mkdir_all_below_existing_base_std(&directory, base, root)?;
os::replace_multipart_source_metadata(&directory.join(RETIREMENT_ANCHOR), &anchor_bytes, base, root)?;
#[cfg(target_os = "linux")]
@@ -2037,9 +2123,28 @@ impl NativeMultipartPublication {
}
} else {
if let Some(previous) = old_metadata {
SourceStore::write_owned_backup_std(source, base, root, backup_owner, true, previous, false)?;
SourceStore::write_owned_backup_inner_std(
source,
base,
root,
backup_owner,
true,
previous,
false,
false,
true,
true,
)?;
}
SourceStore::persist_state_std(source, base, root, &next_state_raw, terminal.as_deref(), false)?;
SourceStore::persist_state_with_guard_std(
source,
base,
root,
&next_state_raw,
terminal.as_deref(),
false,
Some(directory_guard),
)?;
}
Ok(())
})();
@@ -2269,6 +2374,7 @@ impl NativeMultipartPublication {
target_already_synced: bool,
fused_in_place: bool,
staging_cleanup: Option<(PathBuf, Arc<std::sync::atomic::AtomicBool>)>,
directory_guard: Option<&os::RenameCommitGuard>,
) -> Result<MultipartPublicationResult> {
let mut state = self.state.lock().clone();
let expected_state_raw = encode(&state)?;
@@ -2354,7 +2460,15 @@ impl NativeMultipartPublication {
})?;
}
}
SourceStore::persist_state_std(source, base, root, &next_state_raw, terminal.as_deref(), !fused_in_place)?;
SourceStore::persist_state_with_guard_std(
source,
base,
root,
&next_state_raw,
terminal.as_deref(),
!fused_in_place,
directory_guard,
)?;
if let Some((io_path, cleaned)) = staging_cleanup {
match std::fs::remove_dir(&io_path) {
Ok(()) => cleaned.store(true, std::sync::atomic::Ordering::Release),
@@ -2387,7 +2501,7 @@ impl NativeMultipartPublication {
) -> Result<MultipartPublicationResult> {
let this = Arc::clone(self);
os::run_blocking_namespace_operation_with_sync(target_lease, true, move || {
this.finish_on_thread(target_already_synced, false, staging_cleanup)
this.finish_on_thread(target_already_synced, false, staging_cleanup, None)
.map_err(IoError::other)
})
.await
@@ -455,8 +455,11 @@ impl LocalDisk {
}
let meta_name = format!("part.{}.meta", part.number);
let actual = if let Some(cached_part) =
super::multipart_source_state::get_active_multipart_source_part(&source_object, part.number)
{
super::multipart_source_state::get_active_multipart_source_part_with_head(
&source_object,
part.number,
from_active_cache,
) {
cached_part
} else {
if pinned_source_data.is_none() {
+95 -41
View File
@@ -611,6 +611,10 @@ pub(crate) mod prepare_rename_test_hooks {
}
}
pub(crate) fn has_after_component_opened() -> bool {
!AFTER_COMPONENT_OPENED.lock().is_empty()
}
/// Hooks keyed by a directory component name, run before a Windows
/// directory pin retries a transient sharing conflict on it.
#[cfg(windows)]
@@ -3804,7 +3808,7 @@ pub(crate) fn rename_all_with_precondition_and_parent_sync_std(
#[cfg(all(test, not(windows)))]
prepared_publication_test_hooks::run_rename_destination(source, destination);
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
{
if prepared_publication_test_hooks::has_any_hooks() {
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Rename, source);
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Rename, destination);
if let Some(check) = precondition {
@@ -7198,6 +7202,39 @@ pub(crate) fn pin_multipart_source_directory(
}
}
fn same_parent_commit_guard(
parent: &Path,
base: &Path,
publication_root: &PublicationRoot,
directory_guard: Option<&RenameCommitGuard>,
) -> io::Result<RenameCommitGuard> {
#[cfg(target_os = "linux")]
if let Some(directory_guard) = directory_guard.filter(|g| g.directories.source_path == parent) {
let dir = &directory_guard.directories.source;
return Ok(RenameCommitGuard {
directories: Arc::new(LinuxCommitDirectories {
root: directory_guard.directories.root.clone(),
source_path: parent.to_path_buf(),
destination_path: parent.to_path_buf(),
source: LinuxCommitDirectory {
file: Arc::clone(&dir.file),
device: dir.device,
inode: dir.inode,
},
destination: std::sync::OnceLock::from(LinuxCommitDirectory {
file: Arc::clone(&dir.file),
device: dir.device,
inode: dir.inode,
}),
destination_base: base.to_path_buf(),
}),
});
}
#[cfg(not(target_os = "linux"))]
let _ = directory_guard;
prepare_rename_commit_guard(parent, parent, base, publication_root)
}
/// A small control record always gets its own inode and an atomic replacement.
/// The caller owns the upload SourceRW and keeps its namespace lease alive.
pub(crate) fn replace_multipart_source_metadata(
@@ -7205,11 +7242,21 @@ pub(crate) fn replace_multipart_source_metadata(
bytes: &[u8],
base: &Path,
publication_root: &PublicationRoot,
) -> io::Result<()> {
replace_multipart_source_metadata_with_guard(path, bytes, base, publication_root, None)
}
pub(crate) fn replace_multipart_source_metadata_with_guard(
path: &Path,
bytes: &[u8],
base: &Path,
publication_root: &PublicationRoot,
directory_guard: Option<&RenameCommitGuard>,
) -> io::Result<()> {
let parent = path.parent().ok_or_else(|| io::Error::other("MPU record has no parent"))?;
#[cfg(not(target_os = "linux"))]
let _pinned = pin_multipart_source_directory(parent, base, publication_root)?;
let guard = prepare_rename_commit_guard(parent, parent, base, publication_root)?;
let guard = same_parent_commit_guard(parent, base, publication_root, directory_guard)?;
let staged = parent.join(format!(".mpu-record-{}", uuid::Uuid::new_v4()));
let mut prepared = create_prepared_rename_source_with_commit_guard(&staged, path, &guard)?;
prepared.write_all(bytes, true)?;
@@ -7458,37 +7505,9 @@ pub(crate) fn install_multipart_prepared_head(
if original_path.parent() != Some(parent) || original_path == canonical_head {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "MPU proxy must be a private sibling"));
}
#[cfg(target_os = "linux")]
let guard = if let Some(directory_guard) = directory_guard.filter(|g| g.directories.source_path == parent) {
let dir = &directory_guard.directories.source;
RenameCommitGuard {
directories: Arc::new(LinuxCommitDirectories {
root: directory_guard.directories.root.clone(),
source_path: parent.to_path_buf(),
destination_path: parent.to_path_buf(),
source: LinuxCommitDirectory {
file: Arc::clone(&dir.file),
device: dir.device,
inode: dir.inode,
},
destination: std::sync::OnceLock::from(LinuxCommitDirectory {
file: Arc::clone(&dir.file),
device: dir.device,
inode: dir.inode,
}),
destination_base: base.to_path_buf(),
}),
}
} else {
prepare_rename_commit_guard(parent, parent, base, publication_root)?
};
#[cfg(not(target_os = "linux"))]
let _pinned = pin_multipart_source_directory(parent, base, publication_root)?;
#[cfg(not(target_os = "linux"))]
let guard = {
let _ = directory_guard;
prepare_rename_commit_guard(parent, parent, base, publication_root)?
};
let guard = same_parent_commit_guard(parent, base, publication_root, directory_guard)?;
rename_prepared_source_std(&prepared, &original_path, canonical_head, base, publication_root, &guard)?;
prepared.path = canonical_head.to_path_buf();
fsync_commit_directory_limited_std(parent, &guard)?;
@@ -7790,8 +7809,7 @@ pub(crate) fn hardlink_multipart_source_files(
}
#[cfg(unix)]
{
use rustix::fs::{AtFlags, Mode, OFlags, linkat, openat, unlinkat};
use std::os::unix::fs::MetadataExt;
use rustix::fs::{AtFlags, FileType, linkat, statat, unlinkat};
#[cfg(test)]
let source_path = source.to_path_buf();
#[cfg(test)]
@@ -7806,20 +7824,26 @@ pub(crate) fn hardlink_multipart_source_files(
.ok_or_else(|| io::Error::other("missing multipart attempt directory"))?;
#[cfg(test)]
prepared_publication_test_hooks::run_hardlink_destination(&source_path, &destination_path);
let flags = OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
for name in names {
if cancelled() {
return Err(io::Error::new(io::ErrorKind::Interrupted, "multipart staging cancelled"));
}
let original = std::fs::File::from(openat(source, name.as_str(), flags, Mode::empty())?);
let expected = original.metadata()?;
if !expected.is_file() {
let expected = statat(source, name.as_str(), AtFlags::SYMLINK_NOFOLLOW)?;
let expected_type = FileType::from_raw_mode(expected.st_mode);
if expected_type.is_symlink() {
return Err(io::Error::from_raw_os_error(libc::ELOOP));
}
if !expected_type.is_file() {
return Err(io::Error::new(io::ErrorKind::InvalidData, "multipart shard is not a regular file"));
}
linkat(source, name.as_str(), destination, name.as_str(), AtFlags::empty())?;
let linked = std::fs::File::from(openat(destination, name.as_str(), flags, Mode::empty())?);
let actual = linked.metadata()?;
if actual.dev() != expected.dev() || actual.ino() != expected.ino() {
let actual = statat(destination, name.as_str(), AtFlags::SYMLINK_NOFOLLOW)?;
if !FileType::from_raw_mode(actual.st_mode).is_file()
|| actual.st_dev != expected.st_dev
|| actual.st_ino != expected.st_ino
|| actual.st_size != expected.st_size
|| actual.st_mtime != expected.st_mtime
{
let _ = unlinkat(destination, name.as_str(), AtFlags::empty());
return Err(io::Error::new(io::ErrorKind::InvalidData, "multipart shard changed during staging"));
}
@@ -8240,7 +8264,37 @@ pub(crate) fn mkdir_all_below_existing_base_std(
let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
let mode = Mode::RWXU | Mode::RWXG | Mode::RWXO;
#[cfg(target_os = "linux")]
let base = publication_root.open_directory(base_dir)?.into();
let base = {
use rustix::fs::{ResolveFlags, openat2};
use std::os::fd::AsFd;
let owned_base = (base_dir != publication_root.io_path.as_path() && base_dir != publication_root.path.as_path())
.then(|| publication_root.open_directory(base_dir).map(std::os::fd::OwnedFd::from))
.transpose()?;
#[cfg(test)]
let has_hook = prepare_rename_test_hooks::has_after_component_opened();
#[cfg(not(test))]
let has_hook = false;
if !has_hook {
let base_fd = owned_base
.as_ref()
.map_or_else(|| publication_root.directory.as_fd(), AsFd::as_fd);
let target = if relative.as_os_str().is_empty() {
Path::new(".")
} else {
relative
};
match openat2(base_fd, target, flags, Mode::empty(), ResolveFlags::BENEATH | ResolveFlags::NO_SYMLINKS) {
Ok(leaf) => return Ok(vec![leaf]),
Err(Errno::NOENT | Errno::NOSYS) => {}
Err(err) => return Err(err.into()),
}
}
match owned_base {
Some(base) => base,
None => publication_root.open_directory(base_dir)?.into(),
}
};
#[cfg(not(target_os = "linux"))]
let base = {
// The configured endpoint is trusted; every component beneath it
+3 -2
View File
@@ -917,9 +917,10 @@ mod tests {
#[test]
fn test_configure_request_development_defaults_require_opt_in() {
let key_dir = std::env::temp_dir().join("kms-key-dir");
let local_raw = serde_json::json!({
"backend_type": "local",
"key_dir": "/tmp/kms-key-dir"
"key_dir": key_dir
});
let request: ConfigureKmsRequest = serde_json::from_value(local_raw).expect("local request should deserialize");
let config = request.to_kms_config();
@@ -927,7 +928,7 @@ mod tests {
let local_opt_in_raw = serde_json::json!({
"backend_type": "local",
"key_dir": "/tmp/kms-key-dir",
"key_dir": key_dir,
"allow_insecure_dev_defaults": true
});
let request: ConfigureKmsRequest = serde_json::from_value(local_opt_in_raw).expect("local request should deserialize");
+5 -1
View File
@@ -1608,10 +1608,14 @@ mod tests {
assert!(config.validate().is_err());
assert!(config.with_insecure_development_defaults().validate().is_ok());
#[cfg(windows)]
let production_key_dir = PathBuf::from(r"C:\rustfs\kms");
#[cfg(not(windows))]
let production_key_dir = PathBuf::from("/var/lib/rustfs/kms");
let production_config = KmsConfig {
backend: KmsBackend::Local,
backend_config: BackendConfig::Local(LocalConfig {
key_dir: PathBuf::from("/var/lib/rustfs/kms"),
key_dir: production_key_dir,
master_key: Some("production-master-key".to_string()),
file_permissions: Some(0o600),
}),
+5 -1
View File
@@ -336,7 +336,11 @@ async fn invalid_configurations_are_rejected_before_anything_starts() {
assert_configuration_error(relative.validate(), "must be an absolute path");
// Production-shaped Local config: no master key is refused without opt-in.
let production_without_master_key = KmsConfig::local("/var/lib/rustfs/kms-behavior".into());
#[cfg(windows)]
let production_key_dir = r"C:\rustfs\kms-behavior";
#[cfg(not(windows))]
let production_key_dir = "/var/lib/rustfs/kms-behavior";
let production_without_master_key = KmsConfig::local(production_key_dir.into());
assert_configuration_error(production_without_master_key.validate(), "requires a master key");
// ...and so is a key directory under the process temp dir.
+119
View File
@@ -1873,4 +1873,123 @@ mod tests {
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert!(err.to_string().contains("invalid checksum value"), "unexpected error: {err}");
}
fn software_crc32_bitwise(reflected_poly: u32, data: &[u8]) -> [u8; 4] {
let mut crc = !0u32;
for &b in data {
crc ^= u32::from(b);
for _ in 0..8 {
let mask = (crc & 1).wrapping_neg();
crc = (crc >> 1) ^ (reflected_poly & mask);
}
}
(!crc).to_be_bytes()
}
fn software_crc64_nvme_bitwise(data: &[u8]) -> [u8; 8] {
let reflected_poly = super::CRC64_NVME_POLYNOMIAL.reverse_bits();
let mut crc = !0u64;
for &b in data {
crc ^= u64::from(b);
for _ in 0..8 {
let mask = (crc & 1).wrapping_neg();
crc = (crc >> 1) ^ (reflected_poly & mask);
}
}
(!crc).to_be_bytes()
}
#[test]
fn rio_crc_simd_matches_bitwise_scalar_across_tail_and_vector_boundaries() {
for &size in &[
0usize,
1,
7,
8,
15,
16,
17,
31,
32,
33,
63,
64,
65,
127,
128,
129,
255,
256,
257,
1024,
4096 + 17,
64 * 1024,
] {
let payload: Vec<u8> = (0..size).map(|i| ((i * 31 + 17) % 251) as u8).collect();
let crc32_simd = Checksum::new_from_data(ChecksumType::CRC32, &payload).expect("crc32");
let crc32c_simd = Checksum::new_from_data(ChecksumType::CRC32C, &payload).expect("crc32c");
let crc64_simd = Checksum::new_from_data(ChecksumType::CRC64_NVME, &payload).expect("crc64nvme");
let crc32_sw = software_crc32_bitwise(0xEDB88320, &payload);
let crc32c_sw = software_crc32_bitwise(0x82F63B78, &payload);
let crc64_sw = software_crc64_nvme_bitwise(&payload);
assert_eq!(crc32_simd.raw.as_slice(), &crc32_sw, "CRC32 SIMD vs scalar mismatch at {size}B");
assert_eq!(crc32c_simd.raw.as_slice(), &crc32c_sw, "CRC32C SIMD vs scalar mismatch at {size}B");
assert_eq!(crc64_simd.raw.as_slice(), &crc64_sw, "CRC64_NVME SIMD vs scalar mismatch at {size}B");
}
}
#[test]
#[ignore = "utility throughput microbenchmark; run via cargo test -p rustfs-rio --lib --release -- --ignored --nocapture"]
fn microbench_rio_checksum_simd_vs_scalar_and_all_base_algorithms() {
use std::hint::black_box;
use std::io::Write as _;
use std::time::Instant;
for &(size, iters) in &[(1024usize, 100usize), (64 * 1024, 20), (1024 * 1024, 4)] {
let payload: Vec<u8> = (0..size).map(|i| ((i * 31 + 17) % 251) as u8).collect();
let total_mib = (size * iters) as f64 / (1024.0 * 1024.0);
// Verify SIMD-accelerated CRC32 / CRC32C / CRC64_NVME against bitwise scalar reference at 1 KiB & 64 KiB.
if size <= 64 * 1024 {
let crc32_simd = Checksum::new_from_data(ChecksumType::CRC32, &payload).expect("crc32");
let crc32c_simd = Checksum::new_from_data(ChecksumType::CRC32C, &payload).expect("crc32c");
let crc64_simd = Checksum::new_from_data(ChecksumType::CRC64_NVME, &payload).expect("crc64nvme");
let t_sw = Instant::now();
let crc32_sw = software_crc32_bitwise(0xEDB88320, black_box(&payload));
let crc32c_sw = software_crc32_bitwise(0x82F63B78, black_box(&payload));
let crc64_sw = software_crc64_nvme_bitwise(black_box(&payload));
let sw_elapsed = t_sw.elapsed();
assert_eq!(crc32_simd.raw.as_slice(), &crc32_sw, "CRC32 SIMD vs scalar mismatch at {size}B");
assert_eq!(crc32c_simd.raw.as_slice(), &crc32c_sw, "CRC32C SIMD vs scalar mismatch at {size}B");
assert_eq!(crc64_simd.raw.as_slice(), &crc64_sw, "CRC64_NVME SIMD vs scalar mismatch at {size}B");
println!(
"[microbench][rio::checksum] size={size}B bitwise scalar reference check passed in {:?}",
sw_elapsed
);
}
for &checksum_type in super::BASE_CHECKSUM_TYPES {
let mut hasher = checksum_type.hasher().expect("base checksum type must have hasher");
let t0 = Instant::now();
let mut digest = Vec::new();
for _ in 0..iters {
hasher.reset();
hasher.write_all(black_box(&payload)).expect("hasher write_all");
digest = hasher.finalize();
black_box(&digest);
}
let elapsed = t0.elapsed();
assert_eq!(digest.len(), checksum_type.raw_byte_len());
let mibs = total_mib / elapsed.as_secs_f64().max(1e-9);
println!(
"[microbench][rio::checksum] size={size}B algo={} throughput={mibs:.1} MiB/s",
checksum_type
);
}
}
}
}
+162
View File
@@ -735,4 +735,166 @@ mod tests {
}
}
}
#[test]
fn highwayhash_simd_matches_portable_scalar_and_chunked_slices_across_unaligned_boundaries() {
use highway::PortableHash;
for &size in &[
0usize,
1,
7,
15,
31,
32,
33,
63,
64,
65,
127,
128,
129,
255,
257,
1024,
4096 + 17,
64 * 1024,
] {
let payload = generate_compat_test_data(size);
let mut h_simd = HighwayHasher::new(MAGIC_HIGHWAY_HASH256_PARSED_KEY);
h_simd.append(&payload);
let simd_digest = u8x32_from_u64x4(h_simd.finalize256());
let mut h_scalar = PortableHash::new(MAGIC_HIGHWAY_HASH256_PARSED_KEY);
h_scalar.append(&payload);
let scalar_digest = u8x32_from_u64x4(h_scalar.finalize256());
assert_eq!(
simd_digest, scalar_digest,
"HighwayHasher SIMD must match PortableHash scalar byte-for-byte at {size}B"
);
for &chunk_size in &[17usize, 61, 4096] {
if size > 4096 && chunk_size < 61 {
continue;
}
let slices: Vec<&[u8]> = payload.chunks(chunk_size).collect();
for algo in [
HashAlgorithm::HighwayHash256S,
HashAlgorithm::HighwayHash256SLegacy,
HashAlgorithm::SHA256,
HashAlgorithm::BLAKE2b512,
HashAlgorithm::Md5,
] {
let contig_out = algo.hash_encode(&payload);
let sliced_out = algo.hash_encode_slices(slices.iter().copied());
assert_eq!(
contig_out.as_ref(),
sliced_out.as_ref(),
"{algo:?} contiguous vs {chunk_size}B sliced digest mismatch at {size}B"
);
}
}
}
}
#[test]
#[ignore = "utility throughput microbenchmark; run via cargo test -p rustfs-utils --lib --features hash --release -- --ignored --nocapture"]
fn microbench_highwayhash_simd_vs_portable_scalar_and_hash_algorithms() {
use highway::PortableHash;
use std::hint::black_box;
use std::time::Instant;
for &(size, iters) in &[(1024usize, 200usize), (64 * 1024, 40), (1024 * 1024, 8)] {
let payload = generate_compat_test_data(size);
// 1. Compare HighwayHasher (runtime SIMD: AVX2/SSE4.1/NEON) against PortableHash scalar reference.
let t_simd = Instant::now();
let mut simd_digest = [0u8; 32];
for _ in 0..iters {
let mut h = HighwayHasher::new(MAGIC_HIGHWAY_HASH256_PARSED_KEY);
h.append(black_box(&payload));
simd_digest = u8x32_from_u64x4(h.finalize256());
black_box(&simd_digest);
}
let simd_elapsed = t_simd.elapsed();
let t_scalar = Instant::now();
let mut scalar_digest = [0u8; 32];
for _ in 0..iters {
let mut h = PortableHash::new(MAGIC_HIGHWAY_HASH256_PARSED_KEY);
h.append(black_box(&payload));
scalar_digest = u8x32_from_u64x4(h.finalize256());
black_box(&scalar_digest);
}
let scalar_elapsed = t_scalar.elapsed();
assert_eq!(
simd_digest, scalar_digest,
"HighwayHasher SIMD must match PortableHash scalar byte-for-byte at {size}B"
);
let total_mib = (size * iters) as f64 / (1024.0 * 1024.0);
let simd_mibs = total_mib / simd_elapsed.as_secs_f64().max(1e-9);
let scalar_mibs = total_mib / scalar_elapsed.as_secs_f64().max(1e-9);
println!(
"[microbench][utils::hash] size={size}B HighwayHash256S SIMD={simd_mibs:.1} MiB/s vs PortableScalar={scalar_mibs:.1} MiB/s (speedup={:.2}x)",
simd_mibs / scalar_mibs.max(1e-9)
);
// 2. Compare contiguous hash_encode vs 4 KiB chunked hash_encode_slices across all bitrot algorithms.
let slices: Vec<&[u8]> = payload.chunks(4096).collect();
for algo in [
HashAlgorithm::HighwayHash256S,
HashAlgorithm::HighwayHash256SLegacy,
HashAlgorithm::SHA256,
HashAlgorithm::BLAKE2b512,
HashAlgorithm::Md5,
] {
let t_contig = Instant::now();
let mut contig_out = Vec::new();
for _ in 0..iters {
contig_out = algo.hash_encode(black_box(&payload)).as_ref().to_vec();
black_box(&contig_out);
}
let contig_elapsed = t_contig.elapsed();
let t_sliced = Instant::now();
let mut sliced_out = Vec::new();
for _ in 0..iters {
sliced_out = algo.hash_encode_slices(black_box(slices.iter().copied())).as_ref().to_vec();
black_box(&sliced_out);
}
let sliced_elapsed = t_sliced.elapsed();
assert_eq!(contig_out, sliced_out, "{algo:?} contiguous vs 4KiB sliced digest mismatch at {size}B");
let contig_mibs = total_mib / contig_elapsed.as_secs_f64().max(1e-9);
let sliced_mibs = total_mib / sliced_elapsed.as_secs_f64().max(1e-9);
println!(
"[microbench][utils::hash] size={size}B algo={algo:?} contiguous={contig_mibs:.1} MiB/s vs sliced(4KiB)={sliced_mibs:.1} MiB/s"
);
}
}
// 3. Key-routing microbenchmark: crc_hash (CRC32) vs sip_hash (SipHash-1-3).
let keys: Vec<String> = (0..1024)
.map(|i| format!("bucket/prefix-{i}/object-key-{i}.parquet"))
.collect();
let t_crc = Instant::now();
let mut crc_acc = 0usize;
for key in &keys {
crc_acc ^= crc_hash(black_box(key), 16);
}
let crc_ns = t_crc.elapsed().as_nanos();
let t_sip = Instant::now();
let mut sip_acc = 0usize;
for key in &keys {
sip_acc ^= sip_hash(black_box(key), 16, &[0u8; 16]);
}
black_box(sip_acc);
let sip_ns = t_sip.elapsed().as_nanos();
assert!(black_box(crc_acc) < 16 && black_box(sip_acc) < 16);
println!("[microbench][utils::hash] 1024 object keys: crc_hash={crc_ns}ns total, sip_hash={sip_ns}ns total");
}
}
+2
View File
@@ -8,6 +8,8 @@ collection:
## Operations
- [Candidate release pipeline](operations/release-pipeline.md): frozen inputs, staged acceptance, immutable publication and recovery.
- [Sampled local batch CPU diagnostics](operations/local-batch-cpu-diagnostics.md) — default-off request-correlated allocation and worker wall/CPU diagnosis.
- [Retained MRF responsibilities](operations/mrf-responsibilities.md) — unknown bucket identities, bounded probes, and unreadable checkpoint recovery.
+79
View File
@@ -0,0 +1,79 @@
# Candidate acceptance and release publication
RustFS builds the final product version before publication. A candidate is identified by its Actions run and `<version>-preview.<run_number>`; this identifier is not a Git tag or the executable's displayed version. Failed candidates consume no public version. A public tag is created only after the complete artifact manifest is approved.
## Entry points
| Workflow | Input | Result | Public writes |
|---|---|---|---|
| `release-candidate.yml` | Exact final version and full source SHA | Four Linux archives, native checks, upgrade results, Linux manifest | None |
| `release-complete.yml` | Successful candidate run, accepted Linux manifest SHA-256, backlog acceptance report | Desktop archives, DEB/RPM, OCI images, Helm chart, full certification and sealed manifest | None |
| `release-publish.yml` | Successful completion run and approved final manifest SHA-256 | Annotated tag, GitHub Release, R2 files, registry images, stable pointers and Helm index | Explicit publication |
The builder workflows (`build.yml`, `package.yml`, `docker.yml`, `helm-package.yml`) are reusable stages. Only `release-publish.yml` has release write credentials. Development builds retain their artifact names and download channel. Daily development builds now select Linux only; macOS/Windows development artifacts are refreshed by an explicit manual build, and both remain mandatory for a completed release candidate. Tag pushes no longer start a second compilation.
## Freeze and build
Select a reviewed source commit whose Cargo version equals the intended final version. Finish exact-source CI before dispatching the candidate. The candidate workflow itself must run from upstream main. Unrelated main commits do not require rebuilding an accepted source.
```bash
gh workflow run release-candidate.yml --ref main \
-f version='<version>' -f source_sha='<full-source-sha>'
```
The initial job records the source and workflow SHAs, release recipe, dependency lock, exact stable Rust version, Console tag/asset/SHA-256, CLI assets, previous stable release and validation plan. All platforms consume that record; none independently follows `latest`. The source commit must contain the same reviewed release recipe as the dispatcher. Relevant recipe changes require a new candidate; unrelated source changes on main do not invalidate the frozen record.
Linux is built first. Each archived executable must display the final version, expected target, source commit and compiler version, then start natively, serve the Console, and pass an authenticated S3 bucket/object round trip. The upgrade lane runs the existing pinned compatibility matrix plus the stable version frozen at candidate creation. It compiles the harness and runs the accepted executable; it does not rebuild a substitute server. A zero-test invocation fails.
The Linux manifest is partial evidence and cannot authorize publication. Download the exact artifacts for field acceptance. Record the source, manifest digest, run URL and actual tested binary digests in backlog. Required field acceptance includes Scanner/Heal and multi-pool behavior, major features, memory, high-concurrency 503s, performance against the agreed baseline, previous-version data integrity and upgrade recovery. The agreed stability duration is a release-specific requirement, not an implicit workflow default.
## Complete the accepted candidate
After the Linux report passes, dispatch `release-complete.yml` with the exact digest printed by the candidate run. The `release-linux-acceptance` environment is the operator gate. Configure its protection and allowed main branch in repository settings; a typed digest and report identify what is being accepted, while the workflow independently revalidates automated evidence.
```bash
gh workflow run release-complete.yml --ref main \
-f candidate_run_id='<run-id>' \
-f linux_manifest_sha256='<accepted-linux-manifest-sha256>' \
-f acceptance_report='https://github.com/rustfs/backlog/issues/<issue>#issuecomment-<comment>'
```
macOS and Windows now build and run the same native checks. Packages and images wrap the accepted Linux binaries. DEB/RPM extraction must reproduce the recorded binary digest. Both OCI variants are checked on amd64 and arm64 and scanned before sealing. Vulnerability scans retain the repository's report-only policy; scanner execution errors still fail the stage. Image OS packages are resolved during packaging, then the actual resulting OCI bytes are accepted and preserved; this is not a promise of reproducible package-manager output.
The Helm chart is tested and packaged before sealing, with chart and application versions equal to the final product version. Full certification requires every platform and upgrade case from the original plan. The seal includes artifact hashes, raw evidence, source/input identities, acceptance report, checksums, SBOM and provenance. Successful Linux receipts remain valid while desktop results are added because each receipt binds its own exact archive and executable, the frozen plan, candidate identity and trusted producer.
Native smoke checks are not a substitute for Console UI and rc acceptance. Complete the release skill's UI/client checks against these exact artifacts, including desktop checks applicable to the release, before asking for final publication approval.
## Approve and publish
Review the complete run and final `release-manifest.json`. Human approval binds its SHA-256, source and version. A changed manifest requires renewed approval. No source version bump, compilation, package generation or image build occurs during publication.
```bash
gh workflow run release-publish.yml --ref main \
-f complete_run_id='<successful-completion-run-id>' \
-f manifest_sha256='<approved-final-manifest-sha256>'
```
The publisher downloads the artifact from the exact successful main workflow run, checks GitHub's artifact digest, recomputes file hashes and reruns complete certification. It creates an annotated tag whose message binds the source and manifest. An existing tag or asset with different bytes is a hard failure; tags are never moved and uploads never clobber assets.
Versioned GitHub assets, R2 objects and Docker Hub/GHCR/Quay manifests are uploaded and read back before the Release becomes public. OCI promotion uses `--preserve-digests`; registry conversion fails rather than silently accepting a new image. R2 creation uses atomic `If-None-Match: *`. The small evidence bundle persists with the public Release after Actions artifacts expire. Provenance is descriptive, not a claim of cryptographic attestation.
Publication uses a per-version lock and a shared channel lock, both without cancelling an active run. Stable channels advance only after versioned delivery succeeds. GitHub Latest acts as a monotonic marker during partial channel updates, so an older retry cannot downgrade Docker tags, R2 aliases or installer pointers. Prereleases do not advance stable channels. Helm publishes the already accepted chart after versioned images exist. GitHub's legacy `*-latest.zip` download names remain aliases of the accepted bytes; they become default downloads only when GitHub Latest advances.
Cross-registry publication is not atomic. A channel job can fail after some verified destinations advance. Resume the same run/seal: matching destinations are reused and conflicting ones stop the workflow. Retrying never recompiles. Do not claim complete publication until the channel receipt and all public endpoints are verified.
## Failure and retention
- Infrastructure failure: rerun failed jobs in the same run. Frozen inputs, successful platform/package/image artifacts and completed seals are reused. Missing or expired artifacts fail closed.
- Product or build-input change before tagging: create a new candidate run and repeat affected acceptance. Do not mutate the old candidate.
- Failure after tagging: resume publication of exactly that seal. A code fix requires a new patch version; never move the tag.
- New main commits: retain evidence for unchanged source, recipe and inputs. Recheck only affected boundaries; do not restart the entire matrix because an unrelated PR merged.
- Artifacts are retained for 30 days while awaiting acceptance. Public-repository Actions artifacts and staging identifiers are not confidential storage. Preserve important failure evidence in task-owned storage and backlog.
## Migration and workflow maintenance
Finish any in-flight legacy tag release, including its downstream Docker/package/Helm jobs, before enabling this cutover on main. Existing tags and Releases retain their identities. Do not feed a legacy public preview binary into the new promotion path: it embeds a different version and was not built from the frozen-input contract.
All workflow responsibilities, triggers and schedule ownership remain registered in `.github/scheduled-validations.json`. The migration does not re-enable workflows disabled in GitHub. PR checks, cache warming, nightly development builds, functional chains, lab tests and monitoring retain their distinct purposes; shared source or cache does not make different feature/platform tests redundant. The watchdog allocates a runner only for relevant unsuccessful scheduled/chained runs, while its registry still determines ownership.
Run `actionlint`, the release contract suite and the workflow registry validator after changes. The Security Audit workflow also exercises actual GitHub artifact transport, DEB/RPM package extraction, both multi-platform OCI builders, local registry promotion and archive scanning. Native packaging jobs run only for relevant changes or a manual audit. Their payloads are explicit transport fixtures, not product release acceptance. See the [release skill](../../.agents/skills/rustfs-release-publish/SKILL.md) for field acceptance, human confirmation and post-release follow-up.
+29 -7
View File
@@ -116,18 +116,40 @@ Functional-chain health runs on a hosted runner independently of the lab fleet.
## Release validation
Post-merge and tag-driven; not a substitute for a PR gate.
Post-merge and tag-driven; not a substitute for a PR gate. Every public publication exit consumes the release certification described here; this certification path is separate from the reusable CI artifact contract in [verification plans](ci-verification-pipeline.md), whose archives expire after one day and are never used as release certification.
The versioned plan is `.config/release-validation-plan.json` (`schema_version=1`): release types, target OS/arch/ABI, supported features, a fixed harness SHA per lane, required case IDs, environment capabilities, timeouts, applicability conditions, and an explicit `upgrade_sources` list carrying the real artifact digests of the supported N-1/N-2 sources plus a stated rollback basis — compatibility and rollback are never invented or defaulted. `scripts/release/release_gate.py validate` expands and freezes `plan_sha256` and the required case set before reading results, then binds that plan, a `candidate.json` (product 40-character SHA, version, release profile, features, immutable deliverable locations with SHA256 and wrapped-binary SHA256, image digest) and the shared G01 evidence-envelope set (rustfs/backlog#2658) of one parent candidate batch, and writes machine JSON, JUnit and a summary. Within a batch the unique key is `(lane, platform{os,arch,abi}, case_id)`; the top-level run/attempt is the parent batch and the nested workflow run/attempt is the child workflow identity, and both are validated separately (workflow SHA and product build SHA are bound independently and need not be equal). Missing, duplicate or extra cases, stale attempts, wrong digests, wrong platforms, wrong harness SHAs, zero tests, cancellation, timeout, untrusted sources and any non-PASS required item block with classified errors; only a complete legal success returns 0. `verify-promotion` re-checks the exact bytes about to be published against the passed list and fails on a swapped package or an edited plan after verification. preview, prerelease and stable all require an explicit plan; preview may use the plan's smaller pre-declared set but never bypasses artifact identity and startup checks. development artifacts are explicitly uncertified and never touch official or latest channels.
| Trigger | Workflow / job | Result |
|---|---|---|
| Nightly schedule, dispatch | `build.yml` `build-rustfs` (development build over the full platform matrix) | build artifacts and the dev download channel; no release publication |
| Valid release or preview tag | `build.yml` `build-rustfs`, `create-release`, `upload-release-assets`, `publish-release` | draft release, checksummed assets, publish |
| Successful non-preview release-tag build (`workflow_run`) | `docker.yml` `build-docker`, `scan-docker-image` | multi-architecture images and vulnerability report |
| Successful release-tag build (`workflow_run`) | `package.yml` `package` | DEB/RPM packages and checksums uploaded to the release |
| Successful non-preview release-tag build (`workflow_run`) | `helm-package.yml` `build-helm-package`, `publish-helm-package` | versioned chart and repository index |
| Nightly schedule, dispatch | `build.yml` `build-rustfs` (development build over the full platform matrix) | build artifacts and the uncertified dev download channel; no release publication |
| Valid release or preview tag | `build.yml` `build-rustfs`, `release-certification`, `create-release`, `upload-release-assets`, `publish-release`, `promote-release-artifacts` | staged binaries, draft release, checksummed assets, then certified publish and promotion of the same bytes |
| Successful non-preview release-tag build (`workflow_run`) | `docker.yml` `release-certification`, `build-docker`, `scan-docker-image` | multi-architecture images staged, verified against the certification and promoted, plus vulnerability report |
| Successful release-tag build (`workflow_run`) | `package.yml` `release-certification`, `package`, `promote-packages` | DEB/RPM packages with wrapped-binary verification, staged then promoted with checksums |
| Successful non-preview release-tag build (`workflow_run`) | `helm-package.yml` `release-certification`, `build-helm-package`, `publish-helm-package` | versioned chart and repository index, only over a certified image promotion record |
| Final tag's release published | `build.yml` `cleanup-preview-releases` | deletes every `<target>-preview.<N>` Release for that target; the tags are kept |
Use an exact preview tag for an end-to-end release rehearsal. Manual dispatches are backfill/debug paths and do not prove the automatic `workflow_run` chain.
Publication exits and their gate:
| Exit | Writer | Certification consumed |
|---|---|---|
| GitHub Release draft → public | `build.yml` `publish-release` | `release-certification` success and per-asset `verify-promotion` |
| Official R2 binary path `artifacts/rustfs/release/` | `build.yml` `promote-release-artifacts` | `release-certification` plus `verify-promotion`; the build job itself only writes `artifacts/rustfs/staging/<run>/<attempt>/` |
| `latest.json` version pointer | `build.yml` `update-latest-version` | transitively through `publish-release` |
| Image channels (Docker Hub, GHCR, Quay tags) | `docker.yml` `build-docker` promotion step | certified input digests, staged image binary identity and `verify-promotion`, then promotion of the same digest with `imagetools create` |
| Official R2 package paths `packages/release` and `packages/latest` | `package.yml` `promote-packages` | `release-certification` and wrapped-binary `verify-promotion`; the package job only stages |
| GitHub release package assets | `package.yml` `package` | `release-certification` and wrapped-binary `verify-promotion` before upload |
| Helm chart repository (`rustfs/helm` push) | `helm-package.yml` `publish-helm-package` | `release-certification` plus the Docker workflow's image-promotion record bound to the same candidate |
Downstream workflows fail closed when the trusted certification is absent: a manual dispatch without certification fails and pushes no official tag. Only results from the trusted verification sources listed in the plan (currently the `rustfs/s3chaos` `release-gate.yml` envelope runs) carry release authority; a PR-uploaded JSON set cannot certify a release. Retries reuse the same candidate, only the staged bytes are promoted (no rebuild-after-verify), and no downstream step re-resolves a mutable `latest`.
Use an exact preview tag for an end-to-end release rehearsal. Manual dispatches are backfill/debug paths and do not prove the automatic `workflow_run` chain. The committed fixtures under `testdata/release-gate/` and the commands below are contract rehearsals of this orchestration; they are explicitly not product release evidence and not live verifier results.
```bash
python3 -m unittest discover -s scripts/release -p 'test_release_gate.py'
python3 scripts/release/release_gate.py validate --plan testdata/release-gate/plan.json --candidate testdata/release-gate/candidate.json --results testdata/release-gate/pass --output target/release-gate-check
bash scripts/security/check_preview_release_workflow.sh
```
## Change checklist
+2
View File
@@ -8,6 +8,8 @@ The classifier reads the pull request base revision's planner and policy. It com
The committed policy is `enforced`: ordinary Rust changes select their reverse dependency closure plus the fixed core inventory, and unrelated changes can omit the Connect specialization. Package-owned fixtures, non-Rust inputs, build scripts, manifests, unknown paths and non-PR events retain full validation. Existing documentation and isolated E2E scopes remain conservative. Changing a PR's own policy cannot authorize skipping its base revision's requirements. A base planner predating the current workflow contract clears all narrower outputs and selects the full matrix. Set the policy back to `shadow` to retain proposed selections while restoring full ordinary-code execution; do not remove core inventories or negative checks as a rollback.
Stable installation-version alignment has one content-based exception in enforced mode: literal version fields in `flake.nix`, `helm/rustfs/Chart.yaml` and `rustfs.spec`, plus a standard prepended RPM release note, can use the documentation scope. Quick Checks, Typos, CLA and the required aggregate remain. The planner compares both complete Git blobs and requires ordinary non-executable files; changes to build logic, CLI pins, chart settings, RPM macros or older changelog entries retain the full gate. New/deleted files, mode changes, prerelease syntax, unavailable history, shadow rollout and explicit full-path policy overrides also retain full validation. Product-code changes in the same PR retain their normal dependency and core coverage, and main pushes, merge groups, scheduled and manual runs still validate the complete candidate. This exception does not establish artifact availability or authorize a release; post-release checks still verify the published target before updating defaults.
`Test and Lint` remains the required aggregate name. Every verification producer and consumer is a direct dependency. The verifier accepts only the effective plan's exact expected success/skipped outcomes; failure, cancellation, missing results, extra jobs or a plan for a different candidate fail. Quick Checks and CLA retain their separate required names.
## Build and execute
+1 -1
View File
@@ -77,7 +77,7 @@
rustfs = rustPlatform.buildRustPackage {
pname = "rustfs";
version = "1.0.4";
version = "1.0.5";
src = ./.;
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: rustfs
description: RustFS helm chart to deploy RustFS on kubernetes cluster.
type: application
version: "1.0.4"
appVersion: "1.0.4"
version: "1.0.5"
appVersion: "1.0.5"
home: https://rustfs.com
icon: https://media.sys.truenas.net/apps/rustfs/icons/icon.svg
maintainers:
+4 -1
View File
@@ -1,7 +1,7 @@
%global _enable_debug_packages 0
%global _empty_manifest_terminate_build 0
Name: rustfs
Version: 1.0.4
Version: 1.0.5
Release: 1
Summary: High-performance distributed object storage for MinIO alternative
@@ -57,6 +57,9 @@ install %_builddir/%{name}-%{version}/target/%_arch/%_arch-unknown-linux-gnu/rel
%_bindir/rustfs
%changelog
* Wed Nov 11 2026 overtrue <anzhengchao@gmail.com>
- Update RPM package to RustFS 1.0.5
* Mon Oct 26 2026 overtrue <anzhengchao@gmail.com>
- Update RPM package to RustFS 1.0.4
+96 -27
View File
@@ -254,11 +254,15 @@ impl DefaultAdminUsecase {
/// absent from `buckets_usage` — absent already means "unknown" on the
/// wire, distinct from a present zero — and the snapshot is marked
/// non-converged so clients can tell it is not the whole namespace. A
/// snapshot that is structurally incomplete, or that measured nothing the
/// namespace still contains, carries no usable information and is dropped
/// exactly as before.
fn narrow_data_usage_snapshot_to_measured_buckets(info: &mut DataUsageInfo, buckets: impl IntoIterator<Item = String>) {
if !info.is_complete_bucket_usage_snapshot() && !info.is_valid_partial_snapshot() {
/// snapshot that is structurally incomplete (`snapshot_measured` is false),
/// or that measured nothing the namespace still contains, carries no usable
/// information and is dropped exactly as before.
fn narrow_data_usage_snapshot_to_measured_buckets(
info: &mut DataUsageInfo,
snapshot_measured: bool,
buckets: impl IntoIterator<Item = String>,
) {
if !snapshot_measured {
*info = DataUsageInfo::default();
return;
}
@@ -275,13 +279,31 @@ impl DefaultAdminUsecase {
let covers_namespace = current_buckets.len() == info.buckets_usage.len();
info.buckets_count = info.buckets_usage.len() as u64;
if !covers_namespace {
// Some bucket in the namespace was never measured, so the totals
// below are a floor rather than the converged truth.
if !covers_namespace || !info.is_complete_bucket_usage_snapshot() {
// Some bucket in the namespace was never measured, or holds
// mutations no scan has reconciled yet, so the totals below are
// not the converged truth.
info.usage_snapshot_converged = Some(false);
}
}
/// Overlay single-process counts on a persisted snapshot and narrow it
/// for display.
///
/// Whether the snapshot measured anything is decided before the overlay.
/// The overlay clears `usage_snapshot_complete` when it holds counts it
/// cannot reconcile with the snapshot, such as writes into a bucket that
/// has no verified baseline. That makes the totals unconverged, not the
/// scanned buckets unusable; treating it as structural incompleteness
/// blanked usage for every bucket for as long as one such bucket kept
/// receiving writes, because each write restarts its scanner fence.
async fn present_data_usage_snapshot(mut info: DataUsageInfo, buckets: impl IntoIterator<Item = String>) -> DataUsageInfo {
let snapshot_measured = info.is_complete_bucket_usage_snapshot() || info.is_valid_partial_snapshot();
apply_bucket_usage_memory_overlay(&mut info).await;
Self::narrow_data_usage_snapshot_to_measured_buckets(&mut info, snapshot_measured, buckets);
info
}
async fn rebalance_status_snapshot(store: &ECStore) -> AdminUsecaseResult<Option<RebalanceMeta>> {
store.rebalance_status_meta_snapshot().await.map_err(|err| {
error!("refresh rebalance metadata for pool status failed: {:?}", err);
@@ -323,8 +345,7 @@ impl DefaultAdminUsecase {
/// This path never triggers a live full-version listing
/// (rustfs/backlog#1306); freshness is owned by the scanner.
pub(crate) async fn query_data_usage_info_with_store(store: Arc<ECStore>) -> AdminUsecaseResult<DataUsageInfo> {
let mut info = Self::map_data_usage_load_result(load_admin_data_usage_from_backend_cached(store.clone()).await)?;
apply_bucket_usage_memory_overlay(&mut info).await;
let info = Self::map_data_usage_load_result(load_admin_data_usage_from_backend_cached(store.clone()).await)?;
let buckets = store
.list_bucket(&BucketOptions {
cached: true,
@@ -333,11 +354,7 @@ impl DefaultAdminUsecase {
})
.await
.map_err(|_| Self::app_error(S3ErrorCode::InternalError, "list_bucket failed"))?;
// Serve what the scan measured; buckets it never reached stay absent
// (absent means unknown on the wire, unlike a present zero) and the
// snapshot is flagged non-converged. See rustfs#5806 for why blanking
// the whole document instead was worse than the rule it enforced.
Self::narrow_data_usage_snapshot_to_measured_buckets(&mut info, buckets.into_iter().map(|bucket| bucket.name));
let mut info = Self::present_data_usage_snapshot(info, buckets.into_iter().map(|bucket| bucket.name)).await;
let storage_info = StorageAdminApi::storage_info(store.as_ref()).await;
@@ -876,6 +893,12 @@ mod tests {
});
}
/// Narrow a snapshot no overlay has touched, judging it as persisted.
fn narrow_persisted_snapshot(info: &mut DataUsageInfo, buckets: impl IntoIterator<Item = String>) {
let snapshot_measured = info.is_complete_bucket_usage_snapshot() || info.is_valid_partial_snapshot();
DefaultAdminUsecase::narrow_data_usage_snapshot_to_measured_buckets(info, snapshot_measured, buckets);
}
/// rustfs#5806: a bucket the last scan never reached must not blank the
/// usage the scan *did* measure — that turned every bucket creation into a
/// deployment-wide usage blackout until the next cycle.
@@ -897,10 +920,7 @@ mod tests {
// A brand-new bucket the scan never saw: keep bucket-a, omit bucket-new,
// and say so via the convergence flag.
let mut info = measured("bucket-a");
DefaultAdminUsecase::narrow_data_usage_snapshot_to_measured_buckets(
&mut info,
["bucket-a".to_string(), "bucket-new".to_string(), ".rustfs.sys".to_string()],
);
narrow_persisted_snapshot(&mut info, ["bucket-a".to_string(), "bucket-new".to_string(), ".rustfs.sys".to_string()]);
assert!(info.buckets_usage.contains_key("bucket-a"), "measured usage must survive");
assert!(!info.buckets_usage.contains_key("bucket-new"), "unmeasured bucket stays absent");
assert_eq!(info.usage_snapshot_converged, Some(false));
@@ -909,22 +929,19 @@ mod tests {
// Full coverage leaves the snapshot untouched.
let mut info = measured("bucket-a");
DefaultAdminUsecase::narrow_data_usage_snapshot_to_measured_buckets(
&mut info,
["bucket-a".to_string(), ".rustfs.sys".to_string()],
);
narrow_persisted_snapshot(&mut info, ["bucket-a".to_string(), ".rustfs.sys".to_string()]);
assert_eq!(info.usage_snapshot_converged, None, "a covering snapshot is not downgraded");
assert_eq!(info.buckets_count, 1);
// A bucket deleted since the scan must not linger in the response.
let mut info = measured("bucket-gone");
DefaultAdminUsecase::narrow_data_usage_snapshot_to_measured_buckets(&mut info, ["bucket-live".to_string()]);
narrow_persisted_snapshot(&mut info, ["bucket-live".to_string()]);
assert_eq!(info, DataUsageInfo::default(), "nothing measured is still in the namespace");
// Structurally incomplete snapshots carry no usable information.
let mut info = measured("bucket-a");
info.usage_snapshot_complete = false;
DefaultAdminUsecase::narrow_data_usage_snapshot_to_measured_buckets(&mut info, ["bucket-a".to_string()]);
narrow_persisted_snapshot(&mut info, ["bucket-a".to_string()]);
assert_eq!(info, DataUsageInfo::default());
// A structurally valid partial admin view can still carry useful
@@ -945,7 +962,7 @@ mod tests {
tombstone: false,
segment_invalidation_proof: None,
}];
DefaultAdminUsecase::narrow_data_usage_snapshot_to_measured_buckets(&mut info, ["bucket-a".to_string()]);
narrow_persisted_snapshot(&mut info, ["bucket-a".to_string()]);
assert_eq!(info.usage_snapshot_converged, Some(false));
assert_eq!(info.buckets_count, 1);
assert_eq!(info.objects_total_count, 7);
@@ -957,11 +974,63 @@ mod tests {
buckets_count: 0,
..Default::default()
};
DefaultAdminUsecase::narrow_data_usage_snapshot_to_measured_buckets(&mut info, [".rustfs.sys".to_string()]);
narrow_persisted_snapshot(&mut info, [".rustfs.sys".to_string()]);
assert_eq!(info.usage_snapshot_converged, None);
assert_eq!(info.buckets_count, 0);
}
/// A write into a bucket without a verified baseline leaves process-local
/// counts the overlay cannot reconcile, so it clears
/// `usage_snapshot_complete`. That must flag the response non-converged,
/// not erase every bucket the persisted scan measured: under continuous
/// writes the scanner fence never clears and the blackout never ends.
#[tokio::test]
async fn unverified_bucket_writes_do_not_blank_measured_usage() {
let measured_bucket = "admin-usage-measured";
let unverified_bucket = "admin-usage-unverified";
let mut persisted = DataUsageInfo {
last_update: Some(std::time::SystemTime::now() - std::time::Duration::from_secs(60)),
usage_snapshot_complete: true,
buckets_count: 1,
..Default::default()
};
persisted.buckets_usage.insert(
measured_bucket.to_string(),
rustfs_data_usage::BucketUsageInfo {
objects_count: 4_000,
versions_count: 4_000,
size: 8_192_000,
..Default::default()
},
);
persisted.bucket_sizes.insert(measured_bucket.to_string(), 8_192_000);
persisted.calculate_totals();
assert!(persisted.is_complete_bucket_usage_snapshot());
crate::app::storage_api::test::data_usage::record_bucket_object_write_memory(unverified_bucket, None, 1_024).await;
let info = DefaultAdminUsecase::present_data_usage_snapshot(
persisted,
[measured_bucket.to_string(), unverified_bucket.to_string()],
)
.await;
assert_eq!(
info.buckets_usage
.get(measured_bucket)
.map(|usage| (usage.objects_count, usage.size)),
Some((4_000, 8_192_000)),
"usage the scanner measured must survive an unrelated unverified bucket"
);
assert!(
!info.buckets_usage.contains_key(unverified_bucket),
"a bucket without a verified baseline stays absent (unknown)"
);
assert_eq!(info.usage_snapshot_converged, Some(false));
assert!(!info.is_complete_bucket_usage_snapshot());
assert_eq!(info.objects_total_count, 4_000);
}
#[tokio::test]
async fn execute_query_storage_info_returns_internal_error_when_store_uninitialized() {
let usecase = DefaultAdminUsecase::without_context();
+83 -12
View File
@@ -17,6 +17,7 @@
use super::*;
use crate::app::storage_api::object_usecase::DeleteObjectCondition;
use s3s::dto::ObjectIdentifier;
use std::borrow::Cow;
use std::collections::HashSet;
async fn authorize_recursive_delete<T>(
@@ -81,6 +82,19 @@ async fn authorize_recursive_delete<T>(
result
}
// XML body keys bypass s3s's URL-path normalization. Match its private slash
// normalizer without URL-decoding or resolving dot segments in client keys.
fn normalize_delete_object_key(key: &str) -> Cow<'_, str> {
if !key.starts_with('/') {
return Cow::Borrowed(key);
}
let mut normalized = key.split('/').filter(|part| !part.is_empty()).collect::<Vec<_>>().join("/");
if key.ends_with('/') {
normalized.push('/');
}
Cow::Owned(normalized)
}
fn successful_delete_audit_objects(
delete: &s3s::dto::Delete,
successful_results: impl IntoIterator<Item = bool>,
@@ -90,7 +104,9 @@ fn successful_delete_audit_objects(
.iter()
.zip(successful_results)
.filter(|(_, successful)| *successful)
.map(|(requested, _)| AuditObjectVersion::new(requested.key.clone(), requested.version_id.clone()))
.map(|(requested, _)| {
AuditObjectVersion::new(normalize_delete_object_key(&requested.key).into_owned(), requested.version_id.clone())
})
.collect()
}
@@ -590,7 +606,10 @@ impl DefaultObjectUsecase {
// Per-key denials are audited once per denied action, in aggregate, after the loop.
let mut bulk_denials = crate::storage::access::BulkDenialAudit::start(&mut req)?;
for (idx, obj_id) in delete.objects.iter().enumerate() {
if let Err(err) = validate_object_key(&obj_id.key, "DeleteObjects") {
// Authorization and every storage side effect must address the same
// normalized object; retain the request key only for response entries.
let key = normalize_delete_object_key(&obj_id.key);
if let Err(err) = validate_object_key(&key, "DeleteObjects") {
delete_results[idx].error = Some(s3s::dto::Error {
code: Some(err.code().as_str().to_string()),
key: Some(obj_id.key.clone()),
@@ -623,7 +642,7 @@ impl DefaultObjectUsecase {
{
let req_info = req_info_mut(&mut req)?;
req_info.bucket = Some(bucket.clone());
req_info.object = Some(obj_id.key.clone());
req_info.object = Some(key.to_string());
req_info.version_id = version_id.clone();
}
@@ -670,7 +689,7 @@ impl DefaultObjectUsecase {
}
}
if let Err(err) = validate_table_catalog_object_mutation(&bucket, &obj_id.key).await {
if let Err(err) = validate_table_catalog_object_mutation(&bucket, &key).await {
delete_results[idx].error = Some(s3s::dto::Error {
code: Some("InvalidRequest".to_string()),
key: Some(obj_id.key.clone()),
@@ -680,9 +699,9 @@ impl DefaultObjectUsecase {
continue;
}
let synthetic_version_id = delete_condition.is_none() && version_id.is_none() && is_dir_object(&obj_id.key);
let synthetic_version_id = delete_condition.is_none() && version_id.is_none() && is_dir_object(&key);
let object = ObjectToDelete {
object_name: obj_id.key.clone(),
object_name: key.into_owned(),
version_id: version_uuid,
delete_condition,
synthetic_version_id,
@@ -910,15 +929,16 @@ impl DefaultObjectUsecase {
let deleted = (!delete.quiet.unwrap_or_default()).then(|| {
delete_results
.iter()
.filter_map(|result| result.delete_object.as_ref().map(|object| (result, object)))
.map(|(result, object)| DeletedObject {
.enumerate()
.filter_map(|(idx, result)| result.delete_object.as_ref().map(|object| (idx, result, object)))
.map(|(idx, result, object)| DeletedObject {
delete_marker: { if object.delete_marker { Some(true) } else { None } },
delete_marker_version_id: delete_marker_response_version_id(
object.delete_marker_version_id,
object.delete_marker && result.version_suspended && result.requested_version_id.is_none(),
result.synthetic_version_id,
),
key: Some(object.object_name.clone()),
key: Some(delete.objects[idx].key.clone()),
version_id: delete_entry_response_version_id(
object.version_id,
object.delete_marker,
@@ -936,7 +956,12 @@ impl DefaultObjectUsecase {
let errors = delete_results
.iter()
.filter_map(|v| v.error.clone())
.enumerate()
.filter_map(|(idx, result)| {
let mut error = result.error.clone()?;
error.key = Some(delete.objects[idx].key.clone());
Some(error)
})
.collect::<Vec<s3s::dto::Error>>();
let output = DeleteObjectsOutput {
deleted,
@@ -2818,11 +2843,57 @@ mod tests {
assert_eq!(err.message(), Some("Not init"));
}
#[test]
fn delete_objects_key_normalization_matches_s3s_path_keys() {
use s3s::path::{
parse_path_style_with_validation_and_normalization, parse_virtual_hosted_style_with_validation_and_normalization,
};
use s3s::validation::AwsNameValidation;
for key in [
"plain",
"/a/b",
"//a/b",
"/dir///sub//file",
"/dir///",
"/",
"///",
"dir//file",
"/literal%2F+名",
"/dir/../file",
"/dir/./file",
"/dir\\file",
"/a\0b",
] {
let normalized = normalize_delete_object_key(key);
let path = parse_path_style_with_validation_and_normalization(
&format!("/test-bucket/{key}"),
&AwsNameValidation::new(),
true,
)
.expect("path-style key should parse before storage validation");
let virtual_host = parse_virtual_hosted_style_with_validation_and_normalization(
Some("test-bucket"),
&format!("/{key}"),
&AwsNameValidation::new(),
true,
)
.expect("virtual-hosted key should parse before storage validation");
assert_eq!(Some(normalized.as_ref()), path.get_object_key(), "path-style mismatch for {key:?}");
assert_eq!(
Some(normalized.as_ref()),
virtual_host.get_object_key(),
"virtual-hosted mismatch for {key:?}"
);
}
assert_eq!(normalize_delete_object_key(""), "");
}
#[test]
fn delete_objects_audit_details_include_only_successful_request_entries() {
let requested = vec![
ObjectIdentifier {
key: "first-key".to_string(),
key: "/first-key".to_string(),
version_id: None,
..Default::default()
},
@@ -2832,7 +2903,7 @@ mod tests {
..Default::default()
},
ObjectIdentifier {
key: "versioned-key".to_string(),
key: "//versioned-key".to_string(),
version_id: Some("requested-version".to_string()),
..Default::default()
},
+2 -2
View File
@@ -14460,8 +14460,8 @@ fn object_mutation_entrypoints_call_reserved_prefix_guard() {
assert_ordered(
method(delete, "fn execute_delete_objects_owned("),
&[
"validate_object_key(&obj_id.key, \"DeleteObjects\")",
"if let Err(err) = validate_table_catalog_object_mutation(&bucket, &obj_id.key).await {",
"validate_object_key(&key, \"DeleteObjects\")",
"if let Err(err) = validate_table_catalog_object_mutation(&bucket, &key).await {",
"continue;\n }",
"authorized_deletes.push(AuthorizedDelete { idx, object });",
],
+1 -1
View File
@@ -40,7 +40,7 @@ their issue closes.
| `check_unsafe_code_allowances.sh` | ci-gate | Unsafe-code allowance ledger guard | ci.yml Quick Checks |
| `layer-dependency-baseline.txt` | ci-gate (data) | Committed baseline consumed by `check_layer_dependencies.sh` | [Architecture guard troubleshooting](../docs/operations/architecture-guard-troubleshooting.md) |
| `static.sh` | ci-gate | Static-build helper executed inside image builds | `Dockerfile.source`, `Dockerfile.decommission-local` |
| `helm_chart_version.sh` | ci-gate | Keeps the Helm chart version in sync with the release | helm-package.yml |
| `helm_chart_version.sh` | dev-tool | Normalizes chart/app versions for installation references | rustfs-release-version-bump skill |
| `test_helm_templates.sh` | ci-gate | Helm template rendering test | helm-package.yml |
## Test & e2e runners
+62 -3
View File
@@ -80,6 +80,58 @@ def git_file(root: Path, revision: str, path: str) -> str:
return git(root, "show", f"{revision}:{path}")
def release_metadata_only(root: Path, base: str, head: str, path: str) -> bool:
"""Recognize literal stable-version edits, never general packaging changes."""
stable = r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)"
marker = "<version>"
patterns = {
"flake.nix": [rf'(?m)^([ \t]*pname = "rustfs";\n[ \t]*version = ")({stable})(";)$'],
"helm/rustfs/Chart.yaml": [rf'(?m)^({key}: ")({stable})(")$' for key in ("version", "appVersion")],
"rustfs.spec": [rf'(?m)^(Version:[ \t]+)({stable})()$'],
}
if path not in patterns or not SHA.fullmatch(base) or not SHA.fullmatch(head):
return False
try:
normalized, versions = [], []
for revision in (base, head):
# A symlink, added/deleted file or executable-mode change is a
# build input even if its apparent text resembles metadata.
entry = git(root, "ls-tree", "-z", revision, "--", path)
if not re.fullmatch(rf"100644 blob [0-9a-f]{{40}}\t{re.escape(path)}\x00", entry):
return False
content, values = git_file(root, revision, path), []
# Existing markers could make edits to distinct fields collapse
# to identical normalized text.
if marker in content:
return False
for pattern in patterns[path]:
matches = list(re.finditer(pattern, content))
if len(matches) != 1:
return False
values.append(matches[0][2])
content = re.sub(pattern, lambda match: match[1] + marker + match[3], content)
if len(set(values)) != 1:
return False
normalized.append(content)
versions.append(values[0])
if path == "rustfs.spec":
old, new = (content.split("%changelog\n") for content in normalized)
if len(old) != 2 or len(new) != 2 or old[0] != new[0]:
return False
if old[1] == new[1]:
return True
# Only the standard prepended release note is non-executable;
# RPM macro syntax and edits to older history retain the full gate.
note = (r"\* (?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) "
r"(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [0-9]{2} [0-9]{4} "
r"[A-Za-z0-9 ._-]+ <[A-Za-z0-9._+-]+@[A-Za-z0-9.-]+>\n"
rf"- Update RPM package to RustFS {re.escape(versions[1])}\n\n")
return re.fullmatch(note + re.escape(old[1]), new[1]) is not None
return normalized[0] == normalized[1]
except (OSError, subprocess.CalledProcessError, UnicodeError, ValueError):
return False
def dependency_tables(document: dict):
for key, value in document.items():
if key in ("dependencies", "dev-dependencies", "build-dependencies"):
@@ -265,10 +317,17 @@ def select_plan(event: str, base: str, head: str, ref: str, root: Path, action:
base_packages, base_graph = head_packages, head_graph
reasons.append(f"conservative fallback: {type(error).__name__}")
graph = {name: head_graph.get(name, set()) | base_graph.get(name, set()) for name in set(head_graph) | set(base_graph)}
affected = set()
affected, non_runtime = set(), set()
if not full and policy is not None:
for path in changed:
if documentation_path(path):
non_runtime.add(path)
continue
if (policy["rollout"] == "enforced"
and not any(fnmatch.fnmatchcase(path, pattern) for pattern in policy["full_paths"])
and release_metadata_only(root, base, head, path)):
non_runtime.add(path)
reasons.append(f"literal stable release metadata: {path}")
continue
if path in E2E_SELECTION_FILES:
affected.add("e2e_test")
@@ -290,9 +349,9 @@ def select_plan(event: str, base: str, head: str, ref: str, root: Path, action:
affected.update(owners)
if not affected and not full:
mode = "docs"
reasons.append("all changes are documentation")
reasons.append("all changes are documentation or literal stable release metadata")
elif affected == {"e2e_test"} and not full and not (closure({"e2e_test"}, graph, reverse=True) - {"e2e_test"}) and all(
documentation_path(path) or path in E2E_SELECTION_FILES or
path in non_runtime or path in E2E_SELECTION_FILES or
(path.startswith("crates/e2e_test/src/") and path.endswith(".rs")) for path in changed):
mode = "e2e"
reasons.append("isolated E2E harness changes")
+1 -6
View File
@@ -17,12 +17,7 @@ case "$RAW" in
esac
APP_VERSION="${RAW_TAG#v}"
BETA_NUM=$(printf '%s\n' "$APP_VERSION" | sed -n -E 's/^.*-beta\.([0-9]+)$/\1/p')
if [ -n "$BETA_NUM" ]; then
CHART_VERSION="0.${BETA_NUM}.0"
else
CHART_VERSION="$APP_VERSION"
fi
CHART_VERSION="$APP_VERSION"
if [ -n "${GITHUB_OUTPUT:-}" ]; then
{
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# Copyright 2026 RustFS Team
# SPDX-License-Identifier: Apache-2.0
set -euo pipefail
PKG_DIR="${DEB_FILE%.deb}"
echo "Building DEB: ${DEB_FILE}"
mkdir -p "${PKG_DIR}/DEBIAN"
mkdir -p "${PKG_DIR}/usr/bin"
mkdir -p "${PKG_DIR}/etc/default"
mkdir -p "${PKG_DIR}/lib/systemd/system"
mkdir -p "${PKG_DIR}/usr/share/doc/rustfs"
cp ./bin/rustfs "${PKG_DIR}/usr/bin/"
chmod 755 "${PKG_DIR}/usr/bin/rustfs"
cp deploy/build/rustfs.service "${PKG_DIR}/lib/systemd/system/"
cat > "${PKG_DIR}/etc/default/rustfs" << 'ENVEOF'
# RustFS Environment Configuration
# See https://docs.rustfs.com for more information
# RUSTFS_ACCESS_KEY=<changeme>
# RUSTFS_SECRET_KEY=<changeme>
# RUSTFS_VOLUMES=""
# RUSTFS_ADDRESS=":9000"
# RUSTFS_CONSOLE_ADDRESS=":9001"
# RUSTFS_CONSOLE_ENABLE=true
# RUSTFS_OBS_LOGGER_LEVEL=error
# RUSTFS_OBS_LOG_DIRECTORY="/var/log/rustfs/"
ENVEOF
cat > "${PKG_DIR}/DEBIAN/control" << EOF
Package: rustfs
Version: ${DEB_VERSION}
Section: utils
Priority: optional
Architecture: ${DEB_ARCH}
EOF
# gnu binaries dynamically link glibc and must declare it; musl
# binaries are statically linked and need no libc dependency.
if [[ -n "$DEB_DEPENDS" ]]; then
echo "Depends: ${DEB_DEPENDS}" >> "${PKG_DIR}/DEBIAN/control"
fi
cat >> "${PKG_DIR}/DEBIAN/control" << EOF
Maintainer: RustFS Team <support@rustfs.com>
Description: High-performance distributed object storage (${LIBC} build)
RustFS is a high-performance distributed object storage software
built using Rust. It is compatible with MinIO and S3 API.
Homepage: https://rustfs.com
EOF
# Declare /etc/default/rustfs as a conffile so dpkg preserves user
# modifications on upgrade instead of silently overwriting them.
cat > "${PKG_DIR}/DEBIAN/conffiles" << 'CONFFILES'
/etc/default/rustfs
CONFFILES
./scripts/release/package_service_scripts.sh after-install > "${PKG_DIR}/DEBIAN/postinst"
chmod 755 "${PKG_DIR}/DEBIAN/postinst"
./scripts/release/package_service_scripts.sh before-remove > "${PKG_DIR}/DEBIAN/prerm"
chmod 755 "${PKG_DIR}/DEBIAN/prerm"
./scripts/release/package_service_scripts.sh after-remove > "${PKG_DIR}/DEBIAN/postrm"
chmod 755 "${PKG_DIR}/DEBIAN/postrm"
cp LICENSE "${PKG_DIR}/usr/share/doc/rustfs/"
cp README.md "${PKG_DIR}/usr/share/doc/rustfs/"
fakeroot dpkg-deb --build "${PKG_DIR}" "$DEB_FILE"
[[ $(dpkg-deb -f "$DEB_FILE" Package) == rustfs ]]
[[ $(dpkg-deb -f "$DEB_FILE" Version) == "$DEB_VERSION" ]]
[[ $(dpkg-deb -f "$DEB_FILE" Architecture) == "$DEB_ARCH" ]]
dpkg-deb --fsys-tarfile "$DEB_FILE" | tar -tf - | grep -Fx './usr/bin/rustfs' >/dev/null
stat --printf='%n %s bytes\n' "$DEB_FILE"
echo "deb_file=$DEB_FILE" >> "$GITHUB_OUTPUT"
echo "✅ DEB built: $DEB_FILE"
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Copyright 2026 RustFS Team
# SPDX-License-Identifier: Apache-2.0
set -euo pipefail
echo "Building RPM for ${RPM_ARCH} (${LIBC})"
./scripts/test_package_versions.sh --require-package-managers
# gnu binaries dynamically link glibc and must declare it; musl
# binaries are statically linked and need no libc dependency.
depends_args=()
if [[ -n "$RPM_DEPENDS" ]]; then
depends_args+=(--depends "$RPM_DEPENDS")
fi
# Create config file for fpm (DEB build creates it in its package dir structure,
# but fpm needs the file to exist before packaging)
mkdir -p ./tmp-pkg/etc/default
cat > ./tmp-pkg/etc/default/rustfs << 'ENVEOF'
# RustFS Environment Configuration
# See https://docs.rustfs.com for more information
# RUSTFS_ACCESS_KEY=<changeme>
# RUSTFS_SECRET_KEY=<changeme>
# RUSTFS_VOLUMES=""
# RUSTFS_ADDRESS=":9000"
# RUSTFS_CONSOLE_ADDRESS=":9001"
# RUSTFS_CONSOLE_ENABLE=true
# RUSTFS_OBS_LOGGER_LEVEL=error
# RUSTFS_OBS_LOG_DIRECTORY="/var/log/rustfs/"
ENVEOF
fpm -s dir -t rpm \
--name rustfs \
--version "$RPM_VERSION" \
--iteration "$RPM_RELEASE" \
--architecture "$RPM_ARCH" \
--package "$RPM_FILE" \
"${depends_args[@]}" \
--description "High-performance distributed object storage (${LIBC} build)" \
--maintainer "RustFS Team <support@rustfs.com>" \
--url "https://rustfs.com" \
--license "Apache-2.0" \
--before-install <(./scripts/release/package_service_scripts.sh rpm-before-install) \
--before-upgrade <(./scripts/release/package_service_scripts.sh rpm-before-upgrade) \
--after-install <(./scripts/release/package_service_scripts.sh after-install) \
--after-upgrade <(./scripts/release/package_service_scripts.sh after-install) \
--before-remove <(./scripts/release/package_service_scripts.sh before-remove) \
--after-remove <(./scripts/release/package_service_scripts.sh after-remove) \
--rpm-posttrans <(./scripts/release/package_service_scripts.sh rpm-posttrans) \
--config-files /etc/default/rustfs \
./bin/rustfs=/usr/bin/rustfs \
./tmp-pkg/etc/default/rustfs=/etc/default/rustfs \
deploy/build/rustfs.service=/lib/systemd/system/rustfs.service \
LICENSE=/usr/share/doc/rustfs/LICENSE \
README.md=/usr/share/doc/rustfs/README.md
if [[ ! -f "$RPM_FILE" ]]; then
echo "❌ RPM build failed"
exit 1
fi
RPM_METADATA=$(rpm -qp --qf '%{NAME}\n%{VERSION}\n%{RELEASE}\n%{ARCH}\n' "$RPM_FILE")
EXPECTED_METADATA=$(printf 'rustfs\n%s\n%s\n%s' "$RPM_VERSION" "$RPM_RELEASE" "$RPM_ARCH")
[[ "$RPM_METADATA" == "$EXPECTED_METADATA" ]]
rpm -qpl "$RPM_FILE" | grep -Fx '/usr/bin/rustfs' >/dev/null
stat --printf='%n %s bytes\n' "$RPM_FILE"
echo "rpm_file=$RPM_FILE" >> "$GITHUB_OUTPUT"
echo "✅ RPM built: $RPM_FILE"
+428
View File
@@ -0,0 +1,428 @@
#!/usr/bin/env python3
# Copyright 2026 RustFS Team
# SPDX-License-Identifier: Apache-2.0
"""Frozen release inputs and artifact identity; publication never builds binaries."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile
import tomllib
import urllib.request
from urllib.parse import urlsplit
import zipfile
ROOT = Path(__file__).resolve().parents[2]
REPOSITORY = "rustfs/rustfs"
SCHEMA = 1
HEX40 = re.compile(r"[0-9a-f]{40}\Z")
HEX64 = re.compile(r"[0-9a-f]{64}\Z")
VERSION = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-(?:alpha|beta|rc)\.(0|[1-9][0-9]*))?\Z")
TARGETS = ("linux-x86_64-musl", "linux-aarch64-musl", "linux-x86_64-gnu", "linux-aarch64-gnu", "macos-aarch64", "windows-x86_64")
LINUX = TARGETS[:4]
DESKTOP = TARGETS[4:]
MAX_MEMBER = 2 * 1024**3
RECIPE_PATHS = (
".github/workflows/build.yml",
".github/workflows/release-candidate.yml",
".github/workflows/release-complete.yml",
".github/workflows/release-publish.yml",
".github/workflows/docker.yml",
".github/workflows/package.yml",
".github/actions/setup/action.yml",
".github/actions/setup/download_release_asset.py",
"scripts/release/candidate.py",
"scripts/release/pipeline.py",
"scripts/release/verify.py",
"scripts/release/deliver.py",
"scripts/release/publish.py",
"scripts/release/release_gate.py",
"scripts/release/package_versions.sh",
"scripts/release/package_service_scripts.sh",
"scripts/release/build_deb.sh",
"scripts/release/build_rpm.sh",
"scripts/release/check_glibc.py",
".config/release-validation-plan.json",
"Dockerfile", "Dockerfile.glibc", "entrypoint.sh", "deploy/build/rustfs.service",
".github/workflows/helm-package.yml", "scripts/test_helm_templates.sh",
"scripts/security/generate_release_supply_chain_assets.py",
".github/workflows/ci.yml", "scripts/ci_gate.py",
)
def require(condition, message):
if not condition:
raise ValueError(message)
def digest(value):
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest()
def stream_digest(stream):
result = hashlib.sha256()
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
result.update(chunk)
return result.hexdigest()
def file_digest(path):
with Path(path).open("rb") as stream:
return stream_digest(stream)
def read_json(path):
path = Path(path)
require(path.is_file() and path.stat().st_size <= 4 * 1024**2, "missing or oversized JSON record")
def unique(pairs):
value = {}
for name, item in pairs:
require(name not in value, "duplicate JSON field")
value[name] = item
return value
return json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique)
def write_json(path, value):
"""Existing identity records are immutable, including across job retries."""
path = Path(path)
if path.exists():
require(read_json(path) == value, "refusing to replace an existing identity record")
return
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("x", encoding="utf-8") as stream:
json.dump(value, stream, indent=2, sort_keys=True, ensure_ascii=False)
stream.write("\n")
def command(args, *, input_text=None, cwd=None):
return subprocess.run(args, input=input_text, text=True, capture_output=True, check=True, cwd=cwd, timeout=120).stdout.strip()
class ArtifactRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, request, fp, code, message, headers, url):
require(urlsplit(url).scheme == "https", "artifact redirect must use HTTPS")
redirected = super().redirect_request(request, fp, code, message, headers, url)
if redirected is not None and urlsplit(request.full_url).netloc != urlsplit(url).netloc:
redirected.remove_header("Authorization")
return redirected
def github_stream(path):
require(path.startswith("repos/") and ":" not in path and not path.startswith("/"), "invalid GitHub API path")
token = os.environ.get("GH_TOKEN") or command(["gh", "auth", "token"])
request = urllib.request.Request("https://api.github.com/" + path,
headers={"Authorization": "Bearer " + token, "Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28", "User-Agent": "rustfs-release"})
return urllib.request.build_opener(ArtifactRedirect()).open(request, timeout=600)
def github(path):
with github_stream(path) as response:
return json.load(response)
def git_file(revision, path, root=ROOT):
require(HEX40.fullmatch(revision) is not None, "source must be a full commit SHA")
return subprocess.check_output(["git", "show", f"{revision}:{path}"], cwd=root, timeout=60)
def recipe_digest(revision, root=ROOT):
files = {path: hashlib.sha256(git_file(revision, path, root)).hexdigest() for path in RECIPE_PATHS}
for directory in ("bootstrap", "helm"):
paths = command(["git", "ls-tree", "-r", "--name-only", revision, "--", directory], cwd=root).splitlines()
require(bool(paths), "missing release recipe directory")
files.update({path: hashlib.sha256(git_file(revision, path, root)).hexdigest() for path in paths})
return digest(files)
def console_asset(release):
require(isinstance(release, dict) and release.get("draft") is False and release.get("prerelease") is False,
"Console must be a published stable release")
tag = release.get("tag_name")
require(isinstance(tag, str) and re.fullmatch(r"v?[0-9]+\.[0-9]+\.[0-9]+", tag), "invalid Console version")
assets = [asset for asset in release.get("assets", [])
if re.fullmatch(r"rustfs-console-[A-Za-z0-9._-]+\.zip", asset.get("name", ""))]
require(len(assets) == 1, "Console must have exactly one unambiguous ZIP asset")
asset = assets[0]
checksum = asset.get("digest", "")
require(isinstance(checksum, str) and checksum.startswith("sha256:") and HEX64.fullmatch(checksum[7:]),
"Console asset must have a SHA-256 digest")
expected = f"https://github.com/rustfs/console/releases/download/{tag}/{asset['name']}"
require(asset.get("browser_download_url") == expected, "Console URL does not match its release identity")
return {"tag": tag, "name": asset["name"], "url": expected, "sha256": checksum[7:]}
def release_asset(release, repository, name):
require(release.get("draft") is False and release.get("prerelease") is False, "dependency must be a published stable release")
tag = release.get("tag_name", "")
require(re.fullmatch(r"v?[0-9]+\.[0-9]+\.[0-9]+", tag), "invalid dependency tag")
matches = [a for a in release.get("assets", []) if a.get("name") == name]
require(len(matches) == 1, "missing or ambiguous pinned dependency")
asset = matches[0]
url = f"https://github.com/{repository}/releases/download/{tag}/{name}"
require(asset.get("browser_download_url") == url and re.fullmatch(r"sha256:[0-9a-f]{64}", asset.get("digest", "")), "invalid dependency identity")
return {"version": tag, "name": name, "url": url, "sha256": asset["digest"][7:]}
def validate_dependency(asset, repository):
require(isinstance(asset, dict) and set(asset) == {"version", "name", "url", "sha256"}, "invalid dependency record")
require(re.fullmatch(r"[A-Za-z0-9._-]+", asset["name"]), "unsafe dependency name")
normalized = release_asset({"draft": False, "prerelease": False, "tag_name": asset["version"],
"assets": [{"name": asset["name"], "browser_download_url": asset["url"],
"digest": "sha256:" + asset["sha256"]}]}, repository, asset["name"])
require(normalized == asset, "dependency record changed")
def validate_inputs(value):
require(isinstance(value, dict), "candidate inputs must be an object")
fields = {"schema", "candidate_id", "version", "source_sha", "workflow_sha", "recipe_sha256", "lock_sha256",
"plan_sha256", "toolchain", "console", "cli", "previous", "ci", "origin_run_id", "origin_run_number"}
require(set(value) == fields and type(value["schema"]) is int and value["schema"] == SCHEMA,
"unsupported candidate input schema")
require(isinstance(value["version"], str) and VERSION.fullmatch(value["version"]), "invalid release version")
for name in ("source_sha", "workflow_sha"):
require(isinstance(value[name], str) and HEX40.fullmatch(value[name]), f"invalid {name}")
for name in ("recipe_sha256", "lock_sha256", "plan_sha256"):
require(isinstance(value[name], str) and HEX64.fullmatch(value[name]), f"invalid {name}")
for name in ("origin_run_id", "origin_run_number"):
require(type(value[name]) is int and value[name] > 0, f"invalid {name}")
require(value["candidate_id"] == f"{value['version']}-preview.{value['origin_run_number']}", "candidate name differs from its immutable run identity")
require(isinstance(value["toolchain"], str) and re.fullmatch(r"[1-9][0-9]*\.[0-9]+\.[0-9]+", value["toolchain"]),
"candidate toolchain must be an exact stable Rust version")
console = value["console"]
require(isinstance(console, dict) and set(console) == {"tag", "name", "url", "sha256"}, "invalid Console identity")
normalized = console_asset({"draft": False, "prerelease": False, "tag_name": console["tag"],
"assets": [{"name": console["name"], "browser_download_url": console["url"],
"digest": "sha256:" + str(console["sha256"])}]})
require(normalized == console, "invalid pinned Console asset")
require(isinstance(value["cli"], dict) and set(value["cli"]) == set(LINUX), "missing CLI platform")
for asset in value["cli"].values():
validate_dependency(asset, "rustfs/cli")
validate_dependency(value["previous"], REPOSITORY)
require(value["previous"]["version"].lstrip("v") != value["version"], "previous release must differ from the candidate")
require(isinstance(value["ci"], dict) and set(value["ci"]) == {"run_id", "run_attempt"}
and all(type(item) is int and item > 0 for item in value["ci"].values()), "invalid exact-source CI identity")
return value
def freeze(version, source_sha, workflow_sha, toolchain, release, run_id, run_number, cli, previous, ci, root=ROOT):
require(VERSION.fullmatch(version) is not None, "invalid release version")
source = tomllib.loads(git_file(source_sha, "Cargo.toml", root).decode())
require(source["workspace"]["package"]["version"] == version, "Cargo workspace version differs from the target")
recipe = recipe_digest(workflow_sha, root)
require(recipe_digest(source_sha, root) == recipe, "candidate must contain the reviewed release recipe")
plan = json.loads(git_file(workflow_sha, ".config/release-validation-plan.json", root))
value = {"schema": SCHEMA, "version": version, "candidate_id": f"{version}-preview.{run_number}",
"source_sha": source_sha, "workflow_sha": workflow_sha, "recipe_sha256": recipe,
"lock_sha256": hashlib.sha256(git_file(source_sha, "Cargo.lock", root)).hexdigest(),
"plan_sha256": digest(plan), "toolchain": toolchain, "console": console_asset(release),
"origin_run_id": run_id, "origin_run_number": run_number, "ci": ci,
"cli": {target: release_asset(cli, "rustfs/cli", f"rustfs-cli-linux-{'amd64' if 'x86_64' in target else 'arm64'}{'-gnu' if target.endswith('gnu') else ''}-{cli['tag_name']}.tar.gz") for target in LINUX},
"previous": release_asset(previous, REPOSITORY, f"rustfs-linux-x86_64-gnu-v{previous['tag_name'].lstrip('v')}.zip")}
return validate_inputs(value)
def targets(selection):
if selection == "linux":
return LINUX
if selection == "desktop":
return DESKTOP
if selection == "all":
return TARGETS
selected = tuple(selection.split(","))
require(selected and len(set(selected)) == len(selected) and all(item in TARGETS for item in selected),
"unknown or duplicate build targets")
return selected
def archive_record(path, target, inputs):
validate_inputs(inputs)
require(target in TARGETS, "unknown binary platform")
path = Path(path)
name = f"rustfs-{target}-v{inputs['version']}.zip"
require(path.name == name and path.is_file() and not path.is_symlink(), "archive name does not match the target version")
binary = "rustfs.exe" if target == "windows-x86_64" else "rustfs"
expected = {binary}
if target.endswith("-gnu"):
expected.update(("rustfs.cpu-symbol-catalog.json", "rustfs.cpu-symbol-catalog.sha256"))
with zipfile.ZipFile(path) as archive:
members = archive.infolist()
require(len(members) == len(expected) and {m.filename for m in members} == expected,
"unexpected or duplicate release archive member")
for member in members:
require(not member.is_dir() and 0 < member.file_size <= MAX_MEMBER and (member.external_attr >> 16) & 0o170000 != 0o120000,
"invalid release archive member")
with archive.open(binary) as stream:
binary_sha = stream_digest(stream)
return {"target": target, "name": name, "kind": "zip", "sha256": file_digest(path), "size": path.stat().st_size,
"binary_sha256": binary_sha, "inputs_sha256": digest(inputs)}
def verify_records(inputs, records, directory, required):
validate_inputs(inputs)
require(isinstance(records, list) and len(records) == len(required), "candidate is missing required platform artifacts")
seen = set()
for record in records:
require(isinstance(record, dict) and record.get("target") in required, "unexpected artifact platform")
target = record["target"]
require(target not in seen, "duplicate artifact platform")
seen.add(target)
expected = archive_record(Path(directory) / f"rustfs-{target}-v{inputs['version']}.zip", target, inputs)
require(record == expected, "candidate artifact or input identity changed")
require(seen == set(required), "incomplete candidate platform set")
return sorted(records, key=lambda record: record["target"])
def certification_candidate(inputs, records, directory, required=TARGETS):
"""Candidate identity survives adding desktop files; test receipts bind bytes."""
records = verify_records(inputs, records, directory, required)
return {"schema_version": 1, "product_commit": inputs["source_sha"], "version": inputs["version"],
"release_profile": "prerelease" if "-" in inputs["version"] else "stable", "features": ["pyroscope"],
"plan_sha256": inputs["plan_sha256"], "workflow_sha": inputs["workflow_sha"],
"validation_batch": {"run_id": inputs["candidate_id"], "run_attempt": 1},
"deliverables": [{"kind": "zip", "name": r["name"], "sha256": r["sha256"], "size": r["size"],
"binary_sha256": r["binary_sha256"],
"location": f"https://github.com/{REPOSITORY}/actions/runs/{inputs['origin_run_id']}#artifact-{r['sha256']}"}
for r in records]}
def validate_run(run, workflow, *, completed=True):
require(isinstance(run, dict) and (run.get("repository") or {}).get("full_name") == REPOSITORY,
"build run belongs to another repository")
require(run.get("path") == f".github/workflows/{workflow}" and run.get("event") == "workflow_dispatch"
and run.get("head_branch") == "main", "candidate must originate from the trusted main workflow")
require(type(run.get("id")) is int and run["id"] > 0 and type(run.get("run_attempt")) is int and run["run_attempt"] > 0,
"invalid workflow run identity")
require(isinstance(run.get("head_sha"), str) and HEX40.fullmatch(run["head_sha"]), "invalid workflow source SHA")
if completed:
require(run.get("status") == "completed" and run.get("conclusion") == "success", "candidate run did not succeed")
return run
def validate_artifact(artifact, run, name):
require(artifact.get("name") == name and artifact.get("expired") is False, "missing or expired candidate artifact")
origin = artifact.get("workflow_run") or {}
require(origin.get("id") == run["id"] and origin.get("head_sha") == run["head_sha"], "artifact belongs to another build run")
require(type(artifact.get("id")) is int and artifact["id"] > 0 and type(artifact.get("size_in_bytes")) is int
and 0 < artifact["size_in_bytes"] <= 8 * MAX_MEMBER, "invalid artifact identity or size")
require(isinstance(artifact.get("digest"), str) and re.fullmatch(r"sha256:[0-9a-f]{64}", artifact["digest"]),
"artifact has no immutable digest")
return artifact
def artifact_list(run_id):
require(type(run_id) is int and run_id > 0, "invalid run ID")
result = []
for page in range(1, 101):
batch = github(f"repos/{REPOSITORY}/actions/runs/{run_id}/artifacts?per_page=100&page={page}")["artifacts"]
result.extend(batch)
if len(batch) < 100:
return result
raise ValueError("too many candidate artifacts")
def download_artifact(run, name, destination):
matches = [item for item in artifact_list(run["id"]) if item.get("name") == name]
require(len(matches) == 1, "candidate artifact is absent or ambiguous")
artifact = validate_artifact(matches[0], run, name)
destination = Path(destination)
destination.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory() as temp:
archive_path = Path(temp) / "artifact.zip"
with archive_path.open("wb") as stream, github_stream(f"repos/{REPOSITORY}/actions/artifacts/{artifact['id']}/zip") as response:
received = 0
for chunk in iter(lambda: response.read(1024 * 1024), b""):
received += len(chunk)
require(received <= artifact["size_in_bytes"], "artifact response exceeds its declared size")
stream.write(chunk)
require(archive_path.stat().st_size == artifact["size_in_bytes"] and
"sha256:" + file_digest(archive_path) == artifact["digest"], "downloaded artifact digest or size mismatch")
with zipfile.ZipFile(archive_path) as archive:
members = archive.infolist()
require(0 < len(members) <= 256 and len({m.filename for m in members}) == len(members), "invalid artifact members")
require(sum(member.file_size for member in members) <= 8 * MAX_MEMBER, "expanded artifact exceeds the candidate size limit")
for member in members:
require(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,200}", member.filename)
and not member.is_dir() and 0 < member.file_size <= MAX_MEMBER
and (member.external_attr >> 16) & 0o170000 != 0o120000, "unsafe artifact member")
target = destination / member.filename
require(not target.exists(), "artifact extraction would overwrite a file")
with archive.open(member) as source, target.open("xb") as sink:
import shutil
shutil.copyfileobj(source, sink)
return artifact
def emit_output(name, value):
require("\n" not in str(value) and "\r" not in str(value), "invalid workflow output")
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as stream:
stream.write(f"{name}={value}\n")
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
p = sub.add_parser("freeze")
for name in ("version", "source-sha", "workflow-sha", "toolchain", "console", "cli", "previous", "output"):
p.add_argument("--" + name, required=True)
p.add_argument("--run-id", type=int, required=True)
p.add_argument("--run-number", type=int, required=True)
p.add_argument("--ci-run-id", type=int, required=True)
p.add_argument("--ci-run-attempt", type=int, required=True)
p = sub.add_parser("validate-inputs")
p.add_argument("--inputs", required=True)
p.add_argument("--check-checkout", action="store_true")
p = sub.add_parser("record")
p.add_argument("--inputs", required=True)
p.add_argument("--target", required=True)
p.add_argument("--archive", required=True)
p.add_argument("--output", required=True)
p = sub.add_parser("assemble")
p.add_argument("--inputs", required=True)
p.add_argument("--directory", required=True)
p.add_argument("--output", required=True)
p.add_argument("--platforms", choices=("linux", "all"), default="all")
p = sub.add_parser("download")
p.add_argument("--run-id", type=int, required=True)
p.add_argument("--workflow", choices=("release-candidate.yml", "release-complete.yml"), required=True)
p.add_argument("--name", required=True)
p.add_argument("--destination", required=True)
args = parser.parse_args(argv)
if args.command == "freeze":
write_json(args.output, freeze(args.version, args.source_sha, args.workflow_sha, args.toolchain,
read_json(args.console), args.run_id, args.run_number, read_json(args.cli), read_json(args.previous),
{"run_id": args.ci_run_id, "run_attempt": args.ci_run_attempt}))
elif args.command == "validate-inputs":
inputs = validate_inputs(read_json(args.inputs))
if args.check_checkout:
head = command(["git", "rev-parse", "HEAD"], cwd=ROOT)
require(head == inputs["source_sha"], "checkout differs from the frozen source")
require(recipe_digest(head) == inputs["recipe_sha256"], "release recipe differs from frozen inputs")
require(file_digest(ROOT / "Cargo.lock") == inputs["lock_sha256"], "dependency lock differs from frozen inputs")
print(digest(inputs))
elif args.command == "record":
write_json(args.output, archive_record(args.archive, args.target, read_json(args.inputs)))
elif args.command == "assemble":
inputs = validate_inputs(read_json(args.inputs))
directory = Path(args.directory)
records = [read_json(path) for path in sorted(directory.glob("record-*.json"))]
write_json(args.output, certification_candidate(inputs, records, directory, targets(args.platforms)))
elif args.command == "download":
run = validate_run(github(f"repos/{REPOSITORY}/actions/runs/{args.run_id}"), args.workflow)
download_artifact(run, args.name, args.destination)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (ValueError, OSError, KeyError, TypeError, zipfile.BadZipFile, subprocess.CalledProcessError) as error:
print(f"candidate rejected: {error}", file=sys.stderr)
raise SystemExit(1) from None
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env python3
# Copyright 2026 RustFS Team
# SPDX-License-Identifier: Apache-2.0
"""Wrap accepted binaries, then inspect the actual packages and OCI images."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import shutil
import subprocess
import tarfile
import tempfile
import urllib.request
import time
import candidate as c
import pipeline
def run(args, **kwargs):
return subprocess.run(args, check=True, timeout=1800, **kwargs)
def binary(inputs, target, output):
origin = pipeline.get_run(inputs["origin_run_id"], "release-candidate.yml")
c.download_artifact(origin, f"candidate-{inputs['candidate_id']}-{target}", output)
record = c.read_json(output / f"record-{target}.json")
c.verify_records(inputs, [record], output, (target,))
pipeline.verify_evidence(inputs, output, {origin["id"]: origin})
import verify
path, _ = verify.unpack(output / record["name"], target, inputs, output / "bin")
return path, record
def reuse(kind, target):
inputs = pipeline.manifest_env()
current = pipeline.current_run("release-complete.yml")
name = f"{kind}-{target}"
exists = any(a["name"] == name for a in c.artifact_list(current["id"]))
if exists:
with tempfile.TemporaryDirectory() as temp:
c.download_artifact(current, name, temp)
record = c.read_json(Path(temp) / f"{name}.json")
c.require(record.get("inputs_sha256") == c.digest(inputs), "existing delivery belongs to another candidate")
# Successful jobs are immutable. The final seal independently checks
# all sizes and digests before they can be approved for publication.
c.emit_output("reused", str(exists).lower())
def package(inputs, target, output):
output.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory() as temp:
temp = Path(temp)
executable, record = binary(inputs, target, temp / "source")
arch, libc = target.split("-")[1:]
build_type = "prerelease" if "-" in inputs["version"] else "release"
values = c.command(["bash", "scripts/release/package_versions.sh", build_type, inputs["version"], "", arch, libc], cwd=c.ROOT)
versions = dict(line.split("=", 1) for line in values.splitlines())
# Existing package recipes operate in a private working directory.
for name in ("scripts", "deploy"):
(temp / name).symlink_to(c.ROOT / name, target_is_directory=True)
for name in ("LICENSE", "README.md"):
shutil.copyfile(c.ROOT / name, temp / name)
(temp / "bin").mkdir()
shutil.copyfile(executable, temp / "bin/rustfs")
(temp / "bin/rustfs").chmod(0o755)
env = dict(os.environ, **{key.upper(): value for key, value in versions.items()}, LIBC=libc,
DEB_ARCH="amd64" if arch == "x86_64" else "arm64", RPM_ARCH=arch,
DEB_DEPENDS="libc6 (>= 2.31)" if libc == "gnu" else "",
RPM_DEPENDS="glibc >= 2.31" if libc == "gnu" else "", GITHUB_OUTPUT=str(temp / "outputs"))
for kind in ("deb", "rpm"):
run(["bash", str(c.ROOT / f"scripts/release/build_{kind}.sh")], cwd=temp, env=env)
path = temp / versions[f"{kind}_file"]
unpacked = temp / f"unpacked-{kind}"
unpacked.mkdir()
if kind == "deb":
run(["dpkg-deb", "--extract", str(path), str(unpacked)])
else:
with subprocess.Popen(["rpm2cpio", str(path)], stdout=subprocess.PIPE) as source:
run(["cpio", "--extract", "--make-directories", "--quiet", "./usr/bin/rustfs"], stdin=source.stdout, cwd=unpacked)
source.stdout.close()
c.require(source.wait(timeout=60) == 0, "RPM extraction failed")
c.require(c.file_digest(unpacked / "usr/bin/rustfs") == record["binary_sha256"], "package binary differs from accepted ZIP")
shutil.copyfile(path, output / path.name)
files = pipeline.file_records(output)
c.write_json(output / f"packages-{target}.json", {"inputs_sha256": c.digest(inputs), "binary_sha256": record["binary_sha256"], "files": files})
def image_prepare(inputs, variant, output):
output.mkdir(parents=True, exist_ok=True)
binaries = {}
for arch, docker_arch in (("x86_64", "amd64"), ("aarch64", "arm64")):
target = f"linux-{arch}-{variant}"
with tempfile.TemporaryDirectory() as temp:
temp = Path(temp)
executable, record = binary(inputs, target, temp / "source")
directory = c.ROOT / ".release-inputs" / docker_arch
directory.mkdir(parents=True, exist_ok=True)
shutil.copyfile(executable, directory / "rustfs")
(directory / "rustfs").chmod(0o755)
cli = inputs["cli"][target]
archive = temp / "cli.tar.gz"
run(["curl", "--fail", "--location", "--retry", "3", "--output", str(archive), cli["url"]])
c.require(c.file_digest(archive) == cli["sha256"], "CLI dependency changed")
with tarfile.open(archive, "r:gz") as package:
members = [m for m in package.getmembers() if m.name == "rc"]
c.require(len(members) == 1 and members[0].isfile() and 0 < members[0].size <= c.MAX_MEMBER, "invalid CLI archive")
with package.extractfile(members[0]) as source, (directory / "rc").open("wb") as dest:
shutil.copyfileobj(source, dest)
(directory / "rc").chmod(0o755)
binaries[arch] = record["binary_sha256"]
c.write_json(output / f"image-{variant}-inputs.json", {"inputs_sha256": c.digest(inputs), "binaries": binaries})
def image_smoke(inputs, variant, output):
archive = output / f"image-{variant}.tar"
record = c.read_json(output / f"image-{variant}-inputs.json")
scan_directory = Path(str(output) + "-scan")
scan_directory.mkdir(parents=True, exist_ok=True)
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
for arch, docker_arch in (("x86_64", "amd64"), ("aarch64", "arm64")):
image = f"rustfs-acceptance:{variant}-{docker_arch}"
run(["skopeo", "copy", "--override-os", "linux", "--override-arch", docker_arch, f"oci-archive:{archive}", f"docker-daemon:{image}"])
container = c.command(["docker", "create", "--platform", f"linux/{docker_arch}", image])
try:
with tempfile.TemporaryDirectory() as temp:
path = Path(temp) / "rustfs"
run(["docker", "cp", f"{container}:/usr/bin/rustfs", str(path)])
c.require(c.file_digest(path) == record["binaries"][arch], "image wraps a different executable")
finally:
run(["docker", "rm", "-v", container])
version = c.command(["docker", "run", "--rm", "--platform", f"linux/{docker_arch}", "--entrypoint", "/usr/bin/rustfs", image, "--version"])
c.require(version.splitlines()[0] == f"rustfs {inputs['version']}", "container displays wrong version")
container = c.command(["docker", "run", "-d", "--platform", f"linux/{docker_arch}", "-p", "127.0.0.1::9001",
"-e", "RUSTFS_ACCESS_KEY=release-smoke", "-e", "RUSTFS_SECRET_KEY=release-smoke-test-secret",
"-e", "RUSTFS_CONSOLE_ENABLE=true", image])
log = output / f"image-{variant}-{docker_arch}.log"
try:
address = c.command(["docker", "port", container, "9001/tcp"]).strip()
deadline = time.monotonic() + 60
while time.monotonic() < deadline:
try:
with opener.open(f"http://{address}/rustfs/console/", timeout=2) as response:
c.require(response.status == 200 and "text/html" in response.headers.get("Content-Type", ""), "image Console did not return HTML")
break
except OSError:
time.sleep(0.5)
else:
raise ValueError("container startup timed out")
finally:
with log.open("w") as stream:
subprocess.run(["docker", "logs", container], stdout=stream, stderr=subprocess.STDOUT, check=True, timeout=60)
run(["docker", "rm", "-f", "-v", container])
with log.open("a") as stream:
stream.write("\nConsole: HTTP 200 text/html; wrapped executable SHA-256 and version verified\n")
# Trivy's local archive reader does not reliably select a platform from
# a multi-architecture OCI index. Export the image just verified above.
scan_archive = scan_directory / f"image-{variant}-{docker_arch}.tar"
run(["docker", "save", "--output", str(scan_archive), image])
record.setdefault("scan_inputs", {})[docker_arch] = c.file_digest(scan_archive)
raw = subprocess.check_output(["skopeo", "inspect", "--raw", f"oci-archive:{archive}"], timeout=60)
import hashlib
record.update(digest="sha256:" + hashlib.sha256(raw).hexdigest(), sha256=c.file_digest(archive), size=archive.stat().st_size, smoke="pass")
c.write_json(output / f"image-{variant}-smoke.json", record)
def image_record(inputs, variant, output):
record = c.read_json(output / f"image-{variant}-smoke.json")
c.require(record["inputs_sha256"] == c.digest(inputs), "image inputs changed")
for arch in ("amd64", "arm64"):
scan_archive = Path(str(output) + "-scan") / f"image-{variant}-{arch}.tar"
c.require(record.get("scan_inputs", {}).get(arch) == c.file_digest(scan_archive), "scan input changed after image verification")
report = c.read_json(output / f"scan-{variant}-{arch}.sarif")
c.require(report.get("version") == "2.1.0" and report.get("runs"), "container scan did not produce a SARIF report")
record["scan"] = "completed"
c.write_json(output / f"image-{variant}.json", record)
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("mode", choices=("reuse", "package", "image-prepare", "image-smoke", "image-record"))
p.add_argument("--kind", choices=("packages", "image"))
p.add_argument("--target", required=True)
p.add_argument("--output", type=Path, default=Path("delivery"))
args = p.parse_args()
inputs = pipeline.manifest_env()
if args.mode == "reuse":
reuse(args.kind, args.target)
elif args.mode == "package":
c.require(args.target in c.LINUX, "unknown package platform")
package(inputs, args.target, args.output)
else:
c.require(args.target in ("musl", "gnu"), "unknown image variant")
{"image-prepare": image_prepare, "image-smoke": image_smoke, "image-record": image_record}[args.mode](inputs, args.target, args.output)
if __name__ == "__main__":
main()
+361
View File
@@ -0,0 +1,361 @@
#!/usr/bin/env python3
# Copyright 2026 RustFS Team
# SPDX-License-Identifier: Apache-2.0
"""Candidate orchestration. GitHub run identity is checked before trusting bytes."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
import urllib.error
import zipfile
import candidate as c
import release_gate
def current_run(workflow):
c.require(os.environ.get("GITHUB_REPOSITORY") == c.REPOSITORY and os.environ.get("GITHUB_REF") == "refs/heads/main",
"release orchestration must run on the upstream main branch")
run = c.validate_run(c.github(f"repos/{c.REPOSITORY}/actions/runs/{int(os.environ['GITHUB_RUN_ID'])}"), workflow, completed=False)
c.require(run["head_sha"] == os.environ["GITHUB_SHA"], "workflow source changed")
return run
def get_run(run_id, workflow):
run = c.github(f"repos/{c.REPOSITORY}/actions/runs/{int(run_id)}")
active = str(run_id) == os.environ.get("GITHUB_RUN_ID")
return c.validate_run(run, workflow, completed=not active)
def manifest_env():
return c.validate_inputs(json.loads(os.environ["CANDIDATE_MANIFEST"]))
def output_inputs(inputs):
c.emit_output("manifest", json.dumps(inputs, separators=(",", ":")))
for field in ("candidate_id", "source_sha", "workflow_sha", "version"):
c.emit_output(field, inputs[field])
def source_ci(source, version):
runs = c.github(f"repos/{c.REPOSITORY}/actions/workflows/ci.yml/runs?head_sha={source}&status=success&per_page=10")["workflow_runs"]
required = {"Quick Checks", "Test and Lint", "End-to-End Tests (full merge gate)"}
for run in runs:
if (run.get("head_sha") != source or run.get("path") != ".github/workflows/ci.yml"
or run.get("head_branch") not in ("main", f"release/{version}")
or run.get("event") not in ("push", "workflow_dispatch")
or run.get("conclusion") != "success" or run.get("status") != "completed"
or (run.get("repository") or {}).get("full_name") != c.REPOSITORY):
continue
jobs = []
for page in range(1, 11):
batch = c.github(f"repos/{c.REPOSITORY}/actions/runs/{run['id']}/attempts/{run['run_attempt']}/jobs?per_page=100&page={page}")["jobs"]
jobs.extend(batch)
if len(batch) < 100:
break
selected = {name: [job for job in jobs if job.get("name") == name] for name in required}
if all(len(found) == 1 and found[0].get("status") == "completed" and found[0].get("conclusion") == "success" for found in selected.values()):
return {"run_id": run["id"], "run_attempt": run["run_attempt"]}
raise ValueError("no complete exact-source CI found; run ci.yml on main or the release branch before creating a candidate")
def unused_version(version):
# Reject a spent version before allocating native builders. A partial
# publication resumes its existing seal instead of making a new candidate.
for tag in (version, "v" + version):
for endpoint in ("git/ref/tags", "releases/tags"):
try:
c.github(f"repos/{c.REPOSITORY}/{endpoint}/{tag}")
except urllib.error.HTTPError as error:
if error.code == 404:
continue
raise
raise ValueError("version already has a tag or release; resume its approved seal or choose a new version")
def prepare(version, source, directory):
run = current_run("release-candidate.yml")
c.require(c.HEX40.fullmatch(source) and c.VERSION.fullmatch(version), "use a full source commit and exact version")
directory.mkdir(parents=True, exist_ok=True)
name = "release-inputs"
exists = any(a["name"] == name for a in c.artifact_list(run["id"]))
if exists:
c.download_artifact(run, name, directory)
inputs = c.validate_inputs(c.read_json(directory / "inputs.json"))
c.require(inputs["source_sha"] == source and inputs["version"] == version and inputs["workflow_sha"] == run["head_sha"], "retry changed candidate inputs")
else:
unused_version(version)
c.command(["git", "fetch", "--no-tags", "origin", source], cwd=c.ROOT)
ci = source_ci(source, version)
c.command(["rustup", "update", "stable", "--no-self-update"])
toolchain = c.command(["rustc", "+stable", "--version"]).split()[1]
inputs = c.freeze(version, source, run["head_sha"], toolchain, c.github("repos/rustfs/console/releases/latest"),
run["id"], int(os.environ["GITHUB_RUN_NUMBER"]), c.github("repos/rustfs/cli/releases/latest"),
c.github("repos/rustfs/rustfs/releases/latest"), ci)
c.write_json(directory / "inputs.json", inputs)
output_inputs(inputs)
c.emit_output("reused", str(exists).lower())
def reuse_platform(target):
inputs = manifest_env()
workflow = os.environ["GITHUB_WORKFLOW_REF"].split("@", 1)[0].rsplit("/", 1)[-1]
run = current_run(workflow)
name = f"candidate-{inputs['candidate_id']}-{target}"
exists = any(a["name"] == name for a in c.artifact_list(run["id"]))
if exists:
with tempfile.TemporaryDirectory() as directory:
c.download_artifact(run, name, directory)
records = [c.read_json(Path(directory) / f"record-{target}.json")]
c.verify_records(inputs, records, directory, (target,))
verify_evidence(inputs, Path(directory), {run["id"]: run})
c.emit_output("reused", str(exists).lower())
def load_linux(run_id, expected, directory):
run = get_run(run_id, "release-candidate.yml")
c.download_artifact(run, "release-linux", directory)
inputs = c.validate_inputs(c.read_json(directory / "inputs.json"))
c.require(inputs["origin_run_id"] == run["id"] and inputs["workflow_sha"] == run["head_sha"], "candidate inputs belong to another run")
c.require(c.HEX64.fullmatch(expected or "") and c.file_digest(directory / "linux-manifest.json") == expected,
"Linux approval digest does not match the downloaded manifest")
verify_seal_files(c.read_json(directory / "linux-manifest.json"), directory)
check_recipe(inputs)
return inputs, run
def check_recipe(inputs):
# Unrelated main changes do not invalidate a frozen candidate. A changed
# producer/verifier/publisher does, even when the product source is unchanged.
c.require(c.recipe_digest(os.environ["GITHUB_SHA"]) == inputs["recipe_sha256"], "release recipe changed; create a new candidate")
def evidence_files(directory):
return sorted(list(directory.glob("identity-*.json")) + list(directory.glob("upgrade-*.json")))
def verify_evidence(inputs, directory, runs):
for path in evidence_files(directory):
value = c.read_json(path)
workflow = value.get("workflow", {})
run = runs.get(int(workflow.get("run_id", 0)))
c.require(run is not None and workflow.get("repository") == c.REPOSITORY
and workflow.get("workflow_id") == run["path"].rsplit("/", 1)[-1]
and workflow.get("workflow_sha") == run["head_sha"]
and type(workflow.get("run_attempt")) is int and 0 < workflow["run_attempt"] <= run["run_attempt"],
"receipt was not produced by the downloaded trusted run")
c.require(value.get("harness_commit") == inputs["workflow_sha"], "receipt harness changed")
for item in value.get("evidence", []):
name = item.get("path", "")
c.require(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,200}", name), "unsafe evidence path")
log = directory / name
c.require(log.is_file() and not log.is_symlink() and log.stat().st_size == item.get("size")
and c.file_digest(log) == item.get("sha256"), "receipt evidence was removed or substituted")
def certify(inputs, directory, runs, scope):
records = [c.read_json(directory / f"record-{target}.json") for target in c.targets("linux" if scope == "linux" else "all")]
candidate = c.certification_candidate(inputs, records, directory, c.targets("linux" if scope == "linux" else "all"))
plan = c.read_json(c.ROOT / ".config/release-validation-plan.json")
c.require(c.digest(plan) == inputs["plan_sha256"], "validation plan changed")
verify_evidence(inputs, directory, runs)
with tempfile.TemporaryDirectory() as temp:
temp = Path(temp)
results = temp / "results"
results.mkdir()
for path in evidence_files(directory):
shutil.copyfile(path, results / path.name)
c.write_json(temp / "candidate.json", candidate)
c.write_json(temp / "plan.json", plan)
status = release_gate.run_validate(temp / "plan.json", temp / "candidate.json", results, temp / "gate", scope)
summary = c.read_json(temp / "gate/release-gate-summary.json")
c.require(status == 0, "certification failed: " + json.dumps(summary["findings"]))
return candidate, summary
def file_records(directory, excluded=()):
result = []
for path in sorted(directory.iterdir()):
if path.name in excluded:
continue
c.require(path.is_file() and not path.is_symlink() and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,200}", path.name), "unexpected staged file")
result.append({"name": path.name, "sha256": c.file_digest(path), "size": path.stat().st_size})
return result
def verify_seal_files(seal, directory):
c.require(isinstance(seal, dict) and seal.get("schema") == 1 and seal.get("stage") in ("linux", "complete"), "invalid release seal")
filename = "linux-manifest.json" if seal["stage"] == "linux" else "release-manifest.json"
c.require(seal.get("files") == file_records(directory, (filename,)), "sealed files changed, are missing, or contain extras")
def aliases(inputs):
return {f"rustfs-{target}-latest.zip": f"rustfs-{target}-v{inputs['version']}.zip" for target in c.TARGETS}
def linux_seal(directory):
run = current_run("release-candidate.yml")
if any(a["name"] == "release-linux" for a in c.artifact_list(run["id"])):
c.download_artifact(run, "release-linux", directory)
inputs = c.validate_inputs(c.read_json(directory / "inputs.json"))
verify_seal_files(c.read_json(directory / "linux-manifest.json"), directory)
certify(inputs, directory, {run["id"]: run}, "linux")
c.emit_output("reused", "true")
c.emit_output("manifest_sha256", c.file_digest(directory / "linux-manifest.json"))
return
c.download_artifact(run, "release-inputs", directory)
inputs = c.validate_inputs(c.read_json(directory / "inputs.json"))
for target in c.LINUX:
c.download_artifact(run, f"candidate-{inputs['candidate_id']}-{target}", directory)
c.download_artifact(run, "release-upgrade", directory)
_, summary = certify(inputs, directory, {run["id"]: run}, "linux")
c.require(not summary["publish_allowed"], "partial certification must not grant publication authority")
c.write_json(directory / "linux-manifest.json", {"schema": 1, "stage": "linux", "inputs_sha256": c.digest(inputs),
"files": file_records(directory), "summary": summary})
c.emit_output("manifest_sha256", c.file_digest(directory / "linux-manifest.json"))
def check_acceptance(url):
c.require(re.fullmatch(r"https://github\.com/rustfs/backlog/issues/[1-9][0-9]*(?:#issuecomment-[1-9][0-9]*)?", url),
"acceptance must link to a RustFS backlog report")
def complete_prepare(run_id, expected, report, directory):
current_run("release-complete.yml")
check_acceptance(report)
inputs, run = load_linux(run_id, expected, directory)
certify(inputs, directory, {run["id"]: run}, "linux")
output_inputs(inputs)
def complete_seal(run_id, expected, report, directory):
current = current_run("release-complete.yml")
if any(a["name"] == "release-seal" for a in c.artifact_list(current["id"])):
c.download_artifact(current, "release-seal", directory)
inputs = c.validate_inputs(c.read_json(directory / "inputs.json"))
seal = c.read_json(directory / "release-manifest.json")
verify_seal_files(seal, directory)
c.require(inputs["origin_run_id"] == run_id and seal["acceptance"] == {"linux_manifest_sha256": expected, "report": report}, "completion retry changed acceptance")
origin = get_run(run_id, "release-candidate.yml")
certify(inputs, directory, {origin["id"]: origin, current["id"]: current}, "full")
verify_deliveries(inputs, directory)
c.emit_output("reused", "true")
c.emit_output("manifest_sha256", c.file_digest(directory / "release-manifest.json"))
return
inputs, origin = load_linux(run_id, expected, directory)
check_acceptance(report)
(directory / "linux-manifest.json").unlink()
for target in c.DESKTOP:
c.download_artifact(current, f"candidate-{inputs['candidate_id']}-{target}", directory)
for target in c.LINUX:
c.download_artifact(current, f"packages-{target}", directory)
for variant in ("musl", "gnu"):
c.download_artifact(current, f"image-{variant}", directory)
c.download_artifact(current, "helm-package", directory)
candidate, summary = certify(inputs, directory, {origin["id"]: origin, current["id"]: current}, "full")
verify_deliveries(inputs, directory)
# Persist the small proof bundle with the public release; Actions retention
# must not be the only way to audit an already published version.
evidence = directory / "release-evidence.zip"
with zipfile.ZipFile(evidence, "x", compression=zipfile.ZIP_DEFLATED) as archive:
for path in sorted(directory.iterdir()):
if path.suffix in (".json", ".log", ".sarif"):
archive.write(path, path.name)
archive.writestr("certification.json", json.dumps(summary, indent=2, sort_keys=True))
archive.writestr("validation-plan.json", (c.ROOT / ".config/release-validation-plan.json").read_bytes())
with tempfile.TemporaryDirectory() as temp:
lock = Path(temp) / "Cargo.lock"
lock.write_bytes(c.git_file(inputs["source_sha"], "Cargo.lock"))
c.require(c.file_digest(lock) == inputs["lock_sha256"], "SBOM dependency lock changed")
subprocess.run(["python3", str(c.ROOT / "scripts/security/generate_release_supply_chain_assets.py"),
"--asset-dir", str(directory), "--version", inputs["version"], "--tag", inputs["version"],
"--build-type", candidate["release_profile"], "--repository", c.REPOSITORY,
"--ref", "refs/heads/main", "--sha", inputs["source_sha"], "--run-id", str(current["id"]),
"--run-attempt", str(current["run_attempt"]), "--cargo-lock", str(lock),
"--workflow", "release-complete.yml"], check=True, timeout=60)
provenance = directory / f"rustfs-{inputs['version']}.provenance.json"
value = c.read_json(provenance)
value["predicate"]["buildDefinition"]["externalParameters"]["candidateInputs"] = inputs
provenance.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n")
assets = {p.name: p for p in directory.iterdir() if p.suffix in (".zip", ".deb", ".rpm", ".tgz") or p.name.endswith((".sbom.cdx.json", ".provenance.json"))}
assets.update({name: directory / source for name, source in aliases(inputs).items()})
for algorithm in ("sha256", "sha512"):
checksums = []
for name, path in sorted(assets.items()):
with path.open("rb") as stream:
checksum = hashlib.file_digest(stream, algorithm).hexdigest()
checksums.append(f"{checksum} {name}")
(directory / f"{algorithm.upper()}SUMS").write_text("\n".join(checksums) + "\n")
c.write_json(directory / "release-manifest.json", {
"schema": 1, "stage": "complete", "inputs_sha256": c.digest(inputs), "candidate": candidate,
"summary": summary, "files": file_records(directory), "complete_run_id": current["id"],
"aliases": aliases(inputs),
"acceptance": {"linux_manifest_sha256": expected, "report": report},
})
c.emit_output("manifest_sha256", c.file_digest(directory / "release-manifest.json"))
def verify_deliveries(inputs, directory):
chart = c.read_json(directory / "chart.json")
c.require(chart.get("version") == inputs["version"] and chart.get("inputs_sha256") == c.digest(inputs)
and chart.get("name") == f"rustfs-{inputs['version']}.tgz", "chart identity changed")
path = directory / chart["name"]
c.require(path.stat().st_size == chart["size"] and c.file_digest(path) == chart["sha256"], "chart bytes changed")
for target in c.LINUX:
binary = c.read_json(directory / f"record-{target}.json")["binary_sha256"]
record = c.read_json(directory / f"packages-{target}.json")
c.require(record.get("inputs_sha256") == c.digest(inputs) and record.get("binary_sha256") == binary, "package wraps a different candidate")
c.require(len(record.get("files", [])) == 2 and {Path(f["name"]).suffix for f in record["files"]} == {".deb", ".rpm"}, "missing package format")
for item in record["files"]:
path = directory / item["name"]
c.require(path.name == item["name"] and path.is_file() and path.stat().st_size == item["size"] and c.file_digest(path) == item["sha256"], "package changed")
for variant in ("musl", "gnu"):
record = c.read_json(directory / f"image-{variant}.json")
c.require(record.get("inputs_sha256") == c.digest(inputs), "image inputs changed")
expected = {arch: c.read_json(directory / f"record-linux-{arch}-{variant}.json")["binary_sha256"] for arch in ("x86_64", "aarch64")}
c.require(record.get("binaries") == expected, "image contains an unverified binary")
path = directory / f"image-{variant}.tar"
c.require(c.file_digest(path) == record.get("sha256") and path.stat().st_size == record.get("size")
and re.fullmatch(r"sha256:[0-9a-f]{64}", record.get("digest", "")), "image archive changed")
c.require(record.get("smoke") == "pass" and record.get("scan") == "completed", "image verification did not finish")
def main():
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="mode", required=True)
p = sub.add_parser("prepare")
p.add_argument("--version", required=True)
p.add_argument("--source", required=True)
p.add_argument("--directory", type=Path, required=True)
p = sub.add_parser("reuse-platform")
p.add_argument("--target", choices=c.TARGETS, required=True)
p = sub.add_parser("linux-seal")
p.add_argument("--directory", type=Path, required=True)
for name in ("complete-prepare", "complete-seal"):
p = sub.add_parser(name)
p.add_argument("--run-id", type=int, required=True)
p.add_argument("--digest", required=True)
p.add_argument("--report", required=True)
p.add_argument("--directory", type=Path, required=True)
args = parser.parse_args()
if args.mode == "prepare":
prepare(args.version, args.source, args.directory)
elif args.mode == "reuse-platform":
reuse_platform(args.target)
elif args.mode == "linux-seal":
linux_seal(args.directory)
elif args.mode == "complete-prepare":
complete_prepare(args.run_id, args.digest, args.report, args.directory)
else:
complete_seal(args.run_id, args.digest, args.report, args.directory)
if __name__ == "__main__":
main()
+280
View File
@@ -0,0 +1,280 @@
#!/usr/bin/env python3
# Copyright 2026 RustFS Team
# SPDX-License-Identifier: Apache-2.0
"""Promote an approved seal. This module never compiles or packages a deliverable."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
import candidate as c
import pipeline
REGISTRIES = ("docker.io/rustfs/rustfs", "ghcr.io/rustfs/rustfs", "quay.io/rustfs/rustfs")
def api(path, method="GET", body=None, missing=False):
args = ["gh", "api", "--method", method, path]
if body is not None:
args += ["--input", "-"]
result = subprocess.run(args, input=json.dumps(body) if body is not None else None, text=True,
capture_output=True, timeout=120)
if result.returncode:
if missing and "(HTTP 404)" in result.stderr:
return None
raise ValueError(f"GitHub {method} failed for {path}: {result.stderr.strip()}")
return json.loads(result.stdout) if result.stdout.strip() else None
def verify(run_id, expected, directory):
current = pipeline.current_run("release-publish.yml")
complete = pipeline.get_run(run_id, "release-complete.yml")
c.download_artifact(complete, "release-seal", directory)
manifest_path = directory / "release-manifest.json"
c.require(c.HEX64.fullmatch(expected or "") and c.file_digest(manifest_path) == expected, "publication approval does not match the sealed manifest")
seal = c.read_json(manifest_path)
pipeline.verify_seal_files(seal, directory)
c.require(seal["stage"] == "complete" and seal.get("complete_run_id") == complete["id"], "seal belongs to another completion run")
inputs = c.validate_inputs(c.read_json(directory / "inputs.json"))
c.require(seal.get("inputs_sha256") == c.digest(inputs), "seal inputs changed")
c.require(seal.get("aliases") == pipeline.aliases(inputs), "release aliases differ from the approved versioned archives")
pipeline.check_recipe(inputs)
origin = pipeline.get_run(inputs["origin_run_id"], "release-candidate.yml")
c.require(origin["head_sha"] == inputs["workflow_sha"], "candidate producer changed")
candidate, summary = pipeline.certify(inputs, directory, {origin["id"]: origin, complete["id"]: complete}, "full")
c.require(candidate == seal.get("candidate") and summary == seal.get("summary") and summary["publish_allowed"], "full certification does not match the seal")
pipeline.verify_deliveries(inputs, directory)
pipeline.check_acceptance(seal["acceptance"]["report"])
c.require(c.HEX64.fullmatch(seal["acceptance"].get("linux_manifest_sha256", "")), "missing Linux acceptance digest")
c.emit_output("version", inputs["version"])
c.emit_output("source_sha", inputs["source_sha"])
return inputs, seal, current
def ensure_tag(inputs, manifest_sha):
version, source = inputs["version"], inputs["source_sha"]
message = f"RustFS {version}\nRelease manifest SHA-256: {manifest_sha}\n"
path = f"repos/{c.REPOSITORY}/git/ref/tags/{version}"
reference = api(path, missing=True)
if reference is None:
tag = api(f"repos/{c.REPOSITORY}/git/tags", "POST", {"tag": version, "message": message, "object": source, "type": "commit"})
api(f"repos/{c.REPOSITORY}/git/refs", "POST", {"ref": f"refs/tags/{version}", "sha": tag["sha"]})
reference = api(path)
c.require(reference["object"]["type"] == "tag", "existing release tag is not an annotated candidate tag")
tag = api(f"repos/{c.REPOSITORY}/git/tags/{reference['object']['sha']}")
c.require(tag.get("tag") == version and tag.get("object", {}).get("sha") == source and tag.get("object", {}).get("type") == "commit"
and tag.get("message", "").rstrip() == message.rstrip(), "public tag already identifies different source or bytes; never move it")
def public_files(directory):
names = {"release-manifest.json", "SHA256SUMS", "SHA512SUMS", "release-evidence.zip"}
return sorted(p for p in directory.iterdir() if p.name in names or p.suffix in (".zip", ".deb", ".rpm", ".tgz") or p.name.endswith((".sbom.cdx.json", ".provenance.json")))
def release(inputs, manifest_sha):
endpoint = f"repos/{c.REPOSITORY}/releases"
value = api(endpoint + "/tags/" + inputs["version"], missing=True)
marker = f"Release manifest SHA-256: {manifest_sha}"
if value is None:
notes = api(endpoint + "/generate-notes", "POST", {"tag_name": inputs["version"], "target_commitish": inputs["source_sha"],
"previous_tag_name": inputs["previous"]["version"]})
value = api(endpoint, "POST", {"tag_name": inputs["version"], "target_commitish": inputs["source_sha"],
"name": f"RustFS {inputs['version']}", "body": marker + "\n\n" + notes["body"],
"draft": True, "prerelease": "-" in inputs["version"], "make_latest": "false"})
c.require(marker in value.get("body", ""), "existing release belongs to another sealed candidate")
return value
def github_assets(inputs, value, paths):
assets = []
for page in range(1, 101):
batch = api(f"repos/{c.REPOSITORY}/releases/{value['id']}/assets?per_page=100&page={page}")
assets.extend(batch)
if len(batch) < 100:
break
known = {a["name"]: a for a in assets}
c.require(len(known) == len(assets) and set(known) <= {p.name for p in paths}, "release has unsealed or duplicate assets")
for path in paths:
old = known.get(path.name)
if old is None:
# No --clobber: a race or interrupted upload must fail closed.
subprocess.run(["gh", "release", "upload", inputs["version"], str(path), "--repo", c.REPOSITORY], check=True, timeout=600)
else:
c.require(old.get("digest") == "sha256:" + c.file_digest(path) and old.get("size") == path.stat().st_size,
"existing release asset differs; never overwrite accepted bytes")
actual = api(f"repos/{c.REPOSITORY}/releases/{value['id']}/assets?per_page=100")
c.require(len(actual) == len(paths), "release asset set incomplete")
for path in paths:
match = [a for a in actual if a["name"] == path.name]
c.require(len(match) == 1 and match[0].get("digest") == "sha256:" + c.file_digest(path)
and match[0].get("size") == path.stat().st_size, "uploaded release asset verification failed")
def aws(*args, capture=True):
return subprocess.run(["aws", "--endpoint-url", os.environ["R2_ENDPOINT"], *args],
text=True, capture_output=capture, check=False, timeout=600)
def r2_file(path, key, *, mutable=False):
bucket = os.environ["R2_BUCKET"]
expected = c.file_digest(path)
head = aws("s3api", "head-object", "--bucket", bucket, "--key", key)
if head.returncode and "(404)" not in head.stderr:
raise ValueError("R2 lookup failed; refusing to treat an unavailable destination as empty")
if head.returncode == 0:
with tempfile.TemporaryDirectory() as temp:
old = Path(temp) / "object"
result = aws("s3", "cp", f"s3://{bucket}/{key}", str(old), "--only-show-errors")
c.require(result.returncode == 0, "could not verify existing R2 object")
if c.file_digest(old) == expected:
return
c.require(mutable, "existing versioned R2 object differs; never overwrite it")
args = ["s3api", "put-object", "--bucket", bucket, "--key", key, "--body", str(path), "--metadata", json.dumps({"sha256": expected})]
if not mutable:
args += ["--if-none-match", "*"]
result = aws(*args)
c.require(result.returncode == 0, "R2 upload failed; retry the same seal after checking destination state")
with tempfile.TemporaryDirectory() as temp:
actual = Path(temp) / "object"
result = aws("s3", "cp", f"s3://{bucket}/{key}", str(actual), "--only-show-errors")
c.require(result.returncode == 0 and c.file_digest(actual) == expected, "R2 read-back digest mismatch")
def image_digest(reference, *, missing=False):
result = subprocess.run(["skopeo", "inspect", "--authfile", os.environ["REGISTRY_AUTH_FILE"], "--raw", f"docker://{reference}"],
capture_output=True, timeout=120)
if result.returncode:
error = result.stderr.decode(errors="replace").lower()
if missing and ("manifest unknown" in error or "manifest_unknown" in error or "name unknown" in error):
return None
raise ValueError(f"cannot verify registry manifest for {reference}")
return "sha256:" + hashlib.sha256(result.stdout).hexdigest()
def copy_image(archive, reference, expected, *, mutable=False):
old = image_digest(reference, missing=True)
if old == expected:
return
c.require(old is None or mutable, "existing image tag identifies different bytes; never replace it")
subprocess.run(["skopeo", "copy", "--authfile", os.environ["REGISTRY_AUTH_FILE"], "--all", "--preserve-digests",
f"oci-archive:{archive}", f"docker://{reference}"], check=True, timeout=1800)
c.require(image_digest(reference) == expected, "registry changed the accepted image digest")
def images(inputs, directory, *, latest=False):
for variant, suffix in (("musl", ""), ("gnu", "-glibc")):
record = c.read_json(directory / f"image-{variant}.json")
for registry in REGISTRIES:
tag = ("latest" if latest else inputs["version"]) + suffix
copy_image(directory / f"image-{variant}.tar", f"{registry}:{tag}", record["digest"], mutable=latest)
def publish_version(inputs, seal, directory, manifest_sha):
ensure_tag(inputs, manifest_sha)
value = release(inputs, manifest_sha)
paths = public_files(directory)
with tempfile.TemporaryDirectory() as temp:
alias_paths = []
for name, source in seal["aliases"].items():
path = Path(temp) / name
shutil.copyfile(directory / source, path)
alias_paths.append(path)
github_assets(inputs, value, paths + alias_paths)
for path in paths:
prefix = "artifacts/rustfs/release/"
if path.name in ("release-manifest.json", "release-evidence.zip", "SHA256SUMS", "SHA512SUMS"):
prefix += inputs["version"] + "/"
r2_file(path, prefix + path.name)
images(inputs, directory)
# Every versioned destination is verified before the draft is made public.
if value["draft"]:
api(f"repos/{c.REPOSITORY}/releases/{value['id']}", "PATCH",
{"draft": False, "prerelease": "-" in inputs["version"], "make_latest": "false"})
def stable_key(version):
c.require(isinstance(version, str) and re.fullmatch(r"v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)", version), "invalid stable channel version")
return tuple(map(int, version.lstrip("v").split(".")))
def helm(inputs, directory):
record = c.read_json(directory / "chart.json")
package = directory / record["name"]
c.require(c.file_digest(package) == record["sha256"], "Helm package changed")
env = dict(os.environ, GH_TOKEN=os.environ["HELM_TOKEN"])
subprocess.run(["gh", "auth", "setup-git", "--hostname", "github.com"], env=env, check=True, timeout=60)
with tempfile.TemporaryDirectory() as temp:
subprocess.run(["git", "clone", "--depth", "1", "--branch", "main", "https://github.com/rustfs/helm.git", temp], env=env, check=True, timeout=120)
path = Path(temp) / package.name
if path.exists():
c.require(c.file_digest(path) == record["sha256"], "published Helm version already has different bytes")
c.require((Path(temp) / "index.yaml").is_file(), "existing Helm chart has no index")
return
shutil.copyfile(package, path)
c.command(["helm", "repo", "index", ".", "--url", "https://charts.rustfs.com"], cwd=temp)
c.command(["git", "config", "user.name", "RustFS Release"], cwd=temp)
c.command(["git", "config", "user.email", "dev@rustfs.com"], cwd=temp)
c.command(["git", "add", package.name, "index.yaml"], cwd=temp)
c.command(["git", "commit", "-m", f"chore: publish chart {inputs['version']}"], cwd=temp)
subprocess.run(["git", "push", "origin", "HEAD:main"], cwd=temp, env=env, check=True, timeout=120)
def channels(inputs, seal, directory, manifest_sha):
# Recheck all immutable destinations, including when resuming a failed job.
publish_version(inputs, seal, directory, manifest_sha)
# The versioned Helm catalog entry must still be completed when a newer
# release already owns the mutable channels.
helm(inputs, directory)
if "-" in inputs["version"]:
return
latest = api(f"repos/{c.REPOSITORY}/releases/latest", missing=True)
if latest and stable_key(latest["tag_name"]) > stable_key(inputs["version"]):
print("A newer stable release owns the channels; keeping its pointers.")
return
value = release(inputs, manifest_sha)
# GitHub Latest also acts as a monotonic marker during partial channel
# failures. Versioned assets and images are already fully available here.
api(f"repos/{c.REPOSITORY}/releases/{value['id']}", "PATCH", {"make_latest": "true"})
images(inputs, directory, latest=True)
for target in c.TARGETS:
path = directory / f"rustfs-{target}-v{inputs['version']}.zip"
r2_file(path, f"artifacts/rustfs/release/rustfs-{target}-latest.zip", mutable=True)
pointer = {"version": inputs["version"], "tag": inputs["version"], "release_date": value.get("published_at"),
"release_type": "stable", "download_url": f"https://github.com/{c.REPOSITORY}/releases/tag/{inputs['version']}"}
with tempfile.TemporaryDirectory() as temp:
path = Path(temp) / "latest.json"
c.write_json(path, pointer)
subprocess.run([os.environ["OSSUTIL_BIN"], "cp", str(path), "oss://rustfs-version/latest.json", "--force"], check=True, timeout=120)
actual = Path(temp) / "actual.json"
subprocess.run([os.environ["OSSUTIL_BIN"], "cp", "oss://rustfs-version/latest.json", str(actual)], check=True, timeout=120)
c.require(c.read_json(actual) == pointer, "installer pointer did not match the published release")
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("mode", choices=("verify", "version", "channels"))
p.add_argument("--run-id", type=int, required=True)
p.add_argument("--digest", required=True)
p.add_argument("--directory", type=Path, required=True)
args = p.parse_args()
inputs, seal, current = verify(args.run_id, args.digest, args.directory)
if args.mode != "verify":
for name in ("R2_ENDPOINT", "R2_BUCKET", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "REGISTRY_AUTH_FILE"):
c.require(bool(os.environ.get(name)), f"missing publication setting: {name}")
if args.mode == "channels":
c.require(os.environ.get("HELM_TOKEN") and os.environ.get("OSSUTIL_BIN"), "missing channel publication settings")
{"version": publish_version, "channels": channels}[args.mode](inputs, seal, args.directory, args.digest)
c.write_json("publication.json", {"schema": 1, "version": inputs["version"], "manifest_sha256": args.digest,
"source_sha": inputs["source_sha"], "complete_run_id": args.run_id,
"publish_run_id": current["id"], "stage": args.mode})
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
+279
View File
@@ -0,0 +1,279 @@
#!/usr/bin/env python3
# Copyright 2026 RustFS Team
# SPDX-License-Identifier: Apache-2.0
"""Exercise release identity, substitution and retry boundaries without publishing."""
from __future__ import annotations
import copy
import io
import os
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
import urllib.request
import zipfile
import candidate
class TransportTests(unittest.TestCase):
def test_signed_download_redirect_does_not_leak_the_github_token(self):
request = urllib.request.Request("https://api.github.com/repos/rustfs/rustfs/actions/artifacts/1/zip", headers={"Authorization": "Bearer test-token"})
redirected = candidate.ArtifactRedirect().redirect_request(request, None, 302, "Found", {}, "https://example.blob.core.windows.net/artifact.zip?signature=fixture")
self.assertIsNone(redirected.get_header("Authorization"))
same_host = candidate.ArtifactRedirect().redirect_request(request, None, 302, "Found", {}, "https://api.github.com/another-path")
self.assertEqual(same_host.get_header("Authorization"), "Bearer test-token")
with self.assertRaises(ValueError):
candidate.ArtifactRedirect().redirect_request(request, None, 302, "Found", {}, "http://example.invalid/artifact.zip")
def test_download_checks_real_bytes_and_rejects_traversal(self):
import hashlib
run = {"id": 70, "head_sha": "a" * 40}
def blob(name):
stream = io.BytesIO()
with zipfile.ZipFile(stream, "w") as package:
package.writestr(name, b"accepted")
return stream.getvalue()
for name, valid in (("record.json", True), ("../escape", False)):
content = blob(name)
artifact = {"id": 90, "name": "candidate", "expired": False, "workflow_run": run,
"size_in_bytes": len(content), "digest": "sha256:" + hashlib.sha256(content).hexdigest()}
with tempfile.TemporaryDirectory() as temp, patch.object(candidate, "artifact_list", return_value=[artifact]), patch.object(candidate, "github_stream", side_effect=lambda _: io.BytesIO(content)):
if valid:
candidate.download_artifact(run, "candidate", temp)
self.assertEqual((Path(temp) / name).read_bytes(), b"accepted")
artifact["digest"] = "sha256:" + "0" * 64
with self.assertRaisesRegex(ValueError, "digest"):
candidate.download_artifact(run, "candidate", Path(temp) / "changed")
else:
with self.assertRaisesRegex(ValueError, "unsafe"):
candidate.download_artifact(run, "candidate", temp)
def test_duplicate_json_fields_are_not_silently_accepted(self):
with tempfile.TemporaryDirectory() as temp:
path = Path(temp) / "manifest.json"
path.write_text('{"schema": 1, "schema": 2}')
with self.assertRaisesRegex(ValueError, "duplicate"):
candidate.read_json(path)
class FreezeRepositoryTests(unittest.TestCase):
def test_freeze_reads_real_commit_and_rejects_recipe_or_version_drift(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
for name in candidate.RECIPE_PATHS:
path = root / name; path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("recipe\n" if name != ".config/release-validation-plan.json" else "{}\n")
for directory in ("bootstrap", "helm"):
(root / directory).mkdir()
(root / directory / "input.txt").write_text("runtime input\n")
(root / "Cargo.toml").write_text('[workspace.package]\nversion = "1.2.3"\n')
(root / "Cargo.lock").write_text("version = 4\n")
env = dict(os.environ, GIT_AUTHOR_NAME="Fixture", GIT_AUTHOR_EMAIL="fixture@example.invalid", GIT_COMMITTER_NAME="Fixture", GIT_COMMITTER_EMAIL="fixture@example.invalid")
def git(*args):
return subprocess.check_output(["git", *args], cwd=root, env=env, stderr=subprocess.DEVNULL, text=True).strip()
git("init", "-q"); git("add", "."); git("commit", "-qm", "fixture")
original = git("rev-parse", "HEAD")
value = inputs()
console = {"draft": False, "prerelease": False, "tag_name": value["console"]["tag"],
"assets": [{"name": value["console"]["name"], "browser_download_url": value["console"]["url"], "digest": "sha256:" + value["console"]["sha256"]}]}
cli = {"draft": False, "prerelease": False, "tag_name": "v0.1.0", "assets": []}
for arch in ("amd64", "arm64"):
for abi in ("", "-gnu"):
name = f"rustfs-cli-linux-{arch}{abi}-v0.1.0.tar.gz"
cli["assets"].append({"name": name, "browser_download_url": f"https://github.com/rustfs/cli/releases/download/v0.1.0/{name}", "digest": "sha256:" + "f" * 64})
previous = {"draft": False, "prerelease": False, "tag_name": "1.2.2", "assets": [{"name": value["previous"]["name"], "browser_download_url": value["previous"]["url"], "digest": "sha256:" + value["previous"]["sha256"]}]}
def freeze(source, workflow, version="1.2.3"):
return candidate.freeze(version, source, workflow, "1.92.0", console, 70, 7, cli, previous, {"run_id": 60, "run_attempt": 1}, root)
frozen = freeze(original, original)
(root / "unrelated.txt").write_text("main may advance\n")
git("add", "."); git("commit", "-qm", "unrelated")
advanced = git("rev-parse", "HEAD")
self.assertEqual(freeze(original, advanced)["recipe_sha256"], frozen["recipe_sha256"])
(root / ".github/workflows/build.yml").write_text("different recipe\n")
git("add", "."); git("commit", "-qm", "recipe change")
with self.assertRaisesRegex(ValueError, "recipe"):
freeze(original, git("rev-parse", "HEAD"))
with self.assertRaisesRegex(ValueError, "version"):
freeze(original, original, "1.2.4")
def inputs():
value = {
"schema": 1, "candidate_id": "1.2.3-preview.7", "version": "1.2.3", "ci": {"run_id": 60, "run_attempt": 1},
"source_sha": "a" * 40, "workflow_sha": "b" * 40, "recipe_sha256": "c" * 64,
"lock_sha256": "d" * 64, "plan_sha256": "e" * 64, "toolchain": "1.92.0",
"console": {"tag": "v0.1.41", "name": "rustfs-console-v0.1.41.zip",
"url": "https://github.com/rustfs/console/releases/download/v0.1.41/rustfs-console-v0.1.41.zip",
"sha256": "f" * 64}, "origin_run_id": 70, "origin_run_number": 7,
}
def asset(repo, version, name):
return {"version": version, "name": name, "url": f"https://github.com/{repo}/releases/download/{version}/{name}", "sha256": "f" * 64}
value["previous"] = asset("rustfs/rustfs", "1.2.2", "rustfs-linux-x86_64-gnu-v1.2.2.zip")
value["cli"] = {target: asset("rustfs/cli", "v0.1.0", "rustfs-cli.tar.gz") for target in candidate.LINUX}
return value
def archive(directory, target, value=None, *, extra=None, binary=b"exact candidate bytes"):
value = value or inputs()
path = Path(directory) / f"rustfs-{target}-v{value['version']}.zip"
members = {"rustfs.exe" if target == "windows-x86_64" else "rustfs": binary}
if target.endswith("-gnu"):
members.update({"rustfs.cpu-symbol-catalog.json": b"{}", "rustfs.cpu-symbol-catalog.sha256": b"catalog"})
if extra:
members.update(extra)
with zipfile.ZipFile(path, "w") as output:
for name, content in members.items():
output.writestr(name, content)
return path
class InputIdentityTests(unittest.TestCase):
def test_valid_frozen_candidate(self):
self.assertEqual(candidate.validate_inputs(inputs()), inputs())
def test_moving_or_injected_inputs_rejected(self):
changes = [("source_sha", "main"), ("version", "1.2.3\nTAG=bad"), ("version", "01.2.3"),
("candidate_id", "1.2.3-preview.8"), ("toolchain", "stable"), ("origin_run_id", True),
("schema", True), ("recipe_sha256", "c" * 63)]
for key, value in changes:
with self.subTest(key=key, value=value):
record = inputs(); record[key] = value
with self.assertRaises(ValueError):
candidate.validate_inputs(record)
def test_console_asset_cannot_redirect_or_be_ambiguous(self):
record = inputs()
record["console"]["url"] = "https://example.invalid/console.zip"
with self.assertRaises(ValueError):
candidate.validate_inputs(record)
asset = {"name": inputs()["console"]["name"], "browser_download_url": inputs()["console"]["url"], "digest": "sha256:" + "f" * 64}
for release in ({"draft": True, "prerelease": False, "tag_name": "v0.1.41", "assets": [asset]},
{"draft": False, "prerelease": False, "tag_name": "v0.1.41", "assets": [asset, asset]}):
with self.assertRaises(ValueError):
candidate.console_asset(release)
def test_frozen_record_is_retryable_but_not_replaceable(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "inputs.json"
candidate.write_json(path, inputs())
before = path.read_bytes()
candidate.write_json(path, inputs())
self.assertEqual(before, path.read_bytes())
changed = inputs(); changed["source_sha"] = "f" * 40
with self.assertRaisesRegex(ValueError, "replace"):
candidate.write_json(path, changed)
self.assertEqual(before, path.read_bytes())
def test_platform_selection_keeps_desktop_out_of_linux(self):
self.assertEqual(candidate.targets("linux"), candidate.LINUX)
self.assertEqual(candidate.targets("desktop"), candidate.DESKTOP)
for value in ("", "linux-x86_64-gnu,linux-x86_64-gnu", "linux,windows", "all,linux"):
with self.subTest(value=value), self.assertRaises(ValueError):
candidate.targets(value)
class ArtifactIdentityTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(); self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.inputs = inputs()
self.records = [candidate.archive_record(archive(self.root, target), target, self.inputs) for target in candidate.TARGETS]
def test_all_six_final_artifacts_bind_exact_bytes(self):
value = candidate.certification_candidate(self.inputs, self.records, self.root)
self.assertEqual(len(value["deliverables"]), 6)
self.assertEqual(value["version"], "1.2.3")
self.assertEqual(value["release_profile"], "stable")
self.assertEqual(value["validation_batch"], {"run_id": "1.2.3-preview.7", "run_attempt": 1})
self.assertNotIn("latest", json.dumps(value))
def test_partial_linux_bundle_cannot_be_published_as_complete(self):
linux = self.records[:4]
self.assertEqual(len(candidate.certification_candidate(self.inputs, linux, self.root, candidate.LINUX)["deliverables"]), 4)
with self.assertRaisesRegex(ValueError, "missing"):
candidate.certification_candidate(self.inputs, linux, self.root)
def test_same_version_different_bytes_rejected(self):
archive(self.root, candidate.TARGETS[0], binary=b"replacement")
with self.assertRaisesRegex(ValueError, "changed"):
candidate.verify_records(self.inputs, self.records, self.root, candidate.TARGETS)
def test_different_inputs_cannot_share_receipts(self):
changed = copy.deepcopy(self.inputs); changed["console"]["sha256"] = "0" * 64
with self.assertRaisesRegex(ValueError, "changed"):
candidate.verify_records(changed, self.records, self.root, candidate.TARGETS)
def test_duplicate_or_mislabeled_platform_rejected(self):
for replacement in (self.records[:-1] + [self.records[0]], self.records + [self.records[0]]):
with self.assertRaises(ValueError):
candidate.verify_records(self.inputs, replacement, self.root, candidate.TARGETS)
wrong = copy.deepcopy(self.records); wrong[0]["name"] = "../replacement.zip"
with self.assertRaises(ValueError):
candidate.verify_records(self.inputs, wrong, self.root, candidate.TARGETS)
def test_archive_members_are_strict(self):
for name in ("../escape", "rustfs-cli", "nested/rustfs"):
with self.subTest(name=name):
path = archive(self.root, candidate.TARGETS[0], extra={name: b"extra"})
with self.assertRaises(ValueError):
candidate.archive_record(path, candidate.TARGETS[0], self.inputs)
def test_binary_symlink_rejected(self):
path = self.root / f"rustfs-{candidate.TARGETS[0]}-v1.2.3.zip"
with zipfile.ZipFile(path, "w") as output:
entry = zipfile.ZipInfo("rustfs"); entry.external_attr = 0o120777 << 16
output.writestr(entry, "../../untrusted")
with self.assertRaises(ValueError):
candidate.archive_record(path, candidate.TARGETS[0], self.inputs)
def test_real_cli_fails_without_overwriting_existing_output(self):
candidate.write_json(self.root / "inputs.json", self.inputs)
for item in self.records:
candidate.write_json(self.root / f"record-{item['target']}.json", item)
output = self.root / "candidate.json"
command = [sys.executable, str(Path(candidate.__file__)), "assemble", "--inputs", str(self.root / "inputs.json"),
"--directory", str(self.root), "--output", str(output)]
good = subprocess.run(command, text=True, capture_output=True)
self.assertEqual(good.returncode, 0, good.stderr)
original = output.read_bytes()
archive(self.root, candidate.TARGETS[1], binary=b"bad retry")
bad = subprocess.run(command, text=True, capture_output=True)
self.assertNotEqual(bad.returncode, 0)
self.assertEqual(output.read_bytes(), original)
class ProvenanceTests(unittest.TestCase):
def run_record(self):
return {"repository": {"full_name": "rustfs/rustfs"}, "path": ".github/workflows/release-candidate.yml",
"event": "workflow_dispatch", "head_branch": "main", "id": 70, "run_attempt": 1,
"head_sha": "b" * 40, "status": "completed", "conclusion": "success"}
def test_trusted_source_and_completion_required(self):
run = self.run_record()
self.assertEqual(candidate.validate_run(run, "release-candidate.yml"), run)
for field, value in (("head_branch", "unreviewed"), ("event", "pull_request"), ("conclusion", "failure"),
("status", "in_progress"), ("repository", {"full_name": "fork/rustfs"}),
("path", ".github/workflows/other.yml")):
with self.subTest(field=field):
changed = {**run, field: value}
with self.assertRaises(ValueError):
candidate.validate_run(changed, "release-candidate.yml")
def test_artifact_must_match_exact_origin(self):
run = self.run_record()
artifact = {"name": "candidate", "expired": False, "id": 1, "size_in_bytes": 100,
"digest": "sha256:" + "c" * 64, "workflow_run": {"id": 70, "head_sha": "b" * 40}}
candidate.validate_artifact(artifact, run, "candidate")
for field, value in (("expired", True), ("name", "other"), ("digest", ""), ("size_in_bytes", True),
("workflow_run", {"id": 71, "head_sha": "b" * 40})):
with self.subTest(field=field), self.assertRaises(ValueError):
candidate.validate_artifact({**artifact, field: value}, run, "candidate")
if __name__ == "__main__":
unittest.main()
+453
View File
@@ -0,0 +1,453 @@
#!/usr/bin/env python3
# Copyright 2026 RustFS Team
# SPDX-License-Identifier: Apache-2.0
"""Offline contract rehearsals; these are not product acceptance receipts."""
from __future__ import annotations
import copy
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
import zipfile
import candidate as c
import pipeline
import publish
import release_gate
import verify
from test_candidate import archive, inputs
ROOT = c.ROOT
def workflow(name):
# Ruby/Psych is available on the developer host and hosted CI image. Tests
# parse the actual workflow rather than a separately maintained job graph.
result = subprocess.check_output(["ruby", "-ryaml", "-rjson", "-e",
"puts JSON.generate(YAML.load_file(ARGV[0]))", str(ROOT / ".github/workflows" / name)], text=True)
value = json.loads(result)
value["on"] = value.pop("true", value.get("on"))
return value
def run_metadata(run_id, file, sha):
return {"id": run_id, "run_attempt": 2, "head_sha": sha, "head_branch": "main", "event": "workflow_dispatch",
"status": "completed", "conclusion": "success", "path": f".github/workflows/{file}",
"repository": {"full_name": c.REPOSITORY}}
def fixture(directory, selection="all"):
value = inputs()
value["plan_sha256"] = c.digest(c.read_json(ROOT / ".config/release-validation-plan.json"))
runs = {70: run_metadata(70, "release-candidate.yml", value["workflow_sha"]),
80: run_metadata(80, "release-complete.yml", "c" * 40)}
for target in c.targets(selection):
record = c.archive_record(archive(directory, target, value, binary=target.encode()), target, value)
c.write_json(directory / f"record-{target}.json", record)
run = runs[70 if target in c.LINUX else 80]
env = {"GITHUB_WORKFLOW_REF": f"{c.REPOSITORY}/{run['path']}@refs/heads/main", "GITHUB_SHA": run["head_sha"],
"GITHUB_RUN_ID": str(run["id"]), "GITHUB_RUN_ATTEMPT": "1"}
with patch.dict(os.environ, env):
for case in ("artifact-identity", "startup"):
log = directory / f"{target}-{case}.log"
log.write_text("contract fixture only\n")
verify.envelope(value, record, "identity", case, [log], verify.now(), directory)
if target == "linux-x86_64-gnu":
for case in verify.UPGRADES:
log = directory / f"upgrade-{case}.log"
log.write_text("contract fixture only\n")
verify.envelope(value, record, "upgrade", case, [log], verify.now(), directory)
return value, runs
class CertificationTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.path = Path(self.temp.name)
self.value, self.runs = fixture(self.path)
def test_complete_original_bytes_certify(self):
_, summary = pipeline.certify(self.value, self.path, self.runs, "full")
self.assertTrue(summary["publish_allowed"])
self.assertEqual(len(summary["required_cases"]), 18)
def test_linux_only_cannot_authorize_release(self):
with tempfile.TemporaryDirectory() as temp:
value, runs = fixture(Path(temp), "linux")
candidate, summary = pipeline.certify(value, Path(temp), runs, "linux")
self.assertEqual(summary["state"], "pass")
self.assertFalse(summary["publish_allowed"])
self.assertEqual(len(summary["required_cases"]), 14)
self.assertEqual(len(candidate["deliverables"]), 4)
with self.assertRaises((ValueError, OSError)):
pipeline.certify(value, Path(temp), runs, "full")
def mutate_envelope(self, edit):
path = self.path / "identity-linux-x86_64-gnu-startup.json"
value = c.read_json(path)
edit(value)
path.write_text(json.dumps(value))
def test_digest_binding_is_required_and_platform_specific(self):
original = (self.path / "identity-linux-x86_64-gnu-startup.json").read_bytes()
arm = c.read_json(self.path / "record-linux-aarch64-gnu.json")
edits = [lambda v: v.pop("artifact"), lambda v: v["artifact"].update(archive_sha256="0" * 64),
lambda v: v["artifact"].update(archive_sha256=arm["sha256"], binary_sha256=arm["binary_sha256"])]
for edit in edits:
with self.subTest(edit=edit):
(self.path / "identity-linux-x86_64-gnu-startup.json").write_bytes(original)
self.mutate_envelope(edit)
with self.assertRaises(ValueError):
pipeline.certify(self.value, self.path, self.runs, "full")
def test_missing_changed_and_duplicate_evidence_are_rejected(self):
path = self.path / "linux-x86_64-gnu-startup.log"
path.write_text("substituted")
with self.assertRaisesRegex(ValueError, "evidence"):
pipeline.certify(self.value, self.path, self.runs, "full")
def test_receipt_cannot_claim_another_run_or_new_attempt(self):
self.mutate_envelope(lambda v: v["workflow"].update(run_attempt=3))
with self.assertRaisesRegex(ValueError, "trusted run"):
pipeline.certify(self.value, self.path, self.runs, "full")
def test_old_successful_unit_survives_failed_job_retry(self):
# The candidate batch is immutable; successful unit receipts from an
# earlier attempt still bind the exact archive, plan and producer.
_, summary = pipeline.certify(self.value, self.path, self.runs, "full")
self.assertEqual(summary["state"], "pass")
self.mutate_envelope(lambda v: v.update(run_id="different-candidate"))
with self.assertRaises(ValueError):
pipeline.certify(self.value, self.path, self.runs, "full")
def test_new_verifier_or_plan_cannot_reuse_old_acceptance(self):
self.value["workflow_sha"] = "d" * 40
with self.assertRaises(ValueError):
pipeline.certify(self.value, self.path, self.runs, "full")
class SourceCITests(unittest.TestCase):
def test_spent_versions_and_unavailable_lookups_fail_before_building(self):
import urllib.error
missing = urllib.error.HTTPError('https://api.github.com/fixture', 404, 'Not Found', {}, None)
with patch.object(c, "github", side_effect=missing) as api:
pipeline.unused_version("1.2.3")
self.assertEqual(api.call_count, 4)
with patch.object(c, "github", return_value={"id": 1}):
with self.assertRaisesRegex(ValueError, "already has a tag or release"):
pipeline.unused_version("1.2.3")
unavailable = urllib.error.HTTPError('https://api.github.com/fixture', 503, 'Unavailable', {}, None)
with patch.object(c, "github", side_effect=unavailable):
with self.assertRaises(urllib.error.HTTPError):
pipeline.unused_version("1.2.3")
def test_only_complete_exact_source_ci_is_accepted(self):
run = run_metadata(60, "ci.yml", "a" * 40)
jobs = [{"name": name, "status": "completed", "conclusion": "success"} for name in
("Quick Checks", "Test and Lint", "End-to-End Tests (full merge gate)")]
def api(path):
return {"jobs": jobs} if "/jobs?" in path else {"workflow_runs": [run]}
with patch.object(c, "github", side_effect=api):
self.assertEqual(pipeline.source_ci("a" * 40, "1.2.3"), {"run_id": 60, "run_attempt": 2})
for key, wrong in (("head_sha", "b" * 40), ("head_branch", "unreviewed"), ("event", "pull_request"), ("conclusion", "failure")):
old = run[key]; run[key] = wrong
with self.subTest(key=key), self.assertRaises(ValueError):
pipeline.source_ci("a" * 40, "1.2.3")
run[key] = old
for conclusion in ("skipped", "failure", None):
jobs[2]["conclusion"] = conclusion
with self.subTest(conclusion=conclusion), self.assertRaises(ValueError):
pipeline.source_ci("a" * 40, "1.2.3")
def test_duplicate_required_ci_job_is_rejected(self):
run = run_metadata(60, "ci.yml", "a" * 40)
jobs = [{"name": name, "status": "completed", "conclusion": "success"} for name in
("Quick Checks", "Quick Checks", "Test and Lint", "End-to-End Tests (full merge gate)")]
with patch.object(c, "github", side_effect=lambda path: {"jobs": jobs} if "/jobs?" in path else {"workflow_runs": [run]}):
with self.assertRaises(ValueError):
pipeline.source_ci("a" * 40, "1.2.3")
class SupplyChainTests(unittest.TestCase):
def test_actual_generator_uses_frozen_lock_and_all_package_formats(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
assets = root / "assets"; assets.mkdir()
for name in ("server.zip", "package.deb", "package.rpm", "chart.tgz"):
(assets / name).write_bytes(name.encode())
lock = root / "Cargo.lock"
lock.write_text('version = 4\n[[package]]\nname = "frozen-fixture"\nversion = "1.2.3"\n')
args = [sys.executable, str(ROOT / "scripts/security/generate_release_supply_chain_assets.py"), "--asset-dir", str(assets),
"--version", "1.2.3", "--cargo-lock", str(lock), "--workflow", "release-complete.yml", "--sha", "a" * 40]
result = subprocess.run(args, capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
provenance = c.read_json(assets / "rustfs-1.2.3.provenance.json")
self.assertEqual({s["name"] for s in provenance["subject"]}, {"server.zip", "package.deb", "package.rpm", "chart.tgz"})
self.assertTrue(provenance["predicate"]["buildDefinition"]["externalParameters"]["workflow"].endswith("/release-complete.yml"))
sbom = c.read_json(assets / "rustfs-1.2.3.sbom.cdx.json")
self.assertIn("frozen-fixture", {item["name"] for item in sbom["components"]})
lock.unlink()
result = subprocess.run(args, capture_output=True, text=True, timeout=10)
self.assertNotEqual(result.returncode, 0)
class NativeEntryTests(unittest.TestCase):
def test_real_native_entry_point_version_startup_and_failure(self):
# A small child process exercises the verifier entry point and lifecycle;
# it deliberately does not pretend to be a RustFS product test.
target = "macos-aarch64"
value = inputs()
version = f"rustfs {value['version']}\nbuild target : {verify.TRIPLES[target]}\ngit commit : {value['source_sha']}\nrust version : {value['toolchain']}\n"
source = f'''#!/usr/bin/env python3
import sys, threading
from http.server import BaseHTTPRequestHandler, HTTPServer
store = {{}}
if '--version' in sys.argv:
print({version!r}, end='')
else:
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header('Content-Type', 'text/html')
self.end_headers()
self.wfile.write(store.get(self.path, b'<html>fixture</html>'))
def do_PUT(self):
store[self.path] = self.rfile.read(int(self.headers.get('Content-Length', '0')))
self.send_response(200); self.end_headers()
def do_DELETE(self):
store.pop(self.path, None)
self.send_response(204); self.end_headers()
api = sys.argv[sys.argv.index('--address')+1].split(':')
server = HTTPServer((api[0], int(api[1])), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
address=sys.argv[sys.argv.index('--console-address')+1]
host, port=address.split(':')
HTTPServer((host,int(port)),Handler).serve_forever()
'''
with tempfile.TemporaryDirectory() as temp:
path = Path(temp)
c.write_json(path / "inputs.json", value)
binary = archive(path, target, value, binary=source.encode())
env = dict(os.environ, GITHUB_WORKFLOW_REF=f"{c.REPOSITORY}/.github/workflows/release-complete.yml@refs/heads/main",
GITHUB_RUN_ID="80", GITHUB_RUN_ATTEMPT="1", GITHUB_SHA="c" * 40)
args = [sys.executable, str(ROOT / "scripts/release/verify.py"), "native", "--inputs", str(path / "inputs.json"),
"--archive", str(binary), "--target", target, "--output", str(path / "receipts")]
result = subprocess.run(args, env=env, capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(len(list((path / "receipts").glob("*.json"))), 2)
archive(path, target, value, binary=source.replace("rustfs 1.2.3", "rustfs 1.2.4").encode())
args[-1] = str(path / "rejected")
result = subprocess.run(args, env=env, capture_output=True, text=True, timeout=30)
self.assertNotEqual(result.returncode, 0)
self.assertFalse(list((path / "rejected").glob("*.json")))
class PromotionTests(unittest.TestCase):
def test_tag_is_created_once_and_conflicting_tag_never_moved(self):
value = inputs()
state = {}
calls = []
def api(path, method="GET", body=None, missing=False):
calls.append((path, method, body))
if path.endswith("/git/tags"):
state["tag"] = dict(body, object={"sha": body["object"], "type": body["type"]})
return {"sha": "f" * 40}
if path.endswith("/git/refs"):
state["ref"] = {"object": {"type": "tag", "sha": body["sha"]}}
return state["ref"]
if "/git/ref/tags/" in path:
return state.get("ref")
return state["tag"]
with patch.object(publish, "api", side_effect=api):
publish.ensure_tag(value, "a" * 64)
count = len([call for call in calls if call[1] == "POST"])
publish.ensure_tag(value, "a" * 64)
self.assertEqual(len([call for call in calls if call[1] == "POST"]), count)
with self.assertRaisesRegex(ValueError, "never move"):
publish.ensure_tag(value, "b" * 64)
state["tag"]["object"]["sha"] = "e" * 40
with self.assertRaises(ValueError):
publish.ensure_tag(value, "a" * 64)
self.assertFalse(any(method in ("PATCH", "DELETE") for _, method, _ in calls))
def test_image_promotion_preserves_digest_and_refuses_overwrite(self):
with patch.dict(os.environ, REGISTRY_AUTH_FILE="auth.json"), patch.object(publish, "image_digest", return_value="sha256:" + "a" * 64), patch.object(subprocess, "run") as command:
publish.copy_image(Path("image.tar"), "registry/image:1.2.3", "sha256:" + "a" * 64)
command.assert_not_called()
with self.assertRaisesRegex(ValueError, "never replace"):
publish.copy_image(Path("image.tar"), "registry/image:1.2.3", "sha256:" + "b" * 64)
command.assert_not_called()
with patch.dict(os.environ, REGISTRY_AUTH_FILE="auth.json"), patch.object(publish, "image_digest", side_effect=[None, "sha256:" + "a" * 64]), patch.object(subprocess, "run") as command:
publish.copy_image(Path("image.tar"), "registry/image:1.2.3", "sha256:" + "a" * 64)
self.assertIn("--preserve-digests", command.call_args.args[0])
self.assertIn("--all", command.call_args.args[0])
def test_newer_channel_cannot_be_downgraded_after_old_job_retry(self):
with patch.object(publish, "publish_version") as versions, patch.object(publish, "api", return_value={"tag_name": "1.2.4"}) as api, patch.object(publish, "images") as images, patch.object(publish, "helm") as helm:
publish.channels(inputs(), {}, Path("candidate"), "a" * 64)
versions.assert_called_once()
images.assert_not_called()
helm.assert_called_once()
self.assertTrue(all(call.args[1:] == () for call in api.call_args_list))
def test_prerelease_never_changes_stable_pointers(self):
value = inputs(); value["version"] = "1.3.0-rc.1"
with patch.object(publish, "publish_version"), patch.object(publish, "api") as api, patch.object(publish, "images") as images, patch.object(publish, "helm") as helm:
publish.channels(value, {}, Path("candidate"), "a" * 64)
api.assert_not_called(); images.assert_not_called(); helm.assert_called_once()
def test_interrupted_version_publish_never_advances_channels(self):
with patch.object(publish, "publish_version", side_effect=ValueError("registry unavailable")), patch.object(publish, "api") as api:
with self.assertRaises(ValueError):
publish.channels(inputs(), {}, Path("candidate"), "a" * 64)
api.assert_not_called()
def test_incomplete_helm_publication_does_not_advance_stable_channels(self):
with patch.object(publish, "publish_version"), patch.object(publish, "helm", side_effect=ValueError("chart repository unavailable")), patch.object(publish, "api") as api, patch.object(publish, "images") as images:
with self.assertRaises(ValueError):
publish.channels(inputs(), {}, Path("candidate"), "a" * 64)
api.assert_not_called()
images.assert_not_called()
def test_r2_retry_checks_bytes_and_uses_conditional_creation(self):
with tempfile.TemporaryDirectory() as temp, patch.dict(os.environ, R2_BUCKET="releases"):
path = Path(temp) / "asset.zip"; path.write_bytes(b"accepted")
objects = {}
calls = []
def aws(*args, **kwargs):
calls.append(args)
if args[:2] == ("s3api", "head-object"):
exists = args[args.index("--key") + 1] in objects
return subprocess.CompletedProcess(args, 0 if exists else 1, "{}", "" if exists else "(404)")
if args[:2] == ("s3api", "put-object"):
self.assertIn("--if-none-match", args)
objects[args[args.index("--key") + 1]] = Path(args[args.index("--body") + 1]).read_bytes()
else:
Path(args[3]).write_bytes(objects[args[2].split("/", 3)[3]])
return subprocess.CompletedProcess(args, 0, "{}", "")
with patch.object(publish, "aws", side_effect=aws):
publish.r2_file(path, "version/asset.zip")
publish.r2_file(path, "version/asset.zip")
self.assertEqual(sum(call[:2] == ("s3api", "put-object") for call in calls), 1)
objects["version/asset.zip"] = b"changed"
with self.assertRaisesRegex(ValueError, "never overwrite"):
publish.r2_file(path, "version/asset.zip")
class WorkflowContractTests(unittest.TestCase):
def test_nightly_avoids_desktop_and_manual_builds_keep_platform_selection(self):
value = workflow("build.yml")
selection = value["jobs"]["prepare-platform-matrix"]["steps"][-1]
self.assertEqual(selection["env"]["RAW_PLATFORMS"], "${{ inputs.platforms || 'linux' }}")
self.assertEqual(value["on"]["workflow_dispatch"]["inputs"]["platforms"]["default"], "all")
def test_main_push_cannot_cancel_the_scheduled_oidc_gate(self):
value = workflow("oidc-keycloak.yml")
self.assertIn("github.event_name", value["concurrency"]["group"])
self.assertIn("!= 'schedule'", value["concurrency"]["cancel-in-progress"])
def test_linux_is_the_only_initial_build(self):
value = workflow("release-candidate.yml")
self.assertEqual(set(value["on"]), {"workflow_dispatch"})
self.assertEqual(value["jobs"]["linux"]["with"]["platforms"], "linux")
self.assertNotIn("desktop", value["jobs"])
self.assertEqual(set(value["jobs"]["seal-linux"]["needs"]), {"freeze", "linux", "upgrade"})
def test_desktop_and_deliveries_wait_for_linux_acceptance(self):
value = workflow("release-complete.yml")
self.assertEqual(value["jobs"]["acceptance"]["environment"], "release-linux-acceptance")
for name in ("desktop", "packages", "images", "chart"):
self.assertEqual(value["jobs"][name]["needs"], "acceptance")
self.assertNotIn("secrets", value["jobs"][name])
self.assertEqual(set(value["jobs"]["seal"]["needs"]), {"acceptance", "desktop", "packages", "images", "chart"})
def test_only_publisher_has_release_writes_and_never_builds(self):
value = workflow("release-publish.yml")
self.assertEqual(set(value["on"]), {"workflow_dispatch"})
self.assertEqual(value["jobs"]["version"]["environment"], "release-publication")
self.assertEqual(value["jobs"]["channels"]["needs"], ["verify", "version"])
self.assertEqual(value["jobs"]["channels"]["concurrency"]["group"], "rustfs-release-channels")
for job in ("version", "channels"):
self.assertFalse(value["jobs"][job]["concurrency"]["cancel-in-progress"])
for name in ("build.yml", "package.yml", "docker.yml", "helm-package.yml"):
parsed = workflow(name)
self.assertEqual(parsed["permissions"]["actions"], "read")
self.assertNotIn("push", parsed["on"])
self.assertNotIn("workflow_run", parsed["on"])
self.assertNotIn("contents: write", (ROOT / ".github/workflows" / name).read_text())
source = (ROOT / "scripts/release/publish.py").read_text()
for forbidden in ("cargo build", "build-push", "helm package", "--clobber", "force=true"):
self.assertNotIn(forbidden, source.replace("# No --clobber:", "# No replacement:"))
def test_stage_failures_do_not_have_a_continue_on_error_bypass(self):
for name in ("release-candidate.yml", "release-complete.yml", "release-publish.yml", "docker.yml", "package.yml", "helm-package.yml"):
value = workflow(name)
for job in value["jobs"].values():
self.assertNotIn("continue-on-error", job)
for step in job.get("steps", []):
self.assertNotIn("continue-on-error", step)
class ImageWorkflowTests(unittest.TestCase):
def test_substituting_a_single_platform_scan_archive_is_rejected(self):
import deliver
with tempfile.TemporaryDirectory() as temp:
output = Path(temp) / "delivery"
scans = Path(str(output) + "-scan")
output.mkdir(); scans.mkdir()
value = inputs()
record = {"inputs_sha256": c.digest(value), "scan_inputs": {}}
for arch in ("amd64", "arm64"):
path = scans / f"image-musl-{arch}.tar"
path.write_bytes(arch.encode())
record["scan_inputs"][arch] = c.file_digest(path)
c.write_json(output / f"scan-musl-{arch}.sarif", {"version": "2.1.0", "runs": [{}]})
c.write_json(output / "image-musl-smoke.json", record)
deliver.image_record(value, "musl", output)
self.assertEqual(c.read_json(output / "image-musl.json")["scan"], "completed")
(scans / "image-musl-arm64.tar").write_bytes(b"another architecture")
with self.assertRaisesRegex(ValueError, "scan input changed"):
deliver.image_record(value, "musl", output)
def test_both_architectures_scan_before_seal_without_registry_credentials(self):
value = workflow("docker.yml")
self.assertEqual(value["permissions"], {"contents": "read", "actions": "read"})
steps = value["jobs"]["build-docker"]["steps"]
build = next(s for s in steps if "docker/build-push-action@" in s.get("uses", ""))
self.assertFalse(build["with"]["push"])
self.assertIn("type=oci", build["with"]["outputs"])
scans = [s for s in steps if "aquasecurity/trivy-action@" in s.get("uses", "")]
self.assertEqual({s["env"]["TRIVY_PLATFORM"] for s in scans}, {"linux/amd64", "linux/arm64"})
for scan in scans:
self.assertEqual(scan["with"]["exit-code"], "0")
self.assertIn("input", scan["with"])
architecture = scan["env"]["TRIVY_PLATFORM"].split("/")[-1]
self.assertTrue(scan["with"]["input"].endswith(f"-{architecture}.tar"))
self.assertLess(steps.index(scan), next(i for i, s in enumerate(steps) if s.get("name") == "Record verified OCI identity"))
class HelmWorkflowTests(unittest.TestCase):
def test_installation_versions_match_candidate_chart_versions(self):
subprocess.run(["bash", "scripts/test_helm_chart_version.sh"], cwd=ROOT,
check=True, capture_output=True, text=True, timeout=15)
def test_chart_is_sealed_before_publish_and_does_not_push(self):
value = workflow("helm-package.yml")
self.assertEqual(set(value["on"]), {"workflow_call"})
source = (ROOT / ".github/workflows/helm-package.yml").read_text()
self.assertIn("scripts/test_helm_templates.sh", source)
self.assertIn("('version', 'appVersion')", source)
self.assertNotIn("git push", source)
self.assertIn("inputs_sha256", source)
if __name__ == "__main__":
unittest.main()
+344
View File
@@ -0,0 +1,344 @@
#!/usr/bin/env python3
# Copyright 2024 RustFS Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Contract tests for scripts/release/release_gate.py (rustfs/backlog#2662).
Every fixture under testdata/release-gate/ is a contract rehearsal only: these
tests prove the gate's fail-closed orchestration and must never be read as
product release evidence or as R02 live verification results.
"""
import json
import re
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
GATE = ROOT / "scripts" / "release" / "release_gate.py"
FIXTURES = ROOT / "testdata" / "release-gate"
PLAN = FIXTURES / "plan.json"
CANDIDATE = FIXTURES / "candidate.json"
ARTIFACT = FIXTURES / "artifacts" / "rustfs-linux-x86_64-gnu-v9.9.9-fixture.zip"
ARTIFACT_NAME = "rustfs-linux-x86_64-gnu-v9.9.9-fixture.zip"
REAL_PLAN = ROOT / ".config" / "release-validation-plan.json"
def run_gate(*arguments):
result = subprocess.run(
[sys.executable, str(GATE), *map(str, arguments)],
cwd=ROOT,
text=True,
capture_output=True,
check=False,
)
return result
def run_validate(results, output, plan=PLAN, candidate=CANDIDATE):
return run_gate(
"validate",
"--plan", plan,
"--candidate", candidate,
"--results", results,
"--output", output,
)
def run_promote(output, plan=PLAN, candidate=CANDIDATE, summary=None, artifact=ARTIFACT,
artifact_name=ARTIFACT_NAME, artifact_sha256=None):
arguments = [
"verify-promotion",
"--plan", plan,
"--candidate", candidate,
"--summary", summary or output,
]
if artifact is not None:
arguments += ["--artifact", artifact]
if artifact_name is not None:
arguments += ["--artifact-name", artifact_name]
if artifact_sha256 is not None:
arguments += ["--artifact-sha256", artifact_sha256]
if output is not None:
arguments += ["--output", output]
return run_gate(*arguments)
def load_summary(output):
return json.loads((Path(output) / "release-gate-summary.json").read_text())
def finding_codes(summary):
return {finding["code"] for finding in summary["findings"]}
class GateFixtureTests(unittest.TestCase):
"""Assertion matrix rows from rustfs/backlog#2662 exercised on the fixtures."""
def setUp(self):
self._temporary = tempfile.TemporaryDirectory(prefix="release-gate-test.")
self.addCleanup(self._temporary.cleanup)
self.output = Path(self._temporary.name)
def validate_fixture(self, name, expect):
output = self.output / name
result = run_validate(FIXTURES / name, output)
self.assertEqual(result.returncode, expect, result.stderr)
return result, load_summary(output)
def test_all_required_pass_and_identity_consistent(self):
# Matrix: all required PASS and identity consistent -> validate and
# verify-promotion return 0.
result, summary = self.validate_fixture("pass", 0)
self.assertEqual(summary["state"], "pass")
self.assertTrue(summary["publish_allowed"])
self.assertEqual(len(summary["required_cases"]), 5)
self.assertEqual(len(summary["observed"]), 5)
self.assertEqual(summary["findings"], [])
expansion = json.loads((self.output / "pass" / "plan-expansion.json").read_text())
self.assertEqual(len(expansion["required_cases"]), 5)
import hashlib
artifact = self.output / ARTIFACT_NAME
artifact.write_bytes(b"release gate contract payload\n")
candidate = json.loads(CANDIDATE.read_text())
old_sha = candidate["deliverables"][0]["sha256"]
new_sha = hashlib.sha256(artifact.read_bytes()).hexdigest()
candidate["deliverables"][0].update(sha256=new_sha, size=artifact.stat().st_size)
candidate_path = self.output / "candidate.json"
candidate_path.write_text(json.dumps(candidate))
results = self.output / "actual-results"
results.mkdir()
for path in (FIXTURES / "pass").glob("*.json"):
(results / path.name).write_text(path.read_text().replace(old_sha, new_sha))
result = run_validate(results, self.output / "actual-certification", candidate=candidate_path)
self.assertEqual(result.returncode, 0, result.stderr)
promote = run_promote(self.output / "actual-certification", candidate=candidate_path, artifact=artifact)
self.assertEqual(promote.returncode, 0, promote.stderr)
def test_cross_platform_same_case_and_split_shas_accepted(self):
# Matrix: the same case name on two platforms, and a workflow SHA that
# differs from the (legally bound) product SHA, must not be rejected.
result, summary = self.validate_fixture("cross-platform", 0)
self.assertEqual(summary["state"], "pass")
product_commit = json.loads(CANDIDATE.read_text())["product_commit"]
workflows = []
for name in sorted((FIXTURES / "cross-platform").glob("*.json")):
if name.name == "batch.json":
continue
envelope = json.loads(name.read_text())
workflows.append(envelope["workflow"]["workflow_sha"])
self.assertNotEqual(envelope["workflow"]["workflow_sha"], product_commit)
self.assertEqual(len(workflows), 5)
case_names = [entry["case_id"] for entry in summary["observed"]]
self.assertEqual(case_names.count("startup"), 2)
self.assertEqual(case_names.count("artifact-identity"), 2)
def test_missing_case_names_the_case(self):
result, summary = self.validate_fixture("missing-case", 1)
self.assertIn("missing_case", finding_codes(summary))
missing = [f for f in summary["findings"] if f["code"] == "missing_case"]
self.assertTrue(any(f.get("case_id") == "startup" for f in missing), missing)
self.assertFalse(summary["publish_allowed"])
def test_duplicate_key_names_the_case(self):
result, summary = self.validate_fixture("duplicate-key", 1)
self.assertIn("duplicate_key", finding_codes(summary))
duplicates = [f for f in summary["findings"] if f["code"] == "duplicate_key"]
self.assertTrue(any(f.get("case_id") == "startup" for f in duplicates), duplicates)
self.assertFalse(summary["publish_allowed"])
def test_extra_unplanned_case_names_the_case(self):
result, summary = self.validate_fixture("extra-case", 1)
self.assertIn("extra_case", finding_codes(summary))
extra = [f for f in summary["findings"] if f["code"] == "extra_case"]
self.assertTrue(any(f.get("case_id") == "surprise-case" for f in extra), extra)
self.assertFalse(summary["publish_allowed"])
def test_non_pass_required_items_block(self):
# Matrix: any skipped / infra_error / quarantined required item blocks
# and no official publish write is allowed.
for name in ("non-pass-skipped", "non-pass-infra-error", "non-pass-quarantined"):
with self.subTest(name=name):
result, summary = self.validate_fixture(name, 1)
self.assertIn("non_pass", finding_codes(summary))
self.assertFalse(summary["publish_allowed"])
def test_child_workflow_cancellation_blocks(self):
result, summary = self.validate_fixture("child-cancelled", 1)
self.assertIn("cancelled", finding_codes(summary))
self.assertFalse(summary["publish_allowed"])
def test_batch_timeout_blocks(self):
result, summary = self.validate_fixture("timeout", 1)
self.assertIn("timeout", finding_codes(summary))
self.assertFalse(summary["publish_allowed"])
def test_same_tag_different_bytes_is_rejected(self):
result, summary = self.validate_fixture("same-tag-different-bytes", 1)
self.assertIn("digest_mismatch", finding_codes(summary))
self.assertFalse(summary["publish_allowed"])
def test_stale_attempt_cannot_be_reused(self):
result, summary = self.validate_fixture("stale-attempt", 1)
self.assertIn("stale_attempt", finding_codes(summary))
self.assertFalse(summary["publish_allowed"])
def test_wrong_platform_is_rejected(self):
result, summary = self.validate_fixture("wrong-platform", 1)
self.assertIn("platform_mismatch", finding_codes(summary))
self.assertFalse(summary["publish_allowed"])
def test_wrong_harness_sha_cannot_be_reused(self):
result, summary = self.validate_fixture("wrong-harness", 1)
self.assertIn("harness_mismatch", finding_codes(summary))
self.assertFalse(summary["publish_allowed"])
def test_zero_tests_block(self):
result, summary = self.validate_fixture("zero-tests", 1)
self.assertIn("zero_tests", finding_codes(summary))
self.assertFalse(summary["publish_allowed"])
def test_untrusted_source_blocks(self):
# A PR-uploaded or foreign JSON set must never gain release authority.
result, summary = self.validate_fixture("untrusted-source", 1)
self.assertIn("untrusted_source", finding_codes(summary))
self.assertFalse(summary["publish_allowed"])
def test_swapped_package_after_verification_fails_promotion(self):
# Matrix: swapping a published package after verification must fail
# verify-promotion.
output = self.output / "certified"
self.assertEqual(run_validate(FIXTURES / "pass", output).returncode, 0)
promote = run_promote(
output,
artifact=FIXTURES / "promotion-swap" / ARTIFACT_NAME,
summary=output,
)
self.assertEqual(promote.returncode, 1, promote.stdout)
self.assertIn("artifact_mismatch", promote.stderr)
report = json.loads((output / "promotion-check.json").read_text())
self.assertEqual(report["state"], "blocked")
self.assertEqual(finding_codes(report), {"artifact_mismatch"})
def test_edited_plan_after_verification_fails_promotion(self):
# Matrix: editing the plan after verification must fail
# verify-promotion; the old certification cannot be reused.
output = self.output / "certified"
self.assertEqual(run_validate(FIXTURES / "pass", output).returncode, 0)
promote = run_promote(
output,
plan=FIXTURES / "promotion-edit" / "plan.json",
summary=output,
)
self.assertEqual(promote.returncode, 1, promote.stdout)
self.assertIn("plan_edited", promote.stderr)
def test_promotion_without_certification_fails(self):
output = self.output / "blocked"
self.assertEqual(run_validate(FIXTURES / "missing-case", output).returncode, 1)
promote = run_promote(output, summary=output)
self.assertEqual(promote.returncode, 1, promote.stdout)
self.assertIn("certification_missing", promote.stderr)
def test_summary_junit_and_expansion_are_written(self):
output = self.output / "pass"
self.assertEqual(run_validate(FIXTURES / "pass", output).returncode, 0)
summary = load_summary(output)
self.assertEqual(summary["plan_sha256"], json.loads(CANDIDATE.read_text())["plan_sha256"])
junit = (output / "release-gate.junit.xml").read_text()
self.assertIn("release-gate", junit)
self.assertIn("identity/linux-x86_64-gnu/startup", junit)
markdown = (output / "release-gate-summary.md").read_text()
self.assertIn("state: pass", markdown)
def test_error_messages_carry_no_credentials(self):
for name in ("missing-case", "stale-attempt", "untrusted-source", "zero-tests"):
output = self.output / name
run_validate(FIXTURES / name, output)
text = output.joinpath("release-gate-summary.json").read_text()
text += (output / "release-gate-summary.md").read_text()
lowered = text.lower()
for marker in ("secret", "password", "access_key", "akia", "bearer "):
self.assertNotIn(marker, lowered, f"{name} leaked a credential-shaped string")
class PlanContractTests(unittest.TestCase):
def test_real_plan_is_explicit_about_sources_and_rollback(self):
sys.path.insert(0, str(GATE.parent))
import release_gate
plan = json.loads(REAL_PLAN.read_text())
info = release_gate.validate_plan(plan)
sources = info["upgrade_sources"]
self.assertGreaterEqual(len(sources["sources"]), 1)
self.assertFalse(sources["rollback_supported"])
self.assertTrue(sources["basis"].strip())
self.assertTrue(sources["rollback_basis"].strip())
for source in sources["sources"]:
self.assertEqual(len(source["artifact_sha256"]), 64)
def test_plan_requires_trusted_workflows_and_frozen_harness(self):
sys.path.insert(0, str(GATE.parent))
import release_gate
plan = json.loads(REAL_PLAN.read_text())
plan["trusted_workflows"] = []
with self.assertRaises(release_gate.GateError):
release_gate.validate_plan(plan)
plan = json.loads(REAL_PLAN.read_text())
plan["lanes"][0]["harness_sha"] = "not-a-sha"
with self.assertRaises(release_gate.GateError):
release_gate.validate_plan(plan)
plan = json.loads(REAL_PLAN.read_text())
plan["upgrade_sources"].pop("rollback_supported")
with self.assertRaises(release_gate.GateError):
release_gate.validate_plan(plan)
def test_plan_sha256_is_semantic_and_detects_required_set_edits(self):
sys.path.insert(0, str(GATE.parent))
import release_gate
plan = json.loads(PLAN.read_text())
baseline = release_gate.canonical_sha256(plan)
self.assertEqual(release_gate.canonical_sha256(json.loads(json.dumps(plan))), baseline)
edited = json.loads(PLAN.read_text())
edited["lanes"][0]["cases"] = ["artifact-identity"]
self.assertNotEqual(release_gate.canonical_sha256(edited), baseline)
class PublicationWiringTests(unittest.TestCase):
"""The publisher re-runs full certification over trusted downloaded receipts."""
def test_single_publisher_revalidates_the_complete_seal(self):
source = (ROOT / "scripts/release/publish.py").read_text()
self.assertIn('pipeline.certify(inputs, directory,', source)
self.assertIn('"full")', source)
self.assertIn('summary["publish_allowed"]', source)
self.assertIn('pipeline.verify_deliveries(inputs, directory)', source)
workflow = (ROOT / ".github/workflows/release-publish.yml").read_text()
self.assertIn('environment: release-publication', workflow)
self.assertIn('needs: [verify, version]', workflow)
def test_builders_do_not_have_publication_permissions(self):
for name in ("build.yml", "docker.yml", "package.yml", "helm-package.yml"):
source = (ROOT / ".github/workflows" / name).read_text()
self.assertNotIn("contents: write", source)
self.assertNotIn("packages: write", source)
if __name__ == "__main__":
unittest.main()
+230
View File
@@ -0,0 +1,230 @@
#!/usr/bin/env python3
# Copyright 2026 RustFS Team
# SPDX-License-Identifier: Apache-2.0
"""Run release checks against the archived executable, never a rebuilt server."""
from __future__ import annotations
import argparse
from datetime import datetime, timezone
import json
import hashlib
import hmac
import os
from pathlib import Path
import re
import socket
import subprocess
import tempfile
import time
import urllib.request
import zipfile
import candidate as c
TRIPLES = {
"linux-x86_64-musl": "x86_64-unknown-linux-musl", "linux-aarch64-musl": "aarch64-unknown-linux-musl",
"linux-x86_64-gnu": "x86_64-unknown-linux-gnu", "linux-aarch64-gnu": "aarch64-unknown-linux-gnu",
"macos-aarch64": "aarch64-apple-darwin", "windows-x86_64": "x86_64-pc-windows-msvc",
}
UPGRADES = {
"direct-upgrade": "direct_upgrade_from_previous_release_preserves_object_contracts",
"mixed-version-upgrade": "rolling_upgrade_from_previous_release_preserves_mixed_version_contracts",
"bucket-config-upgrade": "direct_upgrade_from_previous_release_preserves_bucket_configuration",
"bucket-config-rollback": "rollback_to_previous_release_reads_current_bucket_metadata",
"multipart-layout-upgrade": "direct_upgrade_from_previous_release_preserves_multipart_layouts",
"multipart-layout-baseline": "previous_release_baseline_replicates_multipart_layouts",
}
def now():
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def envelope(inputs, record, lane, case, logs, started, output):
"""Only called after the real check succeeds; logs remain part of the seal."""
target = record["target"].split("-")
platform = dict(zip(("os", "arch", "abi"), target))
platform.setdefault("abi", "msvc" if platform["os"] == "windows" else "none")
workflow = os.environ["GITHUB_WORKFLOW_REF"].split("@", 1)[0].rsplit("/", 1)[-1]
c.require(workflow in ("release-candidate.yml", "release-complete.yml"), "untrusted verifier workflow")
value = {
"schema_version": 1, "evidence_profile": "release-candidate", "tier": "release",
"run_id": inputs["candidate_id"], "run_attempt": 1, "plan_sha256": inputs["plan_sha256"],
"lane": lane, "case_id": case, "product_commit": inputs["source_sha"],
"harness_commit": inputs["workflow_sha"], "target_fingerprint": record["binary_sha256"],
"platform": platform, "started_at": started, "finished_at": now(), "state": "pass",
"reason_code": "CHECK_PASSED", "reason_class": "NOT_APPLICABLE",
"artifact": {"archive_sha256": record["sha256"], "binary_sha256": record["binary_sha256"]},
"evidence": [{"path": p.name, "sha256": c.file_digest(p), "size": p.stat().st_size} for p in logs],
"workflow": {"repository": c.REPOSITORY, "workflow_id": workflow, "run_id": os.environ["GITHUB_RUN_ID"],
"run_attempt": int(os.environ["GITHUB_RUN_ATTEMPT"]), "workflow_sha": os.environ["GITHUB_SHA"],
"status": "completed"},
}
c.write_json(output / f"{lane}-{record['target']}-{case}.json", value)
def unpack(archive, target, inputs, directory):
record = c.archive_record(archive, target, inputs)
name = "rustfs.exe" if target.startswith("windows") else "rustfs"
with zipfile.ZipFile(archive) as package:
package.extract(name, directory)
binary = (Path(directory) / name).resolve()
binary.chmod(0o755)
return binary, record
def port():
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
def s3_request(opener, endpoint, method, path, access, secret, body=b""):
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
date = timestamp[:8]
host = endpoint.removeprefix("http://")
payload = hashlib.sha256(body).hexdigest()
headers = f"host:{host}\nx-amz-content-sha256:{payload}\nx-amz-date:{timestamp}\n"
signed = "host;x-amz-content-sha256;x-amz-date"
canonical = f"{method}\n{path}\n\n{headers}\n{signed}\n{payload}"
scope = f"{date}/us-east-1/s3/aws4_request"
to_sign = f"AWS4-HMAC-SHA256\n{timestamp}\n{scope}\n{hashlib.sha256(canonical.encode()).hexdigest()}"
key = ("AWS4" + secret).encode()
for part in (date, "us-east-1", "s3", "aws4_request"):
key = hmac.digest(key, part.encode(), "sha256")
signature = hmac.new(key, to_sign.encode(), hashlib.sha256).hexdigest()
request = urllib.request.Request(endpoint + path, data=body if method == "PUT" else None, method=method,
headers={"Host": host, "X-Amz-Content-Sha256": payload, "X-Amz-Date": timestamp,
"Authorization": f"AWS4-HMAC-SHA256 Credential={access}/{scope}, SignedHeaders={signed}, Signature={signature}"})
with opener.open(request, timeout=10) as response:
c.require(response.status in (200, 204), "S3 smoke request failed")
return response.read(1024 * 1024)
def native(inputs, archive, target, output):
started = now()
output.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory() as temp:
binary, record = unpack(archive, target, inputs, temp)
version = subprocess.check_output([str(binary), "--version"], text=True, timeout=60)
c.require(version.splitlines()[0] == f"rustfs {inputs['version']}", "binary displays a different product version")
for label, expected in (("build target", TRIPLES[target]), ("git commit", inputs["source_sha"])):
c.require(re.search(rf"(?m)^{label}\s*:\s*{re.escape(expected)}\s*$", version), f"binary {label} mismatch")
c.require(re.search(rf"(?m)^rust version\s*:\s*(?:rustc )?{re.escape(inputs['toolchain'])}(?:\s|$)", version), "binary toolchain mismatch")
log = output / f"identity-{target}.log"
log.write_text(version, encoding="utf-8")
envelope(inputs, record, "identity", "artifact-identity", [log], started, output)
data = Path(temp) / "data"
data.mkdir()
api, console = port(), port()
while console == api:
console = port()
log = output / f"startup-{target}.log"
access, secret = "release-" + os.urandom(8).hex(), os.urandom(24).hex()
env = dict(os.environ, NO_PROXY="127.0.0.1,localhost,::1", no_proxy="127.0.0.1,localhost,::1")
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
with log.open("w", encoding="utf-8") as stream:
process = subprocess.Popen([str(binary), "server", "--address", f"127.0.0.1:{api}", "--console-enable",
"--console-address", f"127.0.0.1:{console}", "--access-key", access,
"--secret-key", secret, str(data)],
stdout=stream, stderr=subprocess.STDOUT, env=env)
try:
deadline = time.monotonic() + 60
while time.monotonic() < deadline:
c.require(process.poll() is None, "candidate exited before becoming ready")
try:
with opener.open(f"http://127.0.0.1:{api}/health/ready", timeout=2) as response:
c.require(response.status == 200, "S3 listener is not ready")
with opener.open(f"http://127.0.0.1:{console}/rustfs/console/", timeout=2) as response:
c.require(response.status == 200 and "text/html" in response.headers.get("Content-Type", ""), "Console did not return HTML")
break
except OSError:
time.sleep(0.5)
else:
raise ValueError("candidate did not become ready within 60 seconds")
bucket = "/release-" + os.urandom(8).hex()
endpoint = f"http://127.0.0.1:{api}"
content = os.urandom(1024)
s3_request(opener, endpoint, "PUT", bucket, access, secret)
s3_request(opener, endpoint, "PUT", bucket + "/roundtrip", access, secret, content)
c.require(s3_request(opener, endpoint, "GET", bucket + "/roundtrip", access, secret) == content, "S3 round-trip bytes differ")
s3_request(opener, endpoint, "DELETE", bucket + "/roundtrip", access, secret)
s3_request(opener, endpoint, "DELETE", bucket, access, secret)
c.require(process.poll() is None, "candidate exited during startup verification")
finally:
process.terminate()
try:
process.wait(timeout=15)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=15)
c.require(c.file_digest(binary) == record["binary_sha256"], "server bytes changed during native verification")
with log.open("a", encoding="utf-8") as stream:
stream.write("\nConsole: HTTP 200 text/html; S3: ready, bucket/object CRUD and byte-identical round trip\n")
envelope(inputs, record, "identity", "startup", [log], started, output)
def upgrade(inputs, archive, output):
output.mkdir(parents=True, exist_ok=True)
plan = c.read_json(c.ROOT / ".config/release-validation-plan.json")
c.require(c.digest(plan) == inputs["plan_sha256"], "upgrade plan changed")
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
binary, record = unpack(archive, "linux-x86_64-gnu", inputs, root)
metadata = binary.stat()
receipt = root / "receipt.json"
receipt.write_text(json.dumps({"schema": 1, "workspace": str(c.ROOT.resolve()), "binary": str(binary),
"size": metadata.st_size, "modified_ns": metadata.st_mtime_ns, "features": ["pyroscope"]}))
env = dict(os.environ, CARGO_BIN_EXE_rustfs=str(binary), RUSTFS_E2E_BINARY_RECEIPT=str(receipt),
RUSTFS_BUILD_FEATURES="pyroscope", NO_PROXY="127.0.0.1,localhost,::1", no_proxy="127.0.0.1,localhost,::1",
HTTP_PROXY="", HTTPS_PROXY="", ALL_PROXY="", http_proxy="", https_proxy="", all_proxy="")
logs = {case: [] for case in UPGRADES}
started = now()
sources = list(plan["upgrade_sources"]["sources"])
previous = inputs["previous"]
if previous["version"] not in {s["version"] for s in sources}:
sources.append({"version": previous["version"], "artifact_name": previous["name"], "artifact_sha256": previous["sha256"]})
for source in sources:
directory = root / source["version"]
directory.mkdir()
package = directory / source["artifact_name"]
url = f"https://github.com/{c.REPOSITORY}/releases/download/{source['version']}/{source['artifact_name']}"
subprocess.run(["curl", "--fail", "--location", "--retry", "3", "--output", str(package), url], check=True, timeout=600)
c.require(c.file_digest(package) == source["artifact_sha256"], "previous release digest mismatch")
with zipfile.ZipFile(package) as previous:
c.require(previous.namelist().count("rustfs") == 1, "ambiguous previous server")
previous.extract("rustfs", directory)
(directory / "rustfs").chmod(0o755)
env["RUSTFS_UPGRADE_SOURCE_BINARY"] = str(directory / "rustfs")
for case, test in UPGRADES.items():
log = output / f"upgrade-{source['version']}-{case}.log"
env["RUSTFS_E2E_LOG_DIR"] = str(root / f"logs-{source['version']}-{case}")
with log.open("w", encoding="utf-8") as stream:
subprocess.run(["cargo", "test", "--locked", "-p", "e2e_test", f"upgrade_compatibility_test::{test}",
"--", "--ignored", "--exact", "--nocapture"], cwd=c.ROOT, env=env,
stdout=stream, stderr=subprocess.STDOUT, check=True, timeout=1800)
c.require(re.search(r"test result: ok\. 1 passed; 0 failed;", log.read_text()), "upgrade command did not run exactly one passing test")
c.require(c.file_digest(binary) == record["binary_sha256"], "candidate changed during upgrade tests")
logs[case].append(log)
for case, files in logs.items():
envelope(inputs, record, "upgrade", case, files, started, output)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("mode", choices=("native", "upgrade"))
parser.add_argument("--inputs", required=True)
parser.add_argument("--archive", required=True)
parser.add_argument("--target", choices=c.TARGETS)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
inputs = c.validate_inputs(c.read_json(args.inputs))
if args.mode == "native":
c.require(args.target is not None, "native verification needs a platform")
native(inputs, args.archive, args.target, args.output)
else:
upgrade(inputs, args.archive, args.output)
if __name__ == "__main__":
main()
@@ -1,27 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
workflow=".github/workflows/docker.yml"
require_literal() {
local needle="$1"
local description="$2"
if ! grep -Fq "$needle" "$workflow"; then
echo "missing container scan workflow contract: $description" >&2
exit 1
fi
}
require_literal "scan-docker-image:" "scan job"
require_literal "needs: [ build-check, build-docker ]" "build dependency"
require_literal "needs.build-check.outputs.should_build == 'true' && needs.build-check.outputs.should_push == 'true'" "release image push guard"
require_literal "docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9" "pinned GHCR login action"
require_literal "aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25" "pinned Trivy action"
require_literal 'image-ref: ${{ env.REGISTRY_GHCR }}:${{ needs.build-check.outputs.version }}${{ matrix.suffix }}' "GHCR image reference"
require_literal "format: sarif" "SARIF report format"
require_literal 'exit-code: "0"' "report-only failure policy"
require_literal "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" "pinned report upload action"
require_literal "container-image-scan-" "scan report artifact name"
echo "Container scan workflow contract ok."
cd "$(dirname "${BASH_SOURCE[0]}")/../.."
python3 scripts/release/test_pipeline.py ImageWorkflowTests
@@ -1,420 +1,4 @@
#!/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"
package_workflow=".github/workflows/package.yml"
release_script="scripts/release/create_or_update_release.sh"
# shellcheck source=scripts/release/create_or_update_release.sh
source "$release_script"
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
}
require_absent() {
local file="$1"
local text="$2"
local description="$3"
if grep -Fq -- "$text" "$file"; then
echo "invalid preview release workflow contract: $description" >&2
exit 1
fi
}
require_line "$build_workflow" " cargo zigbuild --release --locked --target \"\$TARGET_ARG\" \"\${FEATURE_ARGS[@]}\" \"\${PROFILE_ARGS[@]}\" -p rustfs --bin rustfs" "cross builds must keep matrix features"
require_line "$build_workflow" " cargo build --release --locked --target \"\$TARGET_ARG\" \"\${FEATURE_ARGS[@]}\" \"\${PROFILE_ARGS[@]}\" -p rustfs --bin rustfs" "native builds must keep matrix features"
require_absent "$build_workflow" " cargo zigbuild --release --locked --target \"\$TARGET_ARG\" -p rustfs --bin rustfs" "cross builds must not drop matrix features"
require_absent "$build_workflow" " cargo build --release --locked --target \"\$TARGET_ARG\" -p rustfs --bin rustfs" "native builds must not drop matrix features"
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: needs.build-check.outputs.should_publish == 'true' && 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"
require_line "$package_workflow" " if [[ \"\$TAG\" =~ ^\${semver_core}(-\${prerelease_id})?-preview\\.(0|[1-9][0-9]*)$ ]]; then" "stable and prerelease preview package classification"
require_line "$package_workflow" " if: needs.resolve.outputs.build_type != 'preview' && env.R2_ACCESS_KEY_ID != ''" "preview packages must not publish to R2"
release_guard="needs.build-check.outputs.should_publish == 'true' && 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="needs.build-check.outputs.should_publish == 'true' && 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"
# Preview releases are internal validation artifacts: once the deliverable
# release is published they are deleted, while their tags stay behind.
require_job_if "$build_workflow" "cleanup-preview-releases" " if: $latest_guard"
require_line "$build_workflow" " gh release delete \"\$preview_tag\" --repo \"\${GITHUB_REPOSITORY}\" --yes" "preview release cleanup after publication"
require_line "$build_workflow" " | select(.tag_name | startswith(\$tag + \"-preview.\"))" "cleanup must match the target's own preview tags"
require_line "$build_workflow" " | select(.tag_name | ltrimstr(\$tag + \"-preview.\") | test(\"^[0-9]+\$\"))" "cleanup must match a numeric preview iteration"
require_absent "$build_workflow" "--cleanup-tag" "preview tags must survive their release cleanup"
require_line "$build_workflow" " TARGET_COMMITISH=\$(git rev-parse --verify \"refs/tags/\${TAG}^{commit}\")" "release target commit resolution"
require_line "$build_workflow" " ./scripts/release/create_or_update_release.sh \\" "managed release creation"
require_absent "$build_workflow" "git tag -l --format='%(contents)'" "annotated tag messages must not become release notes"
require_absent "$build_workflow" "--generate-notes" "GitHub must not choose the release notes baseline implicitly"
require_line "$release_script" " gh api --method POST \"repos/\${GITHUB_REPOSITORY}/releases/generate-notes\" \\" "Generate Release Notes API"
require_line "$release_script" " local create_args=(release create \"\$tag\" --title \"\$title\" --notes-file \"\$notes_file\" --latest=false --draft)" "draft release creation with a notes file"
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
publish_release_block=$(awk '
$0 == " publish-release:" { in_job = 1 }
in_job { print }
in_job && /^ [A-Za-z0-9_-]+:$/ && $0 != " publish-release:" { exit }
' "$build_workflow")
if [[ "$publish_release_block" != *" - name: Publish release"* ]]; then
echo "missing preview release workflow contract: publish job must only publish the prepared release" >&2
exit 1
fi
if grep -Eq -- '--notes|generate-notes|create_or_update_release' <<<"$publish_release_block"; then
echo "invalid preview release workflow contract: publish job must not replace release notes" >&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')
EOF
expected_docker_automatic_guard=${expected_docker_automatic_guard%$'\n'}
require_job_if "$docker_workflow" "build-check" "$expected_docker_automatic_guard"
require_line "$docker_workflow" ' source_ref: ${{ steps.check.outputs.source_ref }}' "Docker source ref output"
require_line "$docker_workflow" ' source_ref="$HEAD_SHA"' "automatic Docker source ref"
require_line "$docker_workflow" ' source_ref="$tag_ref"' "manual Docker source ref"
require_line "$docker_workflow" ' ref: ${{ needs.build-check.outputs.source_ref }}' "Docker release source checkout"
require_line "$docker_workflow" ' SOURCE_REVISION="$(git rev-parse HEAD)"' "Docker source revision resolution"
require_line "$docker_workflow" ' LABELS="$LABELS,org.opencontainers.image.revision=$SOURCE_REVISION"' "Docker revision label"
require_absent "$docker_workflow" 'org.opencontainers.image.revision=${{ github.sha }}' "Docker revision must not use the workflow branch SHA"
python3 scripts/test_docker_workflow.py
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"
require_line "$helm_workflow" " ref: \${{ github.event.workflow_run.head_sha || (startsWith(inputs.version, 'refs/tags/') && inputs.version || format('refs/tags/{0}', inputs.version)) }}" "Helm release source checkout"
python3 scripts/test_helm_release_workflow.py
assert_equal() {
local expected="$1"
local actual="$2"
local description="$3"
if [[ "$actual" != "$expected" ]]; then
echo "invalid preview release workflow contract: $description (expected '$expected', got '$actual')" >&2
exit 1
fi
}
IFS= read -r -d '' release_fixture <<'EOF' || true
[
[
{"tag_name":"1.0.0-beta.12-preview.1","created_at":"2026-07-30T02:30:00Z","published_at":"2026-07-30T02:36:43Z","draft":false},
{"tag_name":"1.0.0-beta.12","created_at":"2026-07-30T04:00:00Z","published_at":"2026-07-30T04:13:04Z","draft":false},
{"tag_name":"1.0.0-beta.12-preview.0","created_at":"2026-07-29T10:00:00Z","published_at":"2026-07-29T10:05:00Z","draft":false}
],
[
{"tag_name":"1.0.0-beta.10","created_at":"2026-07-17T05:50:00Z","published_at":"2026-07-17T05:58:19Z","draft":false},
{"tag_name":"1.0.0-beta.11","created_at":"2026-07-23T04:00:00Z","published_at":"2026-07-23T04:07:31Z","draft":false},
{"tag_name":"9.0.0","created_at":"2025-01-01T00:00:00Z","published_at":"2025-01-01T00:00:00Z","draft":false},
{"tag_name":"1.0.0-beta.13","created_at":"2026-08-01T00:00:00Z","published_at":null,"draft":true}
]
]
EOF
preview_previous=$(select_previous_release_tag "1.0.0-beta.12-preview.1" "2026-07-30T02:36:43Z" <<<"$release_fixture")
assert_equal "1.0.0-beta.11" "$preview_previous" "preview notes baseline must exclude preview releases and its final deliverable"
final_previous=$(select_previous_release_tag "1.0.0-beta.12" "2026-07-30T04:13:04Z" <<<"$release_fixture")
assert_equal "1.0.0-beta.11" "$final_previous" "final notes baseline must exclude the same-commit preview release"
no_previous=$(select_previous_release_tag "1.0.0-beta.1-preview.1" "2026-01-01T00:00:00Z" <<'EOF'
[[{"tag_name":"1.0.0-beta.1-preview.1","created_at":"2026-01-01T00:00:00Z","published_at":"2026-01-01T00:00:00Z","draft":false}]]
EOF
)
assert_equal "" "$no_previous" "repositories without a previous deliverable must use GitHub's fallback baseline"
delayed_draft_previous=$(select_previous_release_tag "1.0.0-beta.12" "2026-07-30T04:00:00Z" <<'EOF'
[[
{"tag_name":"1.0.0-beta.12","created_at":"2026-07-30T04:00:00Z","published_at":null,"draft":true},
{"tag_name":"1.0.0-beta.13","created_at":"2026-08-01T00:00:00Z","published_at":"2026-08-01T00:05:00Z","draft":false},
{"tag_name":"1.0.0-beta.11","created_at":"2026-07-23T04:00:00Z","published_at":"2026-07-23T04:07:31Z","draft":false}
]]
EOF
)
assert_equal "1.0.0-beta.11" "$delayed_draft_previous" "draft rerun must exclude deliverables published after the draft was created"
release_test_dir=$(mktemp -d "${TMPDIR:-/tmp}/rustfs-release-workflow-test.XXXXXX")
trap 'rm -rf -- "$release_test_dir"' EXIT
request_json="${release_test_dir}/request.json"
write_generate_notes_request "1.0.0-beta.12" "0123456789abcdef" "1.0.0-beta.11" "$request_json"
assert_equal "1.0.0-beta.11" "$(jq -r .previous_tag_name "$request_json")" "explicit previous_tag_name request field"
write_generate_notes_request "1.0.0-beta.1" "0123456789abcdef" "" "$request_json"
assert_equal "false" "$(jq 'has("previous_tag_name")' "$request_json")" "fallback request must omit previous_tag_name"
existing_release_json="${release_test_dir}/existing-release.json"
jq -n --arg body "Pre-release 1.0.0-beta.12 (beta)" '{body: $body}' > "$existing_release_json"
assert_equal "update" "$(release_notes_action "$existing_release_json" "1.0.0-beta.12")" "rerun must repair legacy prerelease placeholder notes"
jq -n --arg body "${MANAGED_NOTES_MARKER}
## What's Changed" '{body: $body}' > "$existing_release_json"
assert_equal "update" "$(release_notes_action "$existing_release_json" "1.0.0-beta.12")" "rerun must refresh managed notes"
generated_body="## What's Changed
* Fix release notes by @alice in #123
* First contribution by @bob in #124
## New Contributors
* @bob made their first contribution in #124
**Full Changelog**: https://github.com/rustfs/rustfs/compare/1.0.0-beta.11...1.0.0-beta.12"
response_json="${release_test_dir}/response.json"
notes_file="${release_test_dir}/notes.md"
jq -n --arg body "$generated_body" '{body: $body}' > "$response_json"
write_generated_release_notes "$response_json" "$notes_file" "rustfs/rustfs" "1.0.0-beta.12" "1.0.0-beta.11"
expected_notes="${MANAGED_NOTES_MARKER}
${generated_body}"
assert_equal "$expected_notes" "$(<"$notes_file")" "generated multiline Markdown must remain intact"
mock_log="${release_test_dir}/gh.log"
mock_notes="${release_test_dir}/captured-notes.md"
mock_generate_calls="${release_test_dir}/generate-calls"
MOCK_RELEASES_JSON="$release_fixture"
MOCK_GENERATED_BODY="$generated_body"
MOCK_RELEASE_EXISTS=true
MOCK_RELEASE_BODY="Release 1.0.0-beta.12"
MOCK_RELEASE_VIEW_ERROR=""
gh() {
local input_file=""
local notes_path=""
if [[ "$1" == "api" && "$*" == *"--paginate --slurp"* ]]; then
printf '%s\n' "$MOCK_RELEASES_JSON"
return
fi
if [[ "$1" == "api" && "$*" == *"releases/generate-notes"* ]]; then
while [[ "$#" -gt 0 ]]; do
if [[ "$1" == "--input" ]]; then
input_file="$2"
break
fi
shift
done
jq -e '.tag_name == "1.0.0-beta.12" and .target_commitish == "0123456789abcdef" and .previous_tag_name == "1.0.0-beta.11"' "$input_file" >/dev/null
printf '%s\n' called >> "$mock_generate_calls"
jq -n --arg body "$MOCK_GENERATED_BODY" '{body: $body}'
return
fi
if [[ "$1" == "release" && "$2" == "view" ]]; then
if [[ -n "$MOCK_RELEASE_VIEW_ERROR" ]]; then
printf '%s\n' "$MOCK_RELEASE_VIEW_ERROR" >&2
return 1
fi
if [[ "$MOCK_RELEASE_EXISTS" != "true" ]]; then
echo "release not found" >&2
return 1
fi
jq -n --arg body "$MOCK_RELEASE_BODY" '{databaseId: 123, url: "https://github.com/rustfs/rustfs/releases/tag/test", body: $body, createdAt: "2026-07-30T04:00:00Z", publishedAt: "2026-07-30T04:13:04Z"}'
return
fi
if [[ "$1" == "release" && ("$2" == "edit" || "$2" == "create") ]]; then
local action="$2"
shift 2
while [[ "$#" -gt 0 ]]; do
if [[ "$1" == "--notes-file" ]]; then
notes_path="$2"
break
fi
shift
done
cp "$notes_path" "$mock_notes"
MOCK_RELEASE_BODY=$(<"$mock_notes")
MOCK_RELEASE_EXISTS=true
printf '%s\n' "$action" >> "$mock_log"
return
fi
echo "unexpected mock gh invocation: $*" >&2
return 1
}
GITHUB_REPOSITORY="rustfs/rustfs"
GITHUB_OUTPUT="${release_test_dir}/github-output"
RUNNER_TEMP="$release_test_dir"
create_or_update_release "1.0.0-beta.12" "0123456789abcdef" "RustFS 1.0.0-beta.12 (beta)" true
assert_equal "edit" "$(<"$mock_log")" "existing placeholder release must be updated on rerun"
assert_equal "$expected_notes" "$(<"$mock_notes")" "rerun must update notes through --notes-file"
: > "$mock_log"
MOCK_RELEASE_EXISTS=false
MOCK_RELEASE_BODY=""
create_or_update_release "1.0.0-beta.12" "0123456789abcdef" "RustFS 1.0.0-beta.12 (beta)" true
assert_equal "create" "$(<"$mock_log")" "first run must create the draft release"
: > "$mock_log"
: > "$mock_generate_calls"
MOCK_RELEASE_EXISTS=true
MOCK_RELEASE_BODY="$generated_body"
create_or_update_release "1.0.0-beta.12" "0123456789abcdef" "RustFS 1.0.0-beta.12 (beta)" true
assert_equal "" "$(<"$mock_log")" "manual notes must not trigger a release edit"
assert_equal "" "$(<"$mock_generate_calls")" "manual notes must be preserved without regeneration"
MOCK_RELEASE_BODY="Manually curated release guidance"
if create_or_update_release "1.0.0-beta.12" "0123456789abcdef" "RustFS 1.0.0-beta.12 (beta)" true; then
echo "invalid preview release workflow contract: incomplete manual notes must block publication" >&2
exit 1
fi
assert_equal "" "$(<"$mock_log")" "incomplete manual notes must not be overwritten"
MOCK_RELEASE_VIEW_ERROR="gh: service unavailable (HTTP 503)"
if create_or_update_release "1.0.0-beta.12" "0123456789abcdef" "RustFS 1.0.0-beta.12 (beta)" true; then
echo "invalid preview release workflow contract: transient release lookup failures must fail closed" >&2
exit 1
fi
assert_equal "" "$(<"$mock_log")" "release lookup failures must not attempt create or edit"
echo "Preview release workflow contract ok."
cd "$(dirname "${BASH_SOURCE[0]}")/../.."
python3 scripts/release/test_pipeline.py WorkflowContractTests
@@ -25,6 +25,7 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--run-id", default=os.getenv("GITHUB_RUN_ID", ""))
parser.add_argument("--run-attempt", default=os.getenv("GITHUB_RUN_ATTEMPT", ""))
parser.add_argument("--cargo-lock", default="Cargo.lock", help="Cargo.lock path.")
parser.add_argument("--workflow", default="build.yml", help="Workflow that assembled these assets.")
parser.add_argument("--metadata-file", help="Use an existing cargo metadata JSON file.")
return parser.parse_args()
@@ -118,7 +119,7 @@ def build_sbom(packages: list[dict], args: argparse.Namespace, timestamp: str) -
def build_provenance(asset_dir: Path, args: argparse.Namespace, timestamp: str) -> dict:
subjects = []
for asset in sorted(asset_dir.glob("*.zip")):
for asset in sorted(path for path in asset_dir.iterdir() if path.suffix in (".zip", ".deb", ".rpm", ".tgz")):
subjects.append(
{
"name": asset.name,
@@ -129,7 +130,7 @@ def build_provenance(asset_dir: Path, args: argparse.Namespace, timestamp: str)
if not subjects:
raise RuntimeError(f"no .zip release assets found in {asset_dir}")
workflow_ref = f"{args.repository}/.github/workflows/build.yml"
workflow_ref = f"{args.repository}/.github/workflows/{args.workflow}"
invocation = args.run_id
if args.run_attempt:
invocation = f"{invocation}/attempts/{args.run_attempt}" if invocation else args.run_attempt
+102
View File
@@ -152,6 +152,108 @@ class PlannerTests(unittest.TestCase):
self.assertEqual(code['mode'], 'full')
self.assertIn('leaf', code['proposed']['packages'])
def release_metadata_base(self):
self.repo.write(gate.POLICY_PATH, json.dumps(dict(self.repo.policy, rollout='enforced')))
files = {
'flake.nix': '{ pkgs }: {\n rustfs = pkgs.buildRustPackage {\n pname = "rustfs";\n version = "1.0.4";\n src = ./.;\n };\n clientVersion = "0.1.36";\n}\n',
'helm/rustfs/Chart.yaml': 'apiVersion: v2\nname: rustfs\nversion: "1.0.4"\nappVersion: "1.0.4"\n',
'rustfs.spec': 'Name: rustfs\nVersion: 1.0.4\nRelease: 1\n%build\ncargo build --release\n%changelog\n* Mon Oct 26 2026 Maintainer <maintainer@example.invalid>\n- Update RPM package to RustFS 1.0.4\n',
}
for path, content in files.items():
self.repo.write(path, content)
return self.repo.commit(), files
def test_stable_installation_metadata_keeps_quick_checks_without_rust_rebuild(self):
baseline, files = self.release_metadata_base()
for path, content in files.items():
updated = content.replace('1.0.4', '1.0.5') if path != 'rustfs.spec' else content.replace('Version: 1.0.4', 'Version: 1.0.5')
if path == 'rustfs.spec':
updated = updated.replace('%changelog\n', '%changelog\n* Wed Nov 11 2026 Maintainer <maintainer@example.invalid>\n- Update RPM package to RustFS 1.0.5\n\n')
self.repo.write(path, updated)
self.repo.write('README.md', '# Install 1.0.5\n')
head = self.repo.commit()
plan = self.repo.plan(head, base=baseline)
self.assertEqual(plan['mode'], 'docs')
self.assertEqual(plan['packages'], [])
for job in gate.ALWAYS_JOBS:
self.assertEqual(plan['jobs'][job], 'success')
self.assertTrue(all(plan['jobs'][job] == 'skipped' for job in gate.CODE_JOBS))
self.assertEqual(gate.validate_plan(plan, 'pull_request', 'refs/heads/main', head, plan['tree']), plan['jobs'])
for event in ('push', 'merge_group', 'schedule', 'workflow_dispatch'):
self.assertTrue(self.repo.plan(head, event=event, base=baseline)['full_validation'])
def test_individual_stable_version_fields_without_new_rpm_history_are_metadata(self):
baseline, files = self.release_metadata_base()
for path, content in files.items():
with self.subTest(path=path):
self.repo.git('reset', '--hard', baseline)
updated = content.replace('1.0.4', '1.0.5') if path != 'rustfs.spec' else content.replace('Version: 1.0.4', 'Version: 1.0.5')
self.repo.write(path, updated)
self.assertEqual(self.repo.plan(self.repo.commit(), base=baseline)['mode'], 'docs')
def test_installation_metadata_does_not_hide_build_or_malformed_changes(self):
baseline, files = self.release_metadata_base()
cases = [
('flake.nix', files['flake.nix'].replace('src = ./.;', 'src = ./other;')),
('flake.nix', files['flake.nix'].replace('0.1.36', '0.1.37')),
('flake.nix', files['flake.nix'].replace('1.0.4', '${builtins.readFile ./version}')),
('helm/rustfs/Chart.yaml', files['helm/rustfs/Chart.yaml'] + 'dependencies: []\n'),
('helm/rustfs/Chart.yaml', files['helm/rustfs/Chart.yaml'].replace('appVersion: "1.0.4"', 'appVersion: "1.0.5"')),
('helm/rustfs/Chart.yaml', files['helm/rustfs/Chart.yaml'] + 'version: "1.0.5"\n'),
('rustfs.spec', files['rustfs.spec'].replace('cargo build --release', 'cargo build')),
('rustfs.spec', files['rustfs.spec'].replace('Release: 1', 'Release: 2')),
('rustfs.spec', files['rustfs.spec'].replace('%changelog\n', '%changelog\n%(echo injected)\n')),
('rustfs.spec', files['rustfs.spec'].replace('Update RPM package', 'Rewrite existing history')),
]
cases.extend((path, content.replace('1.0.4', '1.0.5-rc.1')) for path, content in files.items())
for path, content in cases:
with self.subTest(path=path, content=content):
self.repo.git('reset', '--hard', baseline)
self.repo.write(path, content)
self.assertTrue(self.repo.plan(self.repo.commit(), base=baseline)['full_validation'])
def test_installation_metadata_rejects_normalization_marker_collisions(self):
_, files = self.release_metadata_base()
old = files['flake.nix'].replace(' clientVersion =', ' second = pkgs.buildRustPackage {\n pname = "rustfs";\n version = "<version>";\n src = ./other;\n };\n clientVersion =')
self.repo.write('flake.nix', old)
baseline = self.repo.commit()
new = old.replace('version = "1.0.4"', 'version = "<version>"').replace('version = "<version>";\n src = ./other;', 'version = "1.0.5";\n src = ./other;')
self.repo.write('flake.nix', new)
self.assertTrue(self.repo.plan(self.repo.commit(), base=baseline)['full_validation'])
def test_installation_metadata_rejects_missing_files_symlinks_and_mode_changes(self):
baseline, files = self.release_metadata_base()
for path in files:
for mutation in ('delete', 'symlink', 'executable'):
with self.subTest(path=path, mutation=mutation):
self.repo.git('reset', '--hard', baseline)
target = self.repo.root / path
if mutation == 'executable':
target.chmod(0o755)
else:
target.unlink()
if mutation == 'symlink':
target.symlink_to('/tmp/release-metadata')
self.assertTrue(self.repo.plan(self.repo.commit(), base=baseline)['full_validation'])
self.repo.git('reset', '--hard', baseline)
self.assertTrue(self.repo.plan(baseline, base=self.repo.base)['full_validation'])
def test_installation_metadata_cannot_override_code_or_explicit_full_policy(self):
baseline, files = self.release_metadata_base()
self.repo.write('flake.nix', files['flake.nix'].replace('1.0.4', '1.0.5'))
self.repo.write('leaf/src/lib.rs', '// changed implementation\n')
plan = self.repo.plan(self.repo.commit(), base=baseline)
self.assertEqual(plan['mode'], 'full')
self.assertEqual(plan['packages'], ['consumer', 'core', 'leaf', 'rustfs'])
self.repo.git('reset', '--hard', baseline)
self.repo.write('flake.nix', files['flake.nix'].replace('1.0.4', '1.0.5'))
head = self.repo.commit()
policy = dict(self.repo.policy, rollout='enforced', full_paths=['flake.nix'])
with patch.object(gate, 'read_policy', return_value=policy):
self.assertTrue(self.repo.plan(head, base=baseline)['full_validation'])
with patch.object(gate, 'read_policy', return_value=self.repo.policy):
self.assertTrue(self.repo.plan(head, base=baseline)['full_validation'])
def test_shadow_retains_full_mode_for_harness_build_and_fixture_changes(self):
for path in ('crates/e2e_test/build.rs', 'crates/e2e_test/src/fixture.json'):
self.repo.write(path, 'changed\n')
+7 -194
View File
@@ -1,199 +1,12 @@
#!/usr/bin/env python3
"""Exercise the Docker workflow's version selection and tags without publishing."""
import os
import re
import subprocess
import tempfile
import unittest
"""Exercise the candidate-based replacement for the former release trigger."""
import importlib
import sys
from pathlib import Path
import unittest
ROOT = Path(__file__).resolve().parents[1]
WORKFLOW = ROOT / ".github/workflows/docker.yml"
REGISTRIES = ("rustfs/rustfs", "ghcr.io/rustfs/rustfs", "quay.io/rustfs/rustfs")
def run_body(name):
lines = WORKFLOW.read_text().splitlines()
start = lines.index(f" - name: {name}")
start = lines.index(" run: |", start) + 1
body = []
for line in lines[start:]:
if line.strip() and not line.startswith(" "):
break
body.append(line[10:])
return "\n".join(body)
class DockerWorkflowTests(unittest.TestCase):
def run_step(self, name, context, **env):
script = re.sub(
r"\$\{\{\s*(.*?)\s*\}\}", lambda m: context[m[1]], run_body(name)
)
# Fail on unexpected Git calls so these tests cannot contact a remote.
mock_git = """
git() {
if [[ "$1" == rev-parse ]]; then
echo 0123456789abcdef
elif [[ "$1 $2 $3" == "ls-remote --tags --refs" ]]; then
if [[ "$STABLE_EXISTS" == true ]]; then
echo "0123456789abcdef refs/tags/v1.0.0"
fi
elif [[ "$1 $2" == "ls-remote --exit-code" ]]; then
[[ "$4" == "$EXISTING_TAG" ]]
else
return 99
fi
}
"""
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "output"
result = subprocess.run(
["bash", "-e", "-o", "pipefail", "-c", mock_git + script],
env={**os.environ, "GITHUB_OUTPUT": str(output), **env},
text=True,
capture_output=True,
check=False,
)
values = (
dict(line.split("=", 1) for line in output.read_text().splitlines())
if output.exists()
else {}
)
return result, values
def classify(
self, version, event="workflow_run", push="true", stable="true", tag=None, **env
):
return self.run_step(
"Check build conditions",
{"github.event_name": event},
GITHUB_SHA="workflow-sha",
HEAD_SHA="release-sha",
HEAD_BRANCH=version,
INPUT_VERSION=version,
INPUT_PUSH_IMAGES=push,
INPUT_FORCE_REBUILD="false",
STABLE_EXISTS=stable,
EXISTING_TAG=tag or f"refs/tags/{version}",
**{"CONCLUSION": "success", "TRIGGERING_EVENT": "push", **env},
)
def assert_tags(self, values, suffix="", channels=()):
context = {
f"needs.build-check.outputs.{key}": value for key, value in values.items()
}
context.update(
{
"matrix.suffix": suffix,
"env.REGISTRY_DOCKERHUB": REGISTRIES[0],
"env.REGISTRY_GHCR": REGISTRIES[1],
"env.REGISTRY_QUAY": REGISTRIES[2],
"github.server_url": "https://github.com",
"github.repository": "rustfs/rustfs",
}
)
result, metadata = self.run_step("Extract metadata and generate tags", context)
self.assertEqual(result.returncode, 0, result.stderr)
expected = {
f"{registry}:{tag}{suffix}"
for registry in REGISTRIES
for tag in (values["version"], *channels)
}
self.assertEqual(set(metadata["tags"].split(",")), expected)
def test_preview_version_tags_only(self):
versions = (
"1.0.1-preview.8",
"v1.0.1-preview.8",
"1.0.0-alpha.1-preview.2",
"1.0.0-beta.1-preview.2",
"v1.0.0-rc.1-preview.2",
)
for event in ("workflow_run", "workflow_dispatch"):
for version in versions:
for stable in ("true", "false"):
with self.subTest(event=event, version=version, stable=stable):
result, values = self.classify(version, event, stable=stable)
self.assertEqual(result.returncode, 0, result.stderr)
for key, expected in {
"should_build": "true",
"should_push": "true",
"build_type": "preview",
"is_prerelease": "true",
"create_latest": "false",
"version": version.removeprefix("v"),
}.items():
self.assertEqual(values[key], expected, key)
expected_ref = (
"release-sha"
if event == "workflow_run"
else f"refs/tags/{version}"
)
self.assertEqual(values["source_ref"], expected_ref)
for suffix in ("", "-glibc"):
self.assert_tags(values, suffix)
def test_manual_preview_dry_run_and_tag_fallback(self):
result, values = self.classify(
"1.0.1-preview.8",
"workflow_dispatch",
push="false",
tag="refs/tags/v1.0.1-preview.8",
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(values["should_build"], "true")
self.assertEqual(values["should_push"], "false")
self.assertEqual(values["source_ref"], "refs/tags/v1.0.1-preview.8")
def test_full_tag_ref(self):
result, values = self.classify("refs/tags/v1.0.1-preview.8")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(values["build_type"], "preview")
self.assertEqual(values["version"], "1.0.1-preview.8")
self.assert_tags(values)
def test_invalid_preview_and_missing_tag_fail(self):
for event in ("workflow_run", "workflow_dispatch"):
for version in (
"1.0.1-preview",
"1.0.1-preview.x",
"1.0.1-preview.8-extra",
):
with self.subTest(event=event, version=version):
result, _ = self.classify(version, event)
self.assertNotEqual(result.returncode, 0)
result, _ = self.classify(
"1.0.1-preview.8", "workflow_dispatch", tag="refs/tags/missing"
)
self.assertNotEqual(result.returncode, 0)
def test_existing_release_channels(self):
for event in ("workflow_run", "workflow_dispatch"):
for stable in ("true", "false"):
for channel in (None, "alpha", "beta", "rc"):
version = f"1.0.1-{channel}.1" if channel else "1.0.1"
with self.subTest(event=event, stable=stable, channel=channel):
result, values = self.classify(version, event, stable=stable)
self.assertEqual(result.returncode, 0, result.stderr)
latest = channel is None or stable == "false"
self.assertEqual(values["create_latest"], str(latest).lower())
channels = ([channel] if channel else []) + (
["latest"] if latest else []
)
self.assert_tags(values, channels=channels)
def test_non_release_builds_are_skipped(self):
for version, env in (
("main", {}),
("feature/test", {}),
("1.0.1-preview.8", {"CONCLUSION": "failure"}),
("1.0.1-preview.8", {"TRIGGERING_EVENT": "workflow_dispatch"}),
):
result, values = self.classify(version, **env)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(values["should_build"], "false")
sys.path.insert(0, str(Path(__file__).resolve().parent / "release"))
module = importlib.import_module("test_pipeline")
if __name__ == "__main__":
unittest.main()
unittest.main(module=module, defaultTest=('PromotionTests', 'ImageWorkflowTests'))
+3 -1
View File
@@ -21,6 +21,8 @@ assert_version() {
rm -f "$output"
}
assert_version "refs/tags/v1.0.0-beta.12" "v1.0.0-beta.12" "1.0.0-beta.12" "0.12.0"
assert_version "refs/tags/v1.0.0-beta.12" "v1.0.0-beta.12" "1.0.0-beta.12" "1.0.0-beta.12"
assert_version "2.0.0-beta.12" "2.0.0-beta.12" "2.0.0-beta.12" "2.0.0-beta.12"
assert_version "1.0.6-rc.1" "1.0.6-rc.1" "1.0.6-rc.1" "1.0.6-rc.1"
assert_version "v1.0.0" "v1.0.0" "1.0.0" "1.0.0"
assert_version "refs/tags/1.0.0" "1.0.0" "1.0.0" "1.0.0"
+7 -130
View File
@@ -1,135 +1,12 @@
#!/usr/bin/env python3
"""Exercise the Helm publication image gate without contacting a registry."""
import os
import subprocess
import tempfile
import unittest
"""Exercise the candidate-based replacement for the former release trigger."""
import importlib
import sys
from pathlib import Path
import unittest
ROOT = Path(__file__).resolve().parents[1]
WORKFLOW = ROOT / ".github/workflows/helm-package.yml"
def image_gate():
lines = WORKFLOW.read_text().splitlines()
start = lines.index(" - name: Wait for release image")
start = lines.index(" run: |", start) + 1
body = []
for line in lines[start:]:
if line.strip() and not line.startswith(" "):
break
body.append(line[10:])
return "\n".join(body)
class HelmReleaseWorkflowTests(unittest.TestCase):
def run_gate(self, version="1.0.1", failures=0, auth="ready"):
# Unexpected URLs fail closed; these functions cannot access the network.
mocks = r"""
curl() {
case "${*: -1}" in
'https://auth.docker.io/token?service=registry.docker.io&scope=repository:rustfs/rustfs:pull')
echo auth >> "$CALLS"
case "$AUTH_STATE" in
unavailable) return 22 ;;
empty) echo '{}' ;;
ready) echo '{"token":"registry-test-token"}' ;;
*) return 99 ;;
esac
;;
"https://registry-1.docker.io/v2/rustfs/rustfs/manifests/$APP_VERSION")
[[ " $* " == *" --head "* ]] || return 99
[[ "$*" == *"Authorization: Bearer registry-test-token"* ]] || return 99
echo image >> "$CALLS"
[[ $(grep -c '^image$' "$CALLS") -gt "$IMAGE_FAILURES" ]]
;;
*) return 99 ;;
esac
}
sleep() {
[[ "$1" == 15 ]] || return 99
echo sleep >> "$CALLS"
}
"""
with tempfile.TemporaryDirectory() as directory:
calls = Path(directory) / "calls"
result = subprocess.run(
["bash", "-e", "-o", "pipefail", "-c", mocks + image_gate()],
env={
**os.environ,
"APP_VERSION": version,
"IMAGE_FAILURES": str(failures),
"AUTH_STATE": auth,
"CALLS": str(calls),
},
capture_output=True,
text=True,
check=False,
timeout=30,
)
events = calls.read_text().splitlines() if calls.exists() else []
return result, events
def test_published_stable_and_prerelease_images(self):
for version in ("1.0.1", "1.1.0-beta.1", "1.1.0-rc.1"):
with self.subTest(version=version):
result, events = self.run_gate(version)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(events, ["auth", "image"])
def test_image_published_after_binary_build(self):
result, events = self.run_gate(failures=2)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(events.count("image"), 3)
self.assertEqual(events.count("sleep"), 2)
def test_missing_image_blocks_publication_after_bounded_retries(self):
result, events = self.run_gate(failures=60)
self.assertNotEqual(result.returncode, 0)
self.assertIn("refusing to publish Helm chart", result.stderr)
self.assertEqual(events.count("image"), 60)
self.assertEqual(events.count("sleep"), 59)
def test_auth_failure_is_not_image_availability(self):
for auth in ("unavailable", "empty"):
with self.subTest(auth=auth):
result, events = self.run_gate(auth=auth)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(events.count("auth"), 60)
self.assertNotIn("image", events)
def test_preview_and_invalid_versions_never_reach_registry(self):
for version in ("1.0.1-preview.1", "1.1.0-beta.1-preview.2", "latest", "", "../tags"):
with self.subTest(version=version):
result, events = self.run_gate(version)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(events, [])
def test_image_gate_precedes_packaging(self):
workflow = WORKFLOW.read_text()
gate = workflow.index(" - name: Wait for release image")
package = workflow.index(" - name: Package Helm Chart")
self.assertLess(gate, package)
self.assertNotIn("continue-on-error", workflow[gate:package])
def test_upload_artifact_includes_chart_and_readme(self):
workflow = WORKFLOW.read_text()
copy_readme = workflow.index("cp helm/README.md helm/rustfs/")
upload = workflow.index(" - name: Upload helm package as artifact")
self.assertLess(copy_readme, upload)
publish = workflow.index(" publish-helm-package:", upload)
upload_step = workflow[upload:publish]
expected_path = "path: |\n helm/rustfs/*.tgz\n helm/rustfs/README.md\n"
self.assertIn(expected_path, upload_step)
download = workflow.index(" - name: Download helm package", publish)
push_step = workflow.index(" - name: Push helm package and index file", download)
download_step = workflow[download:push_step]
self.assertIn("name: helm-package", download_step)
self.assertIn("path: ./", download_step)
sys.path.insert(0, str(Path(__file__).resolve().parent / "release"))
module = importlib.import_module("test_pipeline")
if __name__ == "__main__":
unittest.main()
unittest.main(module=module, defaultTest=('HelmWorkflowTests',))
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
# Copyright 2026 RustFS Team
# SPDX-License-Identifier: Apache-2.0
"""Exercise real OCI builders and promotion with explicit non-product fixtures."""
import importlib
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import time
import unittest
import urllib.request
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parent / "release"))
c = importlib.import_module("candidate")
deliver = importlib.import_module("deliver")
publish = importlib.import_module("publish")
fixtures = importlib.import_module("test_candidate")
class ImageIntegrationTests(unittest.TestCase):
def test_native_oci_smoke_and_digest_preserving_registry_promotion(self):
value = fixtures.inputs()
variant = os.environ["RELEASE_IMAGE_VARIANT"]
self.assertIn(variant, ("musl", "gnu"))
output = Path(os.environ["RELEASE_IMAGE_OUTPUT"]).resolve()
output.mkdir(parents=True, exist_ok=True)
run = subprocess.run
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
for name in ("Dockerfile", "Dockerfile.glibc", "entrypoint.sh"):
shutil.copyfile(c.ROOT / name, root / name)
shutil.copytree(c.ROOT / "bootstrap", root / "bootstrap")
(root / "scripts").mkdir()
shutil.copyfile(c.ROOT / "scripts/download_rc.sh", root / "scripts/download_rc.sh")
source = root / "fixture.go"
source.write_text('''package main
import ("fmt"; "net/http"; "os")
func main() {
if len(os.Args) > 1 && os.Args[1] == "--version" { fmt.Println("rustfs 1.2.3"); return }
http.HandleFunc("/rustfs/console/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html"); fmt.Fprintln(w, "<html>transport fixture</html>")
})
if err := http.ListenAndServe(":9001", nil); err != nil { panic(err) }
}
''')
binaries = {}
for arch, docker_arch in (("x86_64", "amd64"), ("aarch64", "arm64")):
directory = root / ".release-inputs" / docker_arch
directory.mkdir(parents=True)
env = dict(os.environ, GOOS="linux", GOARCH=docker_arch, CGO_ENABLED="0")
run(["go", "build", "-o", str(directory / "rustfs"), str(source)], env=env, check=True, timeout=180)
(directory / "rc").write_text("#!/bin/sh\nexit 0\n")
(directory / "rc").chmod(0o755)
binaries[arch] = c.file_digest(directory / "rustfs")
archive = output / f"image-{variant}.tar"
dockerfile = "Dockerfile" if variant == "musl" else "Dockerfile.glibc"
run(["docker", "buildx", "build", "--platform", "linux/amd64,linux/arm64", "--provenance=false",
"--build-arg", "BINARY_SOURCE=supplied", "--build-arg", "RELEASE=1.2.3", "-f", str(root / dockerfile),
"--output", f"type=oci,dest={archive}", str(root)], check=True, timeout=1800)
c.write_json(output / f"image-{variant}-inputs.json", {"inputs_sha256": c.digest(value), "binaries": binaries})
deliver.image_smoke(value, variant, output)
with self.assertRaisesRegex(ValueError, "wrong version"):
deliver.image_smoke(dict(value, version="0.0.0"), variant, output)
expected = c.read_json(output / f"image-{variant}-smoke.json")["digest"]
registry = c.command(["docker", "run", "-d", "-p", "127.0.0.1::5000", "registry:3"])
try:
address = c.command(["docker", "port", registry, "5000/tcp"])
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
try:
with opener.open(f"http://{address}/v2/", timeout=2) as response:
self.assertEqual(response.status, 200)
break
except OSError:
time.sleep(0.1)
else:
self.fail("local registry did not become ready")
auth = root / "auth.json"
c.write_json(auth, {"auths": {}})
reference = f"{address}/fixture:1.2.3"
def local_registry(args, **kwargs):
# Only the ephemeral loopback registry uses HTTP. All OCI
# copying, digest checks and conflict handling are real.
if args[:2] == ["skopeo", "inspect"]:
args = args[:2] + ["--tls-verify=false"] + args[2:]
elif args[:2] == ["skopeo", "copy"]:
args = args[:2] + ["--dest-tls-verify=false"] + args[2:]
return run(args, **kwargs)
with patch.dict(os.environ, REGISTRY_AUTH_FILE=str(auth)), patch.object(subprocess, "run", side_effect=local_registry):
publish.copy_image(archive, reference, expected)
publish.copy_image(archive, reference, expected)
self.assertEqual(publish.image_digest(reference), expected)
with self.assertRaisesRegex(ValueError, "never replace"):
publish.copy_image(archive, reference, "sha256:" + "0" * 64)
self.assertEqual(publish.image_digest(reference), expected)
finally:
run(["docker", "rm", "-f", "-v", registry], check=True, timeout=60)
if __name__ == "__main__":
unittest.main()
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
# Copyright 2026 RustFS Team
# SPDX-License-Identifier: Apache-2.0
"""Linux integration tests using the real DEB/RPM recipes and package managers."""
import hashlib
import importlib
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parent / "release"))
deliver = importlib.import_module("deliver")
c = importlib.import_module("candidate")
fixtures = importlib.import_module("test_candidate")
class PackageIntegrationTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
missing = [tool for tool in ("dpkg-deb", "fakeroot", "rpm", "rpm2cpio", "cpio", "fpm") if not shutil.which(tool)]
if sys.platform != "linux" or missing:
raise RuntimeError(f"Run on Linux with real packaging tools; missing: {missing}")
def test_each_target_wraps_its_accepted_bytes(self):
for target in c.LINUX:
with self.subTest(target=target), tempfile.TemporaryDirectory() as temp:
root = Path(temp)
executable = root / "rustfs"
executable.write_bytes(b"package contract fixture only: " + target.encode())
expected = hashlib.sha256(executable.read_bytes()).hexdigest()
value = fixtures.inputs()
with patch.object(deliver, "binary", return_value=(executable, {"binary_sha256": expected})):
deliver.package(value, target, root / "output")
record = c.read_json(root / "output" / f"packages-{target}.json")
self.assertEqual(record["binary_sha256"], expected)
self.assertEqual(record["inputs_sha256"], c.digest(value))
self.assertEqual({Path(item["name"]).suffix for item in record["files"]}, {".deb", ".rpm"})
def test_real_package_with_substituted_payload_is_rejected(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
executable = root / "rustfs"
executable.write_bytes(b"accepted fixture")
expected = c.file_digest(executable)
real_run = deliver.run
def mutate_deb(args, **kwargs):
result = real_run(args, **kwargs)
if any(str(arg).endswith("build_deb.sh") for arg in args):
work = Path(kwargs["cwd"])
package = work / kwargs["env"]["DEB_FILE"]
unpacked = work / "corrupted-package"
subprocess.run(["dpkg-deb", "--raw-extract", str(package), str(unpacked)], check=True)
(unpacked / "usr/bin/rustfs").write_bytes(b"wrong server")
subprocess.run(["dpkg-deb", "--build", str(unpacked), str(package)], check=True)
return result
with patch.object(deliver, "binary", return_value=(executable, {"binary_sha256": expected})), patch.object(deliver, "run", side_effect=mutate_deb):
with self.assertRaisesRegex(ValueError, "package binary differs"):
deliver.package(fixtures.inputs(), "linux-x86_64-musl", root / "output")
self.assertFalse(list((root / "output").glob("packages-*.json")))
if __name__ == "__main__":
unittest.main()
+29
View File
@@ -0,0 +1,29 @@
{
"schema_version": 1,
"product_commit": "3333333333333333333333333333333333333333",
"version": "9.9.9-fixture",
"release_profile": "stable",
"features": [
"pyroscope"
],
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
"validation_batch": {
"run_id": "fixture-batch-1",
"run_attempt": 2
},
"deliverables": [
{
"kind": "zip",
"name": "rustfs-linux-x86_64-gnu-v9.9.9-fixture.zip",
"location": "https://example.invalid/artifacts/runs/fixture-batch-1/2/rustfs-linux-x86_64-gnu-v9.9.9-fixture.zip",
"sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
"size": 36,
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa"
}
],
"image": {
"ref": "example.invalid/rustfs:9.9.9-fixture",
"digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555",
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"schema_version": 1,
"run_id": "fixture-batch-1",
"run_attempt": 2,
"status": "completed"
}
@@ -0,0 +1,42 @@
{
"schema_version": 1,
"evidence_profile": "release-artifact",
"run_id": "fixture-batch-1",
"run_attempt": 2,
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
"tier": "release-certification",
"lane": "identity",
"case_id": "artifact-identity",
"product_commit": "3333333333333333333333333333333333333333",
"harness_commit": "1111111111111111111111111111111111111111",
"target_fingerprint": "rustfs-9.9.9-fixture-linux-aarch64-gnu",
"platform": {
"os": "linux",
"arch": "aarch64",
"abi": "gnu"
},
"started_at": "2026-09-26T10:02:00Z",
"finished_at": "2026-09-26T10:02:30Z",
"state": "pass",
"reason_code": "ok",
"reason_class": "NOT_APPLICABLE",
"evidence": [
{
"path": "cases/identity/artifact-identity-linux-aarch64-gnu.json",
"sha256": "fa138d5891e5098b97137b0edbb4779c4350285fdc76cec7e6214809b34b3075",
"size": 64
}
],
"workflow": {
"repository": "rustfs/s3chaos",
"workflow_id": "release-gate.yml",
"run_id": "child-identity-linux-aarch64-gnu",
"run_attempt": 1,
"workflow_sha": "1111111111111111111111111111111111111111"
},
"artifact": {
"archive_sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa",
"container_image_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555"
}
}
@@ -0,0 +1,42 @@
{
"schema_version": 1,
"evidence_profile": "release-artifact",
"run_id": "fixture-batch-1",
"run_attempt": 2,
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
"tier": "release-certification",
"lane": "identity",
"case_id": "startup",
"product_commit": "3333333333333333333333333333333333333333",
"harness_commit": "1111111111111111111111111111111111111111",
"target_fingerprint": "rustfs-9.9.9-fixture-linux-aarch64-gnu",
"platform": {
"os": "linux",
"arch": "aarch64",
"abi": "gnu"
},
"started_at": "2026-09-26T10:03:00Z",
"finished_at": "2026-09-26T10:03:30Z",
"state": "pass",
"reason_code": "ok",
"reason_class": "NOT_APPLICABLE",
"evidence": [
{
"path": "cases/identity/startup-linux-aarch64-gnu.json",
"sha256": "314b19c4fee5f406cbf7ee0d6a436059115f1ff06f6864830045f9d452ab91c9",
"size": 64
}
],
"workflow": {
"repository": "rustfs/s3chaos",
"workflow_id": "release-gate.yml",
"run_id": "child-identity-linux-aarch64-gnu",
"run_attempt": 1,
"workflow_sha": "1111111111111111111111111111111111111111"
},
"artifact": {
"archive_sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa",
"container_image_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555"
}
}
@@ -0,0 +1,42 @@
{
"schema_version": 1,
"evidence_profile": "release-artifact",
"run_id": "fixture-batch-1",
"run_attempt": 2,
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
"tier": "release-certification",
"lane": "identity",
"case_id": "artifact-identity",
"product_commit": "3333333333333333333333333333333333333333",
"harness_commit": "1111111111111111111111111111111111111111",
"target_fingerprint": "rustfs-9.9.9-fixture-linux-x86_64-gnu",
"platform": {
"os": "linux",
"arch": "x86_64",
"abi": "gnu"
},
"started_at": "2026-09-26T10:00:00Z",
"finished_at": "2026-09-26T10:00:30Z",
"state": "pass",
"reason_code": "ok",
"reason_class": "NOT_APPLICABLE",
"evidence": [
{
"path": "cases/identity/artifact-identity-linux-x86_64-gnu.json",
"sha256": "37e58ec3fc6fe3ac7ff2fcbeebdb62f6a6aaaa34ec771a0f3306c821da0336f7",
"size": 64
}
],
"workflow": {
"repository": "rustfs/s3chaos",
"workflow_id": "release-gate.yml",
"run_id": "child-identity-linux-x86_64-gnu",
"run_attempt": 1,
"workflow_sha": "1111111111111111111111111111111111111111"
},
"artifact": {
"archive_sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa",
"container_image_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555"
}
}
@@ -0,0 +1,43 @@
{
"schema_version": 1,
"evidence_profile": "release-artifact",
"run_id": "fixture-batch-1",
"run_attempt": 2,
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
"tier": "release-certification",
"lane": "identity",
"case_id": "startup",
"product_commit": "3333333333333333333333333333333333333333",
"harness_commit": "1111111111111111111111111111111111111111",
"target_fingerprint": "rustfs-9.9.9-fixture-linux-x86_64-gnu",
"platform": {
"os": "linux",
"arch": "x86_64",
"abi": "gnu"
},
"started_at": "2026-09-26T10:01:00Z",
"finished_at": "2026-09-26T10:01:30Z",
"state": "infra_error",
"reason_code": "child-cancelled",
"reason_class": "ENV",
"evidence": [
{
"path": "cases/identity/startup-linux-x86_64-gnu.json",
"sha256": "8b8774175c55c400b337610a89fe725ad2637af8fcc4973a82ab218f9182b80d",
"size": 64
}
],
"workflow": {
"repository": "rustfs/s3chaos",
"workflow_id": "release-gate.yml",
"run_id": "child-identity-linux-x86_64-gnu",
"run_attempt": 1,
"workflow_sha": "1111111111111111111111111111111111111111",
"status": "cancelled"
},
"artifact": {
"archive_sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa",
"container_image_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555"
}
}
@@ -0,0 +1,42 @@
{
"schema_version": 1,
"evidence_profile": "release-artifact",
"run_id": "fixture-batch-1",
"run_attempt": 2,
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
"tier": "release-certification",
"lane": "upgrade",
"case_id": "upgrade-smoke",
"product_commit": "3333333333333333333333333333333333333333",
"harness_commit": "2222222222222222222222222222222222222222",
"target_fingerprint": "rustfs-9.9.9-fixture-linux-x86_64-gnu",
"platform": {
"os": "linux",
"arch": "x86_64",
"abi": "gnu"
},
"started_at": "2026-09-26T10:04:00Z",
"finished_at": "2026-09-26T10:04:30Z",
"state": "pass",
"reason_code": "ok",
"reason_class": "NOT_APPLICABLE",
"evidence": [
{
"path": "cases/upgrade/upgrade-smoke-linux-x86_64-gnu.json",
"sha256": "c1ce56808b52859d2590b34b5990aed7bf560b134e44bd0c09f1873c9f764b38",
"size": 64
}
],
"workflow": {
"repository": "rustfs/s3chaos",
"workflow_id": "release-gate.yml",
"run_id": "child-upgrade-linux-x86_64-gnu",
"run_attempt": 1,
"workflow_sha": "2222222222222222222222222222222222222222"
},
"artifact": {
"archive_sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa",
"container_image_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"schema_version": 1,
"run_id": "fixture-batch-1",
"run_attempt": 2,
"status": "completed"
}
@@ -0,0 +1,42 @@
{
"schema_version": 1,
"evidence_profile": "release-artifact",
"run_id": "fixture-batch-1",
"run_attempt": 2,
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
"tier": "release-certification",
"lane": "identity",
"case_id": "artifact-identity",
"product_commit": "3333333333333333333333333333333333333333",
"harness_commit": "1111111111111111111111111111111111111111",
"target_fingerprint": "rustfs-9.9.9-fixture-linux-aarch64-gnu",
"platform": {
"os": "linux",
"arch": "aarch64",
"abi": "gnu"
},
"started_at": "2026-09-26T10:02:00Z",
"finished_at": "2026-09-26T10:02:30Z",
"state": "pass",
"reason_code": "ok",
"reason_class": "NOT_APPLICABLE",
"evidence": [
{
"path": "cases/identity/artifact-identity-linux-aarch64-gnu.json",
"sha256": "fa138d5891e5098b97137b0edbb4779c4350285fdc76cec7e6214809b34b3075",
"size": 64
}
],
"workflow": {
"repository": "rustfs/s3chaos",
"workflow_id": "release-gate.yml",
"run_id": "child-cross-identity-linux-aarch64-gnu-artifact-identity",
"run_attempt": 1,
"workflow_sha": "4444444444444444444444444444444444444444"
},
"artifact": {
"archive_sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa",
"container_image_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555"
}
}
@@ -0,0 +1,42 @@
{
"schema_version": 1,
"evidence_profile": "release-artifact",
"run_id": "fixture-batch-1",
"run_attempt": 2,
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
"tier": "release-certification",
"lane": "identity",
"case_id": "startup",
"product_commit": "3333333333333333333333333333333333333333",
"harness_commit": "1111111111111111111111111111111111111111",
"target_fingerprint": "rustfs-9.9.9-fixture-linux-aarch64-gnu",
"platform": {
"os": "linux",
"arch": "aarch64",
"abi": "gnu"
},
"started_at": "2026-09-26T10:03:00Z",
"finished_at": "2026-09-26T10:03:30Z",
"state": "pass",
"reason_code": "ok",
"reason_class": "NOT_APPLICABLE",
"evidence": [
{
"path": "cases/identity/startup-linux-aarch64-gnu.json",
"sha256": "314b19c4fee5f406cbf7ee0d6a436059115f1ff06f6864830045f9d452ab91c9",
"size": 64
}
],
"workflow": {
"repository": "rustfs/s3chaos",
"workflow_id": "release-gate.yml",
"run_id": "child-cross-identity-linux-aarch64-gnu-startup",
"run_attempt": 1,
"workflow_sha": "4444444444444444444444444444444444444444"
},
"artifact": {
"archive_sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa",
"container_image_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555"
}
}
@@ -0,0 +1,42 @@
{
"schema_version": 1,
"evidence_profile": "release-artifact",
"run_id": "fixture-batch-1",
"run_attempt": 2,
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
"tier": "release-certification",
"lane": "identity",
"case_id": "artifact-identity",
"product_commit": "3333333333333333333333333333333333333333",
"harness_commit": "1111111111111111111111111111111111111111",
"target_fingerprint": "rustfs-9.9.9-fixture-linux-x86_64-gnu",
"platform": {
"os": "linux",
"arch": "x86_64",
"abi": "gnu"
},
"started_at": "2026-09-26T10:00:00Z",
"finished_at": "2026-09-26T10:00:30Z",
"state": "pass",
"reason_code": "ok",
"reason_class": "NOT_APPLICABLE",
"evidence": [
{
"path": "cases/identity/artifact-identity-linux-x86_64-gnu.json",
"sha256": "37e58ec3fc6fe3ac7ff2fcbeebdb62f6a6aaaa34ec771a0f3306c821da0336f7",
"size": 64
}
],
"workflow": {
"repository": "rustfs/s3chaos",
"workflow_id": "release-gate.yml",
"run_id": "child-cross-identity-linux-x86_64-gnu-artifact-identity",
"run_attempt": 1,
"workflow_sha": "4444444444444444444444444444444444444444"
},
"artifact": {
"archive_sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa",
"container_image_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555"
}
}
@@ -0,0 +1,42 @@
{
"schema_version": 1,
"evidence_profile": "release-artifact",
"run_id": "fixture-batch-1",
"run_attempt": 2,
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
"tier": "release-certification",
"lane": "identity",
"case_id": "startup",
"product_commit": "3333333333333333333333333333333333333333",
"harness_commit": "1111111111111111111111111111111111111111",
"target_fingerprint": "rustfs-9.9.9-fixture-linux-x86_64-gnu",
"platform": {
"os": "linux",
"arch": "x86_64",
"abi": "gnu"
},
"started_at": "2026-09-26T10:01:00Z",
"finished_at": "2026-09-26T10:01:30Z",
"state": "pass",
"reason_code": "ok",
"reason_class": "NOT_APPLICABLE",
"evidence": [
{
"path": "cases/identity/startup-linux-x86_64-gnu.json",
"sha256": "8b8774175c55c400b337610a89fe725ad2637af8fcc4973a82ab218f9182b80d",
"size": 64
}
],
"workflow": {
"repository": "rustfs/s3chaos",
"workflow_id": "release-gate.yml",
"run_id": "child-cross-identity-linux-x86_64-gnu-startup",
"run_attempt": 1,
"workflow_sha": "4444444444444444444444444444444444444444"
},
"artifact": {
"archive_sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa",
"container_image_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555"
}
}
@@ -0,0 +1,42 @@
{
"schema_version": 1,
"evidence_profile": "release-artifact",
"run_id": "fixture-batch-1",
"run_attempt": 2,
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
"tier": "release-certification",
"lane": "upgrade",
"case_id": "upgrade-smoke",
"product_commit": "3333333333333333333333333333333333333333",
"harness_commit": "2222222222222222222222222222222222222222",
"target_fingerprint": "rustfs-9.9.9-fixture-linux-x86_64-gnu",
"platform": {
"os": "linux",
"arch": "x86_64",
"abi": "gnu"
},
"started_at": "2026-09-26T10:04:00Z",
"finished_at": "2026-09-26T10:04:30Z",
"state": "pass",
"reason_code": "ok",
"reason_class": "NOT_APPLICABLE",
"evidence": [
{
"path": "cases/upgrade/upgrade-smoke-linux-x86_64-gnu.json",
"sha256": "c1ce56808b52859d2590b34b5990aed7bf560b134e44bd0c09f1873c9f764b38",
"size": 64
}
],
"workflow": {
"repository": "rustfs/s3chaos",
"workflow_id": "release-gate.yml",
"run_id": "child-cross-upgrade-linux-x86_64-gnu-upgrade-smoke",
"run_attempt": 1,
"workflow_sha": "4444444444444444444444444444444444444444"
},
"artifact": {
"archive_sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa",
"container_image_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"schema_version": 1,
"run_id": "fixture-batch-1",
"run_attempt": 2,
"status": "completed"
}
@@ -0,0 +1,42 @@
{
"schema_version": 1,
"evidence_profile": "release-artifact",
"run_id": "fixture-batch-1",
"run_attempt": 2,
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
"tier": "release-certification",
"lane": "identity",
"case_id": "artifact-identity",
"product_commit": "3333333333333333333333333333333333333333",
"harness_commit": "1111111111111111111111111111111111111111",
"target_fingerprint": "rustfs-9.9.9-fixture-linux-aarch64-gnu",
"platform": {
"os": "linux",
"arch": "aarch64",
"abi": "gnu"
},
"started_at": "2026-09-26T10:02:00Z",
"finished_at": "2026-09-26T10:02:30Z",
"state": "pass",
"reason_code": "ok",
"reason_class": "NOT_APPLICABLE",
"evidence": [
{
"path": "cases/identity/artifact-identity-linux-aarch64-gnu.json",
"sha256": "fa138d5891e5098b97137b0edbb4779c4350285fdc76cec7e6214809b34b3075",
"size": 64
}
],
"workflow": {
"repository": "rustfs/s3chaos",
"workflow_id": "release-gate.yml",
"run_id": "child-identity-linux-aarch64-gnu",
"run_attempt": 1,
"workflow_sha": "1111111111111111111111111111111111111111"
},
"artifact": {
"archive_sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa",
"container_image_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555"
}
}
@@ -0,0 +1,42 @@
{
"schema_version": 1,
"evidence_profile": "release-artifact",
"run_id": "fixture-batch-1",
"run_attempt": 2,
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
"tier": "release-certification",
"lane": "identity",
"case_id": "startup",
"product_commit": "3333333333333333333333333333333333333333",
"harness_commit": "1111111111111111111111111111111111111111",
"target_fingerprint": "rustfs-9.9.9-fixture-linux-aarch64-gnu",
"platform": {
"os": "linux",
"arch": "aarch64",
"abi": "gnu"
},
"started_at": "2026-09-26T10:03:00Z",
"finished_at": "2026-09-26T10:03:30Z",
"state": "pass",
"reason_code": "ok",
"reason_class": "NOT_APPLICABLE",
"evidence": [
{
"path": "cases/identity/startup-linux-aarch64-gnu.json",
"sha256": "314b19c4fee5f406cbf7ee0d6a436059115f1ff06f6864830045f9d452ab91c9",
"size": 64
}
],
"workflow": {
"repository": "rustfs/s3chaos",
"workflow_id": "release-gate.yml",
"run_id": "child-identity-linux-aarch64-gnu",
"run_attempt": 1,
"workflow_sha": "1111111111111111111111111111111111111111"
},
"artifact": {
"archive_sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa",
"container_image_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555"
}
}
@@ -0,0 +1,42 @@
{
"schema_version": 1,
"evidence_profile": "release-artifact",
"run_id": "fixture-batch-1",
"run_attempt": 2,
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
"tier": "release-certification",
"lane": "identity",
"case_id": "artifact-identity",
"product_commit": "3333333333333333333333333333333333333333",
"harness_commit": "1111111111111111111111111111111111111111",
"target_fingerprint": "rustfs-9.9.9-fixture-linux-x86_64-gnu",
"platform": {
"os": "linux",
"arch": "x86_64",
"abi": "gnu"
},
"started_at": "2026-09-26T10:00:00Z",
"finished_at": "2026-09-26T10:00:30Z",
"state": "pass",
"reason_code": "ok",
"reason_class": "NOT_APPLICABLE",
"evidence": [
{
"path": "cases/identity/artifact-identity-linux-x86_64-gnu.json",
"sha256": "37e58ec3fc6fe3ac7ff2fcbeebdb62f6a6aaaa34ec771a0f3306c821da0336f7",
"size": 64
}
],
"workflow": {
"repository": "rustfs/s3chaos",
"workflow_id": "release-gate.yml",
"run_id": "child-identity-linux-x86_64-gnu",
"run_attempt": 1,
"workflow_sha": "1111111111111111111111111111111111111111"
},
"artifact": {
"archive_sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa",
"container_image_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555"
}
}
@@ -0,0 +1,42 @@
{
"schema_version": 1,
"evidence_profile": "release-artifact",
"run_id": "fixture-batch-1",
"run_attempt": 2,
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
"tier": "release-certification",
"lane": "identity",
"case_id": "startup",
"product_commit": "3333333333333333333333333333333333333333",
"harness_commit": "1111111111111111111111111111111111111111",
"target_fingerprint": "rustfs-9.9.9-fixture-linux-x86_64-gnu",
"platform": {
"os": "linux",
"arch": "x86_64",
"abi": "gnu"
},
"started_at": "2026-09-26T10:01:00Z",
"finished_at": "2026-09-26T10:01:30Z",
"state": "pass",
"reason_code": "ok",
"reason_class": "NOT_APPLICABLE",
"evidence": [
{
"path": "cases/identity/startup-linux-x86_64-gnu.json",
"sha256": "8b8774175c55c400b337610a89fe725ad2637af8fcc4973a82ab218f9182b80d",
"size": 64
}
],
"workflow": {
"repository": "rustfs/s3chaos",
"workflow_id": "release-gate.yml",
"run_id": "child-identity-linux-x86_64-gnu",
"run_attempt": 1,
"workflow_sha": "1111111111111111111111111111111111111111"
},
"artifact": {
"archive_sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa",
"container_image_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555"
}
}
@@ -0,0 +1,42 @@
{
"schema_version": 1,
"evidence_profile": "release-artifact",
"run_id": "fixture-batch-1",
"run_attempt": 2,
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
"tier": "release-certification",
"lane": "identity",
"case_id": "startup",
"product_commit": "3333333333333333333333333333333333333333",
"harness_commit": "1111111111111111111111111111111111111111",
"target_fingerprint": "rustfs-9.9.9-fixture-linux-x86_64-gnu",
"platform": {
"os": "linux",
"arch": "x86_64",
"abi": "gnu"
},
"started_at": "2026-09-26T10:01:00Z",
"finished_at": "2026-09-26T10:01:30Z",
"state": "pass",
"reason_code": "ok",
"reason_class": "NOT_APPLICABLE",
"evidence": [
{
"path": "cases/identity/startup-linux-x86_64-gnu.json",
"sha256": "8b8774175c55c400b337610a89fe725ad2637af8fcc4973a82ab218f9182b80d",
"size": 64
}
],
"workflow": {
"repository": "rustfs/s3chaos",
"workflow_id": "release-gate.yml",
"run_id": "child-identity-linux-x86_64-gnu",
"run_attempt": 1,
"workflow_sha": "1111111111111111111111111111111111111111"
},
"artifact": {
"archive_sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa",
"container_image_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555"
}
}
@@ -0,0 +1,42 @@
{
"schema_version": 1,
"evidence_profile": "release-artifact",
"run_id": "fixture-batch-1",
"run_attempt": 2,
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
"tier": "release-certification",
"lane": "upgrade",
"case_id": "upgrade-smoke",
"product_commit": "3333333333333333333333333333333333333333",
"harness_commit": "2222222222222222222222222222222222222222",
"target_fingerprint": "rustfs-9.9.9-fixture-linux-x86_64-gnu",
"platform": {
"os": "linux",
"arch": "x86_64",
"abi": "gnu"
},
"started_at": "2026-09-26T10:04:00Z",
"finished_at": "2026-09-26T10:04:30Z",
"state": "pass",
"reason_code": "ok",
"reason_class": "NOT_APPLICABLE",
"evidence": [
{
"path": "cases/upgrade/upgrade-smoke-linux-x86_64-gnu.json",
"sha256": "c1ce56808b52859d2590b34b5990aed7bf560b134e44bd0c09f1873c9f764b38",
"size": 64
}
],
"workflow": {
"repository": "rustfs/s3chaos",
"workflow_id": "release-gate.yml",
"run_id": "child-upgrade-linux-x86_64-gnu",
"run_attempt": 1,
"workflow_sha": "2222222222222222222222222222222222222222"
},
"artifact": {
"archive_sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa",
"container_image_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"schema_version": 1,
"run_id": "fixture-batch-1",
"run_attempt": 2,
"status": "completed"
}
@@ -0,0 +1,42 @@
{
"schema_version": 1,
"evidence_profile": "release-artifact",
"run_id": "fixture-batch-1",
"run_attempt": 2,
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
"tier": "release-certification",
"lane": "identity",
"case_id": "artifact-identity",
"product_commit": "3333333333333333333333333333333333333333",
"harness_commit": "1111111111111111111111111111111111111111",
"target_fingerprint": "rustfs-9.9.9-fixture-linux-aarch64-gnu",
"platform": {
"os": "linux",
"arch": "aarch64",
"abi": "gnu"
},
"started_at": "2026-09-26T10:02:00Z",
"finished_at": "2026-09-26T10:02:30Z",
"state": "pass",
"reason_code": "ok",
"reason_class": "NOT_APPLICABLE",
"evidence": [
{
"path": "cases/identity/artifact-identity-linux-aarch64-gnu.json",
"sha256": "fa138d5891e5098b97137b0edbb4779c4350285fdc76cec7e6214809b34b3075",
"size": 64
}
],
"workflow": {
"repository": "rustfs/s3chaos",
"workflow_id": "release-gate.yml",
"run_id": "child-identity-linux-aarch64-gnu",
"run_attempt": 1,
"workflow_sha": "1111111111111111111111111111111111111111"
},
"artifact": {
"archive_sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa",
"container_image_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555"
}
}
@@ -0,0 +1,42 @@
{
"schema_version": 1,
"evidence_profile": "release-artifact",
"run_id": "fixture-batch-1",
"run_attempt": 2,
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
"tier": "release-certification",
"lane": "identity",
"case_id": "startup",
"product_commit": "3333333333333333333333333333333333333333",
"harness_commit": "1111111111111111111111111111111111111111",
"target_fingerprint": "rustfs-9.9.9-fixture-linux-aarch64-gnu",
"platform": {
"os": "linux",
"arch": "aarch64",
"abi": "gnu"
},
"started_at": "2026-09-26T10:03:00Z",
"finished_at": "2026-09-26T10:03:30Z",
"state": "pass",
"reason_code": "ok",
"reason_class": "NOT_APPLICABLE",
"evidence": [
{
"path": "cases/identity/startup-linux-aarch64-gnu.json",
"sha256": "314b19c4fee5f406cbf7ee0d6a436059115f1ff06f6864830045f9d452ab91c9",
"size": 64
}
],
"workflow": {
"repository": "rustfs/s3chaos",
"workflow_id": "release-gate.yml",
"run_id": "child-identity-linux-aarch64-gnu",
"run_attempt": 1,
"workflow_sha": "1111111111111111111111111111111111111111"
},
"artifact": {
"archive_sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa",
"container_image_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555"
}
}
@@ -0,0 +1,42 @@
{
"schema_version": 1,
"evidence_profile": "release-artifact",
"run_id": "fixture-batch-1",
"run_attempt": 2,
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
"tier": "release-certification",
"lane": "identity",
"case_id": "artifact-identity",
"product_commit": "3333333333333333333333333333333333333333",
"harness_commit": "1111111111111111111111111111111111111111",
"target_fingerprint": "rustfs-9.9.9-fixture-linux-x86_64-gnu",
"platform": {
"os": "linux",
"arch": "x86_64",
"abi": "gnu"
},
"started_at": "2026-09-26T10:00:00Z",
"finished_at": "2026-09-26T10:00:30Z",
"state": "pass",
"reason_code": "ok",
"reason_class": "NOT_APPLICABLE",
"evidence": [
{
"path": "cases/identity/artifact-identity-linux-x86_64-gnu.json",
"sha256": "37e58ec3fc6fe3ac7ff2fcbeebdb62f6a6aaaa34ec771a0f3306c821da0336f7",
"size": 64
}
],
"workflow": {
"repository": "rustfs/s3chaos",
"workflow_id": "release-gate.yml",
"run_id": "child-identity-linux-x86_64-gnu",
"run_attempt": 1,
"workflow_sha": "1111111111111111111111111111111111111111"
},
"artifact": {
"archive_sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa",
"container_image_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555"
}
}
@@ -0,0 +1,42 @@
{
"schema_version": 1,
"evidence_profile": "release-artifact",
"run_id": "fixture-batch-1",
"run_attempt": 2,
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
"tier": "release-certification",
"lane": "identity",
"case_id": "startup",
"product_commit": "3333333333333333333333333333333333333333",
"harness_commit": "1111111111111111111111111111111111111111",
"target_fingerprint": "rustfs-9.9.9-fixture-linux-x86_64-gnu",
"platform": {
"os": "linux",
"arch": "x86_64",
"abi": "gnu"
},
"started_at": "2026-09-26T10:01:00Z",
"finished_at": "2026-09-26T10:01:30Z",
"state": "pass",
"reason_code": "ok",
"reason_class": "NOT_APPLICABLE",
"evidence": [
{
"path": "cases/identity/startup-linux-x86_64-gnu.json",
"sha256": "8b8774175c55c400b337610a89fe725ad2637af8fcc4973a82ab218f9182b80d",
"size": 64
}
],
"workflow": {
"repository": "rustfs/s3chaos",
"workflow_id": "release-gate.yml",
"run_id": "child-identity-linux-x86_64-gnu",
"run_attempt": 1,
"workflow_sha": "1111111111111111111111111111111111111111"
},
"artifact": {
"archive_sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa",
"container_image_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555"
}
}
@@ -0,0 +1,42 @@
{
"schema_version": 1,
"evidence_profile": "release-artifact",
"run_id": "fixture-batch-1",
"run_attempt": 2,
"plan_sha256": "578bb57e65df8ba7a63e40aafc254fa644163270b7ec288fe3368bcd7c10ff86",
"tier": "release-certification",
"lane": "identity",
"case_id": "surprise-case",
"product_commit": "3333333333333333333333333333333333333333",
"harness_commit": "1111111111111111111111111111111111111111",
"target_fingerprint": "rustfs-9.9.9-fixture-linux-x86_64-gnu",
"platform": {
"os": "linux",
"arch": "x86_64",
"abi": "gnu"
},
"started_at": "2026-09-26T10:05:00Z",
"finished_at": "2026-09-26T10:05:30Z",
"state": "pass",
"reason_code": "ok",
"reason_class": "NOT_APPLICABLE",
"evidence": [
{
"path": "cases/identity/surprise-case-linux-x86_64-gnu.json",
"sha256": "f82eedac16c983bd426ca24a6cbf3acfa032dc722e9c4a5ed7ef21c7eff0bcb1",
"size": 64
}
],
"workflow": {
"repository": "rustfs/s3chaos",
"workflow_id": "release-gate.yml",
"run_id": "child-identity-linux-x86_64-gnu",
"run_attempt": 1,
"workflow_sha": "1111111111111111111111111111111111111111"
},
"artifact": {
"archive_sha256": "ea3ac1fb0ec4aa8fbec650f64ccdbc2543843c8d5734c3915d1b57d7cbb92ec8",
"binary_sha256": "7b77e78cc7b8f63a60f8a7101bebed6502f82e03016f4d4e11450f927e0a52fa",
"container_image_digest": "sha256:5555555555555555555555555555555555555555555555555555555555555555"
}
}

Some files were not shown because too many files have changed in this diff Show More