Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions admin-ui/src/utils/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,8 @@
"PlatformWindows": {
"One": "Windows 64-bit"
},
"PlatformLinuxUnavailable": {
"One": "Linux x64 — experimental / unavailable (PR11)"
"PlatformLinux": {
"One": "Linux x64"
},
"PlatformAndroidUnavailable": {
"One": "Android arm64 — experimental / unavailable (PR11)"
Expand Down
4 changes: 2 additions & 2 deletions admin-ui/src/utils/i18n/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,8 @@
"PlatformWindows": {
"One": "Windows 64-bit"
},
"PlatformLinuxUnavailable": {
"One": "Linux x64 — экспериментальная / недоступна (PR11)"
"PlatformLinux": {
"One": "Linux x64"
},
"PlatformAndroidUnavailable": {
"One": "Android arm64 — экспериментальная / недоступна (PR11)"
Expand Down
4 changes: 2 additions & 2 deletions admin-ui/src/utils/i18n/zh_CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,8 @@
"PlatformWindows": {
"One": "Windows 64 位"
},
"PlatformLinuxUnavailable": {
"One": "Linux x64 — 实验性 / 暂不可用(PR11)"
"PlatformLinux": {
"One": "Linux x64"
},
"PlatformAndroidUnavailable": {
"One": "Android arm64 — 实验性 / 暂不可用(PR11)"
Expand Down
14 changes: 7 additions & 7 deletions admin-ui/src/views/custom-client/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@
<el-col :span="8">
<el-form-item :label="T('Platform')" prop="platform">
<!--
Windows x64 is validated end-to-end (GitHub Actions). Linux/Android remain
typed legacy values, but are unavailable for production builds pending PR11
evidence. 32-bit Windows and macOS are not supported (PLAN.md §8.15).
Windows x64 and Linux x64 are validated end-to-end (GitHub Actions run
evidence). Android remains unavailable pending validation. 32-bit Windows
and macOS are not supported (PLAN.md §8.15).
-->
<el-tooltip :content="requiredMessage('platform')" :disabled="!isFieldInvalid('platform')" placement="top" :trigger="['hover', 'focus']" :trigger-keys="[]">
<el-select
Expand All @@ -50,7 +50,7 @@
@change="onPlatformChange"
>
<el-option :label="T('PlatformWindows')" value="windows" />
<el-option :label="T('PlatformLinuxUnavailable')" value="linux" disabled />
<el-option :label="T('PlatformLinux')" value="linux" />
<el-option :label="T('PlatformAndroidUnavailable')" value="android" disabled />
</el-select>
</el-tooltip>
Expand Down Expand Up @@ -711,9 +711,9 @@ export default defineComponent({
// - submitBuild и StartBuild были заблокированы для ВСЕХ не-ready состояний.
const versionsState = ref('loading')
const versionsReady = computed(() => versionsState.value === 'ready')
// Linux/Android remain valid persisted enum values for legacy presets, but
// the backend production capability gate keeps them unavailable until PR11.
const productionPlatformReady = computed(() => form.platform === 'windows')
// Android remains a valid persisted enum value for legacy presets, but the
// backend production capability gate keeps it unavailable until validated.
const productionPlatformReady = computed(() => form.platform === 'windows' || form.platform === 'linux')
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
Expand Down
44 changes: 30 additions & 14 deletions api/http/controller/admin/streaming_artifact_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -406,21 +406,37 @@ func TestPublishDownloadedArtifactRejectsUnprovenExistingOutputAndPreservesSibli
_ = root
}

func TestPublishDownloadedArtifactRejectsUnvalidatedLinuxAndroidCapability(t *testing.T) {
for _, platform := range []string{"linux", "android"} {
t.Run(platform, func(t *testing.T) {
root := withTestOutputRoot(t)
build := &model.CustomBuild{IdModel: model.IdModel{Id: 100}, Platform: platform, AppName: "rustqs"}
archive := makeArtifactZip(t, map[string]string{"nested/binary": "binary", "custom_.txt": "settings"})
if _, err := publishDownloadedArtifact(build, archive); err == nil || !strings.Contains(err.Error(), "production capability") {
t.Fatalf("publishDownloadedArtifact() error = %v, want explicit capability-unavailable error", err)
}
outDir := filepath.Join(root, "output", "100")
if _, err := os.Stat(outDir); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("unvalidated platform output stat = %v, want absent", err)
}
func TestPublishDownloadedArtifactCapabilityGateRejectsAndroidOnly(t *testing.T) {
t.Run("android", func(t *testing.T) {
root := withTestOutputRoot(t)
build := &model.CustomBuild{IdModel: model.IdModel{Id: 100}, Platform: "android", AppName: "rustqs"}
archive := makeArtifactZip(t, map[string]string{"nested/binary": "binary", "custom_.txt": "settings"})
if _, err := publishDownloadedArtifact(build, archive); err == nil || !strings.Contains(err.Error(), "production capability") {
t.Fatalf("publishDownloadedArtifact() error = %v, want explicit capability-unavailable error", err)
}
outDir := filepath.Join(root, "output", "100")
if _, err := os.Stat(outDir); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("unvalidated platform output stat = %v, want absent", err)
}
})
t.Run("linux", func(t *testing.T) {
root := withTestOutputRoot(t)
build := &model.CustomBuild{IdModel: model.IdModel{Id: 101}, Platform: "linux", AppName: "rustqs"}
archive := makeArtifactZip(t, map[string]string{
"rustqs-1.2.3.deb": "deb",
"rustqs-1.2.3-0.x86_64.rpm": "rpm",
"custom_.txt": "settings",
})
}
if _, err := publishDownloadedArtifact(build, archive); err != nil {
t.Fatalf("publishDownloadedArtifact() linux error = %v, want published output", err)
}
outDir := filepath.Join(root, "output", "101")
for _, name := range []string{"rustqs-1.2.3.deb", "rustqs-1.2.3-0.x86_64.rpm", "custom_.txt"} {
if _, err := os.Stat(filepath.Join(outDir, name)); err != nil {
t.Fatalf("published linux output %s stat = %v", name, err)
}
}
})
}

func TestDownloadByKeyBuildsArchiveBeforeSendingSuccessHeaders(t *testing.T) {
Expand Down
71 changes: 39 additions & 32 deletions api/service/build_provenance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,41 +97,48 @@ func TestCompletedBuildProvenanceRequiresDoneAndPublicationMarker(t *testing.T)
}
}

func TestValidateCompletedPublishedOutputRequiresProductionCapability(t *testing.T) {
func TestValidateCompletedPublishedOutputKeepsAndroidCapabilityGated(t *testing.T) {
provenance := testBuildProvenance(202, "owner/repo", "workflow.yml", "refs/heads/ref", "artifact")
provenance.GithubArtifactID = 42
for _, platform := range []string{string(PlatformLinux), string(PlatformAndroid)} {
t.Run(platform, func(t *testing.T) {
build := &model.CustomBuild{
Status: model.CustomBuildStatusDone,
Platform: platform,
Version: provenance.Version,
BuildRef: provenance.BuildRef,
SourceTag: provenance.SourceTag,
AssetsRelease: provenance.AssetsRelease,
AssetsReleaseID: provenance.AssetsReleaseID,
GithubProvider: provenance.GithubProvider,
GithubRepo: provenance.GithubRepo,
GithubWorkflow: provenance.GithubWorkflow,
GithubRef: provenance.GithubRef,
GithubArtifactName: provenance.GithubArtifactName,
GithubArtifactID: provenance.GithubArtifactID,
GithubRunId: provenance.GithubRunID,
GithubRunUrl: provenance.GithubRunURL,
GithubHtmlUrl: provenance.GithubHTMLURL,
PublicationRecordedAt: 1,
PublishedDigest: strings.Repeat("a", 64),
}
_, _, err := ValidateCompletedPublishedOutput(build)
var unavailable *ProductionCapabilityUnavailableError
if !errors.As(err, &unavailable) {
t.Fatalf("ValidateCompletedPublishedOutput() error = %T %v, want capability-unavailable error", err, err)
}
if unavailable.Platform != platform {
t.Fatalf("capability error platform = %q, want %q", unavailable.Platform, platform)
}
})
buildForPlatform := func(platform string) *model.CustomBuild {
return &model.CustomBuild{
Status: model.CustomBuildStatusDone,
Platform: platform,
Version: provenance.Version,
BuildRef: provenance.BuildRef,
SourceTag: provenance.SourceTag,
AssetsRelease: provenance.AssetsRelease,
AssetsReleaseID: provenance.AssetsReleaseID,
GithubProvider: provenance.GithubProvider,
GithubRepo: provenance.GithubRepo,
GithubWorkflow: provenance.GithubWorkflow,
GithubRef: provenance.GithubRef,
GithubArtifactName: provenance.GithubArtifactName,
GithubArtifactID: provenance.GithubArtifactID,
GithubRunId: provenance.GithubRunID,
GithubRunUrl: provenance.GithubRunURL,
GithubHtmlUrl: provenance.GithubHTMLURL,
PublicationRecordedAt: 1,
PublishedDigest: strings.Repeat("a", 64),
}
}
if _, _, err := ValidateCompletedPublishedOutput(buildForPlatform(string(PlatformLinux))); err != nil {
var unavailable *ProductionCapabilityUnavailableError
if errors.As(err, &unavailable) {
t.Fatalf("ValidateCompletedPublishedOutput() rejected Linux as capability-unavailable: %v", err)
}
}
t.Run(string(PlatformAndroid), func(t *testing.T) {
build := buildForPlatform(string(PlatformAndroid))
_, _, err := ValidateCompletedPublishedOutput(build)
var unavailable *ProductionCapabilityUnavailableError
if !errors.As(err, &unavailable) {
t.Fatalf("ValidateCompletedPublishedOutput() error = %T %v, want capability-unavailable error", err, err)
}
if unavailable.Platform != string(PlatformAndroid) {
t.Fatalf("capability error platform = %q, want %q", unavailable.Platform, string(PlatformAndroid))
}
})
}

func TestCreateNormalizedWithIdentityPersistsWriteOnceVersionFields(t *testing.T) {
Expand Down
83 changes: 56 additions & 27 deletions api/service/custom_persistence_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1459,31 +1459,52 @@ func TestCustomBuildProgressSupportsDownloadLifecycleStatesWithRunGuard(t *testi
}
}

func TestCustomBuildProgressCannotTransitionLinuxOrAndroidToDone(t *testing.T) {
func TestCustomBuildProgressCannotTransitionAndroidToDone(t *testing.T) {
db := newCustomPersistenceDB(t)
for _, platform := range []string{"linux", "android"} {
t.Run(platform, func(t *testing.T) {
build := &model.CustomBuild{Status: model.CustomBuildStatusExtracting, Platform: platform, GithubRunId: 909, GithubArtifactID: 42}
if err := db.Create(build).Error; err != nil {
t.Fatalf("create build: %v", err)
}
err := (&CustomBuildService{}).UpdateProgress(BuildProgress{
BuildID: build.Id, ExpectedRunID: build.GithubRunId,
ExpectedArtifactID: build.GithubArtifactID,
Status: model.CustomBuildStatusDone, BuildLog: "must remain non-done",
})
var unavailable *ProductionCapabilityUnavailableError
if !errors.As(err, &unavailable) {
t.Fatalf("UpdateProgress() error = %T %v, want capability-unavailable error", err, err)
}
var stored model.CustomBuild
if err := db.First(&stored, build.Id).Error; err != nil {
t.Fatalf("read build: %v", err)
}
if stored.Status != model.CustomBuildStatusExtracting {
t.Fatalf("status after rejected completion = %q, want extracting", stored.Status)
}
})
build := &model.CustomBuild{Status: model.CustomBuildStatusExtracting, Platform: "android", GithubRunId: 909, GithubArtifactID: 42}
if err := db.Create(build).Error; err != nil {
t.Fatalf("create build: %v", err)
}
err := (&CustomBuildService{}).UpdateProgress(BuildProgress{
BuildID: build.Id, ExpectedRunID: build.GithubRunId,
ExpectedArtifactID: build.GithubArtifactID,
Status: model.CustomBuildStatusDone, BuildLog: "must remain non-done",
})
var unavailable *ProductionCapabilityUnavailableError
if !errors.As(err, &unavailable) {
t.Fatalf("UpdateProgress() error = %T %v, want capability-unavailable error", err, err)
}
var stored model.CustomBuild
if err := db.First(&stored, build.Id).Error; err != nil {
t.Fatalf("read build: %v", err)
}
if stored.Status != model.CustomBuildStatusExtracting {
t.Fatalf("status after rejected completion = %q, want extracting", stored.Status)
}
}

func TestCustomBuildProgressAllowsLinuxCompletion(t *testing.T) {
db := newCustomPersistenceDB(t)
build := &model.CustomBuild{Status: model.CustomBuildStatusExtracting, Platform: "linux", AppName: "rustqs", Version: "1.2.3", GithubRunId: 909, GithubArtifactID: 42}
if err := db.Create(build).Error; err != nil {
t.Fatalf("create build: %v", err)
}
recordValidPublication(t, build)
if err := (&CustomBuildService{}).UpdateProgress(BuildProgress{
BuildID: build.Id,
ExpectedRunID: build.GithubRunId,
ExpectedArtifactID: build.GithubArtifactID,
Status: model.CustomBuildStatusDone,
BuildLog: "completed",
}); err != nil {
t.Fatalf("UpdateProgress() linux completion error = %v", err)
}
var stored model.CustomBuild
if err := db.First(&stored, build.Id).Error; err != nil {
t.Fatalf("read build: %v", err)
}
if stored.Status != model.CustomBuildStatusDone {
t.Fatalf("status after linux completion = %q, want done", stored.Status)
}
}

Expand Down Expand Up @@ -1854,10 +1875,18 @@ func recordValidPublication(t *testing.T, build *model.CustomBuild) {
if appName == "" {
appName = "rustqs"
}
if err := os.WriteFile(filepath.Join(outDir, appName+".exe"), []byte("published"), 0600); err != nil {
t.Fatalf("write published output: %v", err)
names, err := ExpectedProducerOutputFilenames(build.Platform, appName, build.Version)
if err != nil {
t.Fatalf("resolve producer output names: %v", err)
}
contents := make(map[string]string, len(names))
for _, name := range names {
if err := os.WriteFile(filepath.Join(outDir, name), []byte("published"), 0600); err != nil {
t.Fatalf("write published output %q: %v", name, err)
}
contents[name] = "published"
}
producerManifest := producerManifestForBuild(build, map[string]string{appName + ".exe": "published"})
producerManifest := producerManifestForBuild(build, contents)
if err := (&CustomBuildService{}).RecordPublishedOutput(build.Id, build.GithubRunId, build.GithubArtifactID, producerManifest); err != nil {
t.Fatalf("RecordPublishedOutput() error = %v", err)
}
Expand Down
12 changes: 4 additions & 8 deletions api/service/github_build_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -520,14 +520,10 @@ func TestDispatchBuildRejectsUnvalidatedPlatformsAtServiceBoundary(t *testing.T)
t.Fatal("unexpected provider request for unavailable platform")
return nil, nil
}))
for _, platform := range []string{string(PlatformLinux), string(PlatformAndroid)} {
t.Run(platform, func(t *testing.T) {
result, err := (&GithubBuildConfigService{}).DispatchBuild(context.Background(), githubConfig(), githubVersionIdentity(), platform, nil)
var unavailable *ProductionCapabilityUnavailableError
if result != nil || !errors.As(err, &unavailable) {
t.Fatalf("DispatchBuild(%q) = %#v, %T %v; want capability rejection", platform, result, err, err)
}
})
result, err := (&GithubBuildConfigService{}).DispatchBuild(context.Background(), githubConfig(), githubVersionIdentity(), string(PlatformAndroid), nil)
var unavailable *ProductionCapabilityUnavailableError
if result != nil || !errors.As(err, &unavailable) {
t.Fatalf("DispatchBuild(%q) = %#v, %T %v; want capability rejection", string(PlatformAndroid), result, err, err)
}
}

Expand Down
12 changes: 7 additions & 5 deletions api/service/workflow_mapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -1715,7 +1715,7 @@ func (e *GithubProviderConfigurationError) Unwrap() error { return e.Cause }

// ProductionCapabilityUnavailableError means the platform is valid in the
// typed custom-build domain but its production completion path is not yet
// validated. PR11 owns re-enabling additional platforms after evidence.
// validated.
type ProductionCapabilityUnavailableError struct {
Platform string
Capability string
Expand All @@ -1726,14 +1726,16 @@ func (e *ProductionCapabilityUnavailableError) Error() string {
}

// RequireProductionBuildCapability is the single backend capability gate for
// production build execution and completion. Linux and Android remain valid
// enum values for typed settings/preset data, but cannot enter this provider
// path until PR11 validates and explicitly re-enables them.
// production build execution and completion. Windows x64 and Linux x64 are
// enabled after live GitHub Actions evidence (runs 34932397075 / 35031534442).
// Android remains a valid enum value for typed settings/preset data, but
// cannot enter this provider path until it is validated and explicitly
// re-enabled.
func RequireProductionBuildCapability(platform string) error {
if err := ValidateCustomPlatform(platform); err != nil {
return err
}
if platform != string(PlatformWindows) {
if platform != string(PlatformWindows) && platform != string(PlatformLinux) {
return &ProductionCapabilityUnavailableError{
Platform: platform,
Capability: "production build execution and completion",
Expand Down
Loading