diff --git a/admin-ui/src/utils/i18n/en.json b/admin-ui/src/utils/i18n/en.json index 0aa06eb..2ff75b5 100644 --- a/admin-ui/src/utils/i18n/en.json +++ b/admin-ui/src/utils/i18n/en.json @@ -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)" diff --git a/admin-ui/src/utils/i18n/ru.json b/admin-ui/src/utils/i18n/ru.json index 367a5e4..da6b45d 100644 --- a/admin-ui/src/utils/i18n/ru.json +++ b/admin-ui/src/utils/i18n/ru.json @@ -104,8 +104,8 @@ "PlatformWindows": { "One": "Windows 64-bit" }, - "PlatformLinuxUnavailable": { - "One": "Linux x64 — экспериментальная / недоступна (PR11)" + "PlatformLinux": { + "One": "Linux x64" }, "PlatformAndroidUnavailable": { "One": "Android arm64 — экспериментальная / недоступна (PR11)" diff --git a/admin-ui/src/utils/i18n/zh_CN.json b/admin-ui/src/utils/i18n/zh_CN.json index 231e685..fe9e4a9 100644 --- a/admin-ui/src/utils/i18n/zh_CN.json +++ b/admin-ui/src/utils/i18n/zh_CN.json @@ -104,8 +104,8 @@ "PlatformWindows": { "One": "Windows 64 位" }, - "PlatformLinuxUnavailable": { - "One": "Linux x64 — 实验性 / 暂不可用(PR11)" + "PlatformLinux": { + "One": "Linux x64" }, "PlatformAndroidUnavailable": { "One": "Android arm64 — 实验性 / 暂不可用(PR11)" diff --git a/admin-ui/src/views/custom-client/index.vue b/admin-ui/src/views/custom-client/index.vue index 7cc3050..dee3dbc 100644 --- a/admin-ui/src/views/custom-client/index.vue +++ b/admin-ui/src/views/custom-client/index.vue @@ -34,9 +34,9 @@ - + @@ -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) diff --git a/api/http/controller/admin/streaming_artifact_test.go b/api/http/controller/admin/streaming_artifact_test.go index 0101903..3d47642 100644 --- a/api/http/controller/admin/streaming_artifact_test.go +++ b/api/http/controller/admin/streaming_artifact_test.go @@ -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) { diff --git a/api/service/build_provenance_test.go b/api/service/build_provenance_test.go index f25381c..5070001 100644 --- a/api/service/build_provenance_test.go +++ b/api/service/build_provenance_test.go @@ -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) { diff --git a/api/service/custom_persistence_test.go b/api/service/custom_persistence_test.go index 0cea177..9bb9d51 100644 --- a/api/service/custom_persistence_test.go +++ b/api/service/custom_persistence_test.go @@ -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) } } @@ -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) } diff --git a/api/service/github_build_config_test.go b/api/service/github_build_config_test.go index f539f6c..2438528 100644 --- a/api/service/github_build_config_test.go +++ b/api/service/github_build_config_test.go @@ -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) } } diff --git a/api/service/workflow_mapping.go b/api/service/workflow_mapping.go index 8678a4a..f9835a4 100644 --- a/api/service/workflow_mapping.go +++ b/api/service/workflow_mapping.go @@ -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 @@ -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", diff --git a/api/service/workflow_mapping_test.go b/api/service/workflow_mapping_test.go index 57cf28a..6fa7368 100644 --- a/api/service/workflow_mapping_test.go +++ b/api/service/workflow_mapping_test.go @@ -1582,24 +1582,30 @@ func TestGithubBuildConfigGetNormalizesKnownLegacyBranchAtConfigBoundary(t *test } } -func TestRequireProductionBuildCapabilityKeepsUnvalidatedPlatformsFailClosed(t *testing.T) { - for _, platform := range []string{string(PlatformLinux), string(PlatformAndroid)} { +func TestRequireProductionBuildCapabilityEnablesWindowsAndLinuxOnly(t *testing.T) { + for _, platform := range []string{string(PlatformWindows), string(PlatformLinux)} { t.Run(platform, func(t *testing.T) { - err := RequireProductionBuildCapability(platform) - var unavailable *ProductionCapabilityUnavailableError - if !errors.As(err, &unavailable) { - t.Fatalf("RequireProductionBuildCapability(%q) = %T %v, want explicit capability-unavailable error", platform, err, err) - } - if unavailable.Platform != platform || unavailable.Capability == "" { - t.Fatalf("capability error = %#v, want platform and capability", unavailable) + if err := RequireProductionBuildCapability(platform); err != nil { + t.Fatalf("RequireProductionBuildCapability(%q) = %v, want available", platform, err) } }) } - if err := RequireProductionBuildCapability(string(PlatformWindows)); err != nil { - t.Fatalf("Windows production capability = %v, want available", err) - } - if err := RequireProductionBuildCapability(string(PlatformLinux)); err == nil { - t.Fatal("Linux production capability error = nil") + t.Run(string(PlatformAndroid), func(t *testing.T) { + err := RequireProductionBuildCapability(string(PlatformAndroid)) + var unavailable *ProductionCapabilityUnavailableError + if !errors.As(err, &unavailable) { + t.Fatalf("RequireProductionBuildCapability(%q) = %T %v, want explicit capability-unavailable error", string(PlatformAndroid), err, err) + } + if unavailable.Platform != string(PlatformAndroid) || unavailable.Capability == "" { + t.Fatalf("capability error = %#v, want platform and capability", unavailable) + } + }) + for _, platform := range []string{"macos", "plan9", ""} { + t.Run("invalid-"+platform, func(t *testing.T) { + if err := RequireProductionBuildCapability(platform); err == nil { + t.Fatalf("RequireProductionBuildCapability(%q) error = nil, want invalid platform rejection", platform) + } + }) } } @@ -1636,7 +1642,7 @@ func TestRequireDispatchPublicKeyRejectsMalformedMaterial(t *testing.T) { } } -func TestPrepareBuildRejectsUnvalidatedPlatformBeforeProviderOrBuildRow(t *testing.T) { +func TestPrepareBuildRejectsUnvalidatedAndroidBeforeProviderOrBuildRow(t *testing.T) { db, sqlDB := newGithubConfigTestDB(t) previousDB := DB DB = db @@ -1649,7 +1655,7 @@ func TestPrepareBuildRejectsUnvalidatedPlatformBeforeProviderOrBuildRow(t *testi return nil, nil })) - _, err := (&GithubBuildConfigService{}).PrepareBuild(context.Background(), string(PlatformLinux), "1.2.3") + _, err := (&GithubBuildConfigService{}).PrepareBuild(context.Background(), string(PlatformAndroid), "1.2.3") var unavailable *ProductionCapabilityUnavailableError if !errors.As(err, &unavailable) { t.Fatalf("PrepareBuild() error = %T %v, want explicit capability-unavailable error", err, err)