From 6c2d14ce5c6a0fd7b8ee16c5d4091513dc3b5286 Mon Sep 17 00:00:00 2001 From: Vaibhav Patel Date: Thu, 15 Oct 2026 00:24:25 +0000 Subject: [PATCH] fix(release): repair cross-platform and netboot builds Isolate Linux-only filesystem syscalls for Darwin release builds, use stable Noble netboot URLs for both architectures, and prevent failed release builds from reaching the publish job with empty outputs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release-upgrade.yaml | 7 +- hack/release/release_upgrade_workflow_test.go | 35 +++++++++ images/netboot/Containerfile | 15 ++-- internal/fsutil/fsutil.go | 60 ---------------- internal/fsutil/fsutil_linux.go | 71 +++++++++++++++++++ internal/fsutil/fsutil_other.go | 29 ++++++++ 6 files changed, 147 insertions(+), 70 deletions(-) create mode 100644 hack/release/release_upgrade_workflow_test.go create mode 100644 internal/fsutil/fsutil_linux.go create mode 100644 internal/fsutil/fsutil_other.go diff --git a/.github/workflows/release-upgrade.yaml b/.github/workflows/release-upgrade.yaml index 2804e42cb1..275e623a7a 100644 --- a/.github/workflows/release-upgrade.yaml +++ b/.github/workflows/release-upgrade.yaml @@ -893,11 +893,14 @@ jobs: # main only, so there was nothing to run # # A FAILED gate matches neither and publishes nothing, which is the property - # worth protecting. `skipped` is compared explicitly rather than testing for - # "not success", which would let a failure through. + # worth protecting. resolve must also have succeeded: when the upstream + # release build fails, resolve is skipped and its empty from_main output + # must not look like a release-branch cut. `skipped` is compared explicitly + # rather than testing for "not success", which would let a failure through. if: > !cancelled() && github.repository == 'Azure/unbounded' && + needs.resolve.result == 'success' && inputs.force_publish != true && inputs.force_publish != 'true' && ( diff --git a/hack/release/release_upgrade_workflow_test.go b/hack/release/release_upgrade_workflow_test.go new file mode 100644 index 0000000000..eb4974c02b --- /dev/null +++ b/hack/release/release_upgrade_workflow_test.go @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// SPDX-License-Identifier: Apache-2.0 + +package release + +import ( + "os" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestPublishRequiresResolvedRelease(t *testing.T) { + t.Parallel() + + data, err := os.ReadFile("../../.github/workflows/release-upgrade.yaml") + if err != nil { + t.Fatal(err) + } + + var workflow struct { + Jobs map[string]struct { + If string `yaml:"if"` + } `yaml:"jobs"` + } + if err := yaml.Unmarshal(data, &workflow); err != nil { + t.Fatal(err) + } + + condition := workflow.Jobs["publish"].If + if !strings.Contains(condition, "needs.resolve.result == 'success'") { + t.Fatalf("publish condition can run without a resolved release:\n%s", condition) + } +} diff --git a/images/netboot/Containerfile b/images/netboot/Containerfile index 90ed9021ec..b2442846f6 100644 --- a/images/netboot/Containerfile +++ b/images/netboot/Containerfile @@ -22,7 +22,7 @@ RUN mkdir -p /artifacts && \ KVER=$(ls /lib/modules | head -1) && \ cp "/boot/vmlinuz-${KVER}" /artifacts/vmlinuz && \ if [ "${TARGETARCH}" = "amd64" ]; then \ - NETBOOT_BASE="https://releases.ubuntu.com/24.04.2/netboot/amd64"; \ + NETBOOT_BASE="https://releases.ubuntu.com/noble/netboot/amd64"; \ curl -fSL --connect-timeout 10 --retry 3 --retry-delay 2 \ -o /artifacts/shimx64.efi "${NETBOOT_BASE}/bootx64.efi" && \ curl -fSL --connect-timeout 10 --retry 3 --retry-delay 2 \ @@ -30,14 +30,13 @@ RUN mkdir -p /artifacts && \ curl -fSL --connect-timeout 10 --retry 3 --retry-delay 2 \ -o /artifacts/initrd "${NETBOOT_BASE}/initrd"; \ elif [ "${TARGETARCH}" = "arm64" ]; then \ - NETBOOT_URL="https://cdimage.ubuntu.com/releases/noble/release/ubuntu-24.04.4-netboot-arm64.tar.gz"; \ + NETBOOT_BASE="https://cdimage.ubuntu.com/ubuntu/releases/noble/release/netboot/arm64"; \ curl -fSL --connect-timeout 10 --retry 3 --retry-delay 2 \ - -o /tmp/netboot.tar.gz "${NETBOOT_URL}" && \ - tar -xzf /tmp/netboot.tar.gz -C /tmp && \ - cp /tmp/arm64/bootaa64.efi /artifacts/shimaa64.efi && \ - cp /tmp/arm64/grubaa64.efi /artifacts/grubaa64.efi && \ - cp /tmp/arm64/initrd /artifacts/initrd && \ - rm -rf /tmp/netboot.tar.gz /tmp/arm64; \ + -o /artifacts/shimaa64.efi "${NETBOOT_BASE}/bootaa64.efi" && \ + curl -fSL --connect-timeout 10 --retry 3 --retry-delay 2 \ + -o /artifacts/grubaa64.efi "${NETBOOT_BASE}/grubaa64.efi" && \ + curl -fSL --connect-timeout 10 --retry 3 --retry-delay 2 \ + -o /artifacts/initrd "${NETBOOT_BASE}/initrd"; \ else \ echo "Unsupported architecture: ${TARGETARCH}" >&2; exit 1; \ fi diff --git a/internal/fsutil/fsutil.go b/internal/fsutil/fsutil.go index ae1913aca8..04d312b7a3 100644 --- a/internal/fsutil/fsutil.go +++ b/internal/fsutil/fsutil.go @@ -8,13 +8,11 @@ package fsutil import ( "crypto/sha256" "errors" - "fmt" "io" "os" "path/filepath" "github.com/google/renameio/v2" - "golang.org/x/sys/unix" ) // SyncDir persists a directory entry so newly written names survive a crash. @@ -98,53 +96,6 @@ func InstallFile(source, target string, mode os.FileMode) (err error) { return pending.CloseAtomicallyReplace() } -// SyncFilesystems persists every filesystem backing the given paths. -func SyncFilesystems(paths ...string) error { - var files []*os.File - - defer func() { - for _, f := range files { - _ = f.Close() //nolint:errcheck // Read-only handle; sync errors are returned. - } - }() - - for _, path := range paths { - f, err := os.Open(path) - if err != nil { - return err - } - - files = append(files, f) - } - - return SyncOpenFilesystems(files, unix.Syncfs) -} - -// SyncOpenFilesystems synchronizes each distinct filesystem once, using open -// handles that stay valid after teardown removes their paths. -func SyncOpenFilesystems(files []*os.File, syncfs func(int) error) error { - seen := map[uint64]bool{} - - for _, f := range files { - var stat unix.Stat_t - if err := unix.Fstat(int(f.Fd()), &stat); err != nil { - return err - } - - if seen[uint64(stat.Dev)] { - continue - } - - seen[uint64(stat.Dev)] = true - - if err := syncfs(int(f.Fd())); err != nil { - return fmt.Errorf("sync %s: %w", f.Name(), err) - } - } - - return nil -} - // FileSHA256 returns the SHA-256 digest of the file at path. func FileSHA256(path string) ([sha256.Size]byte, error) { var digest [sha256.Size]byte @@ -164,14 +115,3 @@ func FileSHA256(path string) ([sha256.Size]byte, error) { return digest, nil } - -// MountedNoexec reports whether the filesystem holding path is mounted without -// permission to run programs from it, so a binary placed there cannot run. -func MountedNoexec(path string) (bool, error) { - var fs unix.Statfs_t - if err := unix.Statfs(path, &fs); err != nil { - return false, fmt.Errorf("inspect the filesystem of %s: %w", path, err) - } - - return fs.Flags&unix.ST_NOEXEC != 0, nil -} diff --git a/internal/fsutil/fsutil_linux.go b/internal/fsutil/fsutil_linux.go new file mode 100644 index 0000000000..dbfa3de193 --- /dev/null +++ b/internal/fsutil/fsutil_linux.go @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// SPDX-License-Identifier: Apache-2.0 + +//go:build linux + +package fsutil + +import ( + "fmt" + "os" + + "golang.org/x/sys/unix" +) + +// SyncFilesystems persists every filesystem backing the given paths. +func SyncFilesystems(paths ...string) error { + var files []*os.File + + defer func() { + for _, f := range files { + _ = f.Close() //nolint:errcheck // Read-only handle; sync errors are returned. + } + }() + + for _, path := range paths { + f, err := os.Open(path) + if err != nil { + return err + } + + files = append(files, f) + } + + return SyncOpenFilesystems(files, unix.Syncfs) +} + +// SyncOpenFilesystems synchronizes each distinct filesystem once, using open +// handles that stay valid after teardown removes their paths. +func SyncOpenFilesystems(files []*os.File, syncfs func(int) error) error { + seen := map[uint64]bool{} + + for _, f := range files { + var stat unix.Stat_t + if err := unix.Fstat(int(f.Fd()), &stat); err != nil { + return err + } + + if seen[uint64(stat.Dev)] { + continue + } + + seen[uint64(stat.Dev)] = true + + if err := syncfs(int(f.Fd())); err != nil { + return fmt.Errorf("sync %s: %w", f.Name(), err) + } + } + + return nil +} + +// MountedNoexec reports whether the filesystem holding path is mounted without +// permission to run programs from it, so a binary placed there cannot run. +func MountedNoexec(path string) (bool, error) { + var fs unix.Statfs_t + if err := unix.Statfs(path, &fs); err != nil { + return false, fmt.Errorf("inspect the filesystem of %s: %w", path, err) + } + + return fs.Flags&unix.ST_NOEXEC != 0, nil +} diff --git a/internal/fsutil/fsutil_other.go b/internal/fsutil/fsutil_other.go new file mode 100644 index 0000000000..7e971ba248 --- /dev/null +++ b/internal/fsutil/fsutil_other.go @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// SPDX-License-Identifier: Apache-2.0 + +//go:build !linux + +package fsutil + +import ( + "errors" + "fmt" + "os" +) + +var errLinuxOnly = errors.New("operation requires Linux") + +// SyncFilesystems is unsupported outside Linux. +func SyncFilesystems(...string) error { + return errLinuxOnly +} + +// SyncOpenFilesystems is unsupported outside Linux. +func SyncOpenFilesystems([]*os.File, func(int) error) error { + return errLinuxOnly +} + +// MountedNoexec is unsupported outside Linux. +func MountedNoexec(path string) (bool, error) { + return false, fmt.Errorf("inspect the filesystem of %s: %w", path, errLinuxOnly) +}