From d1f74299d54269552d1c15dc712f1b2d5846ef3b Mon Sep 17 00:00:00 2001 From: Nick Date: Sat, 5 Sep 2026 03:51:48 +0300 Subject: [PATCH 01/12] feat: add custom repository and default branch to MainConfig --- src/main/java/io/bookwright/config/MainConfig.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/java/io/bookwright/config/MainConfig.java b/src/main/java/io/bookwright/config/MainConfig.java index fadacc6..e0b50a3 100644 --- a/src/main/java/io/bookwright/config/MainConfig.java +++ b/src/main/java/io/bookwright/config/MainConfig.java @@ -55,4 +55,12 @@ public interface MainConfig extends Config { @Key("teardown.failOnError") @DefaultValue("true") boolean teardownFailOnError(); + + @Key("git.fixtures.repository") + @DefaultVlaue("https://github.com/semaphoreui/integration-tests.git") + String() fixturesRepository(); + + @Key("git.fixtures.branch") + @DefaultVlaue("main") + String() fixturesDefaultBranch(); } From fc3a5e6da50a29c5e049d0055c1269970ae0db64 Mon Sep 17 00:00:00 2001 From: Nick Date: Sat, 5 Sep 2026 03:57:38 +0300 Subject: [PATCH 02/12] fix: change fixtures repository and default branch Change fixtures repository and defalt branch in fixtures --- .../fixtures/semaphore/SemaphoreBuildDeployFixtures.java | 2 +- .../semaphore/SemaphoreEncryptionRotationFixtures.java | 2 +- .../fixtures/semaphore/SemaphoreFileInventoryFixtures.java | 2 +- .../io/bookwright/fixtures/semaphore/SemaphoreFixtures.java | 6 +++--- .../semaphore/SemaphoreProjectDeletionFixtures.java | 2 +- .../fixtures/semaphore/SemaphoreShellOutputFixtures.java | 2 +- .../semaphore/SemaphoreStaticInventoryFixtures.java | 2 +- .../fixtures/semaphore/SemaphoreTerraformFixtures.java | 2 +- .../fixtures/semaphore/SemaphoreUpgradeFixtures.java | 2 +- 9 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreBuildDeployFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreBuildDeployFixtures.java index 2af6dc7..0293e59 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreBuildDeployFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreBuildDeployFixtures.java @@ -24,7 +24,7 @@ public static SemaphoreBuildDeployFixtures from(TestData data) { new ProjectRequest("bookwright-build-deploy-" + suffix, false, 0), new AccessKey("bookwright-build-deploy-key-" + suffix, "none"), new Repository( - "bookwright-build-deploy-repository-" + suffix, "file:///fixtures/ansible", "main"), + "bookwright-build-deploy-repository-" + suffix, fixturesRepository(), fixturesDefaultBranch()), new Inventory( "bookwright-build-deploy-inventory-" + suffix, "[local]\nlocalhost ansible_connection=local", diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreEncryptionRotationFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreEncryptionRotationFixtures.java index 46c3bd4..6444595 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreEncryptionRotationFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreEncryptionRotationFixtures.java @@ -35,7 +35,7 @@ public static SemaphoreEncryptionRotationFixtures standard() { "login_password", "bookwright-post-rekey-user", "Bookwright-post-rekey-password-42!"), - new Repository("bookwright-encryption-repository", "file:///fixtures/ansible", "main"), + new Repository("bookwright-encryption-repository", fixturesRepository(), fixturesDefaultBranch()), new Inventory( "bookwright-encryption-inventory", "[local]\nlocalhost ansible_connection=local", diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFileInventoryFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFileInventoryFixtures.java index 6ab4d61..1a7bab4 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFileInventoryFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFileInventoryFixtures.java @@ -25,7 +25,7 @@ public static SemaphoreFileInventoryFixtures from(TestData data) { new ProjectRequest("bookwright-file-inventory-" + suffix, false, 0), new AccessKey("bookwright-file-inventory-key-" + suffix, "none"), new Repository( - "bookwright-file-inventory-repository-" + suffix, "file:///fixtures/ansible", "main"), + "bookwright-file-inventory-repository-" + suffix, fixturesRepository(), fixturesDefaultBranch()), new FileInventory( "bookwright-file-inventory-" + suffix, "inventories/localhost.ini", "file"), new FileInventory( diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFixtures.java index eceb858..fdff5d5 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFixtures.java @@ -44,14 +44,14 @@ public static SemaphoreFixtures from(MainConfig config, TestData data) { "Bw-secret-" + suffix + "-42!"), new Repositories( new Repository( - "bookwright-demo-repository-" + suffix, "file:///fixtures/ansible", "main"), + "bookwright-demo-repository-" + suffix, fixturesRepository(), fixturesDefaultBranch()), new Repository( "bookwright-ref-repository-" + suffix, - "file:///fixtures/ansible", + fixturesRepository(), "bookwright-fixture-ref"), new Repository( "bookwright-missing-ref-repository-" + suffix, - "file:///fixtures/ansible", + fixturesRepository(), "bookwright-missing-ref"), new Repository( "bookwright-unavailable-repository-" + suffix, diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreProjectDeletionFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreProjectDeletionFixtures.java index 7e717c6..a41405b 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreProjectDeletionFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreProjectDeletionFixtures.java @@ -23,7 +23,7 @@ public static SemaphoreProjectDeletionFixtures from(TestData data) { new ProjectRequest("bookwright-project-delete-" + suffix, false, 0), new AccessKey("bookwright-project-delete-key-" + suffix, "none"), new Repository( - "bookwright-project-delete-repository-" + suffix, "file:///fixtures/ansible", "main"), + "bookwright-project-delete-repository-" + suffix, fixturesRepository(), fixturesDefaultBranch()), new Inventory( "bookwright-project-delete-inventory-" + suffix, "[local]\nlocalhost ansible_connection=local", diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreShellOutputFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreShellOutputFixtures.java index b69cfac..e3f1cc0 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreShellOutputFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreShellOutputFixtures.java @@ -23,7 +23,7 @@ public static SemaphoreShellOutputFixtures from(TestData data) { new ProjectRequest("bookwright-shell-output-" + suffix, false, 0), new AccessKey("bookwright-shell-output-key-" + suffix, "none"), new Repository( - "bookwright-shell-output-repository-" + suffix, "file:///fixtures/ansible", "main"), + "bookwright-shell-output-repository-" + suffix, fixturesRepository(), fixturesDefaultBranch()), new Inventory( "bookwright-shell-output-inventory-" + suffix, "[local]\nlocalhost ansible_connection=local", diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreStaticInventoryFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreStaticInventoryFixtures.java index fec24bf..ca44709 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreStaticInventoryFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreStaticInventoryFixtures.java @@ -26,7 +26,7 @@ public static SemaphoreStaticInventoryFixtures from(TestData data) { new ProjectRequest("bookwright-static-inventory-" + suffix, false, 0), new AccessKey("bookwright-static-inventory-key-" + suffix, "none"), new Repository( - "bookwright-static-inventory-repository-" + suffix, "file:///fixtures/ansible", "main"), + "bookwright-static-inventory-repository-" + suffix, fixturesRepository(), fixturesDefaultBranch()), new StaticInventory( "bookwright-ini-inventory-" + suffix, "[bookwright_selected]\n" diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreTerraformFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreTerraformFixtures.java index 1ee9cfa..beda2c9 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreTerraformFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreTerraformFixtures.java @@ -33,7 +33,7 @@ public static SemaphoreTerraformFixtures from(TestData data) { new ProjectRequest("bookwright-terraform-" + suffix, false, 0), new AccessKey("bookwright-terraform-key-" + suffix, "none"), new Repository( - "bookwright-terraform-repository-" + suffix, "file:///fixtures/ansible", "main"), + "bookwright-terraform-repository-" + suffix, fixturesRepository(), fixturesDefaultBranch()), new TerraformVariableGroup( "bookwright-terraform-variables-" + suffix, "TF_VAR_bookwright_secret", diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreUpgradeFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreUpgradeFixtures.java index 743bbf0..e4b32d8 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreUpgradeFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreUpgradeFixtures.java @@ -25,7 +25,7 @@ public static SemaphoreUpgradeFixtures standard() { "login_password", "bookwright-upgrade-user", "Bookwright-upgrade-password-42!"), - new Repository("bookwright-upgrade-repository", "file:///fixtures/ansible", "main"), + new Repository("bookwright-upgrade-repository", fixturesRepository(), fixturesDefaultBranch()), new Inventory( "bookwright-upgrade-inventory", "[local]\nlocalhost ansible_connection=local", From bdea8b03a4f2848dd8e2d44a38d84cd596e63d9b Mon Sep 17 00:00:00 2001 From: Nick Date: Sat, 5 Sep 2026 04:04:04 +0300 Subject: [PATCH 03/12] fix: fix MainConfig fix: fix MainConfig --- src/main/java/io/bookwright/config/MainConfig.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/io/bookwright/config/MainConfig.java b/src/main/java/io/bookwright/config/MainConfig.java index e0b50a3..3f3475a 100644 --- a/src/main/java/io/bookwright/config/MainConfig.java +++ b/src/main/java/io/bookwright/config/MainConfig.java @@ -57,10 +57,10 @@ public interface MainConfig extends Config { boolean teardownFailOnError(); @Key("git.fixtures.repository") - @DefaultVlaue("https://github.com/semaphoreui/integration-tests.git") - String() fixturesRepository(); + @DefaultValue("https://github.com/semaphoreui/integration-tests.git") + String fixturesRepository(); @Key("git.fixtures.branch") - @DefaultVlaue("main") - String() fixturesDefaultBranch(); + @DefaultValue("main") + String fixturesDefaultBranch(); } From b0b6c47fa685a333edaa59bbda1bd8dbd7919f39 Mon Sep 17 00:00:00 2001 From: Nick Date: Sat, 5 Sep 2026 05:23:28 +0300 Subject: [PATCH 04/12] fix: fix fixtures to corret work with remote repository --- .../SemaphoreBuildDeployFixtures.java | 7 +++--- .../SemaphoreEncryptionRotationFixtures.java | 5 +++-- .../SemaphoreFileInventoryFixtures.java | 5 +++-- .../fixtures/semaphore/SemaphoreFixtures.java | 6 ++--- .../SemaphoreProjectDeletionFixtures.java | 5 +++-- .../SemaphoreShellOutputFixtures.java | 5 +++-- .../SemaphoreStaticInventoryFixtures.java | 5 +++-- .../semaphore/SemaphoreTerraformFixtures.java | 5 +++-- .../semaphore/SemaphoreUpgradeFixtures.java | 5 +++-- .../junit/StepsParameterResolver.java | 22 ++++++++++++------- .../framework/FixtureArchitectureTest.java | 4 ++-- 11 files changed, 44 insertions(+), 30 deletions(-) diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreBuildDeployFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreBuildDeployFixtures.java index 0293e59..7b07067 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreBuildDeployFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreBuildDeployFixtures.java @@ -8,6 +8,7 @@ import io.bookwright.api.model.semaphore.TemplateRequest; import io.bookwright.util.TestData; import java.util.List; +import io.bookwright.config.MainConfig; /** Typed data for a manually selected Build to Deploy artifact-version chain. */ public record SemaphoreBuildDeployFixtures( @@ -18,20 +19,20 @@ public record SemaphoreBuildDeployFixtures( BuildTemplate build, DeployTemplate deploy) { - public static SemaphoreBuildDeployFixtures from(TestData data) { + public static SemaphoreBuildDeployFixtures from(MainConfig config, TestData data) { String suffix = Long.toUnsignedString(data.testSeed(), 36); return new SemaphoreBuildDeployFixtures( new ProjectRequest("bookwright-build-deploy-" + suffix, false, 0), new AccessKey("bookwright-build-deploy-key-" + suffix, "none"), new Repository( - "bookwright-build-deploy-repository-" + suffix, fixturesRepository(), fixturesDefaultBranch()), + "bookwright-build-deploy-repository-" + suffix, config.fixturesRepository(), config.fixturesDefaultBranch()), new Inventory( "bookwright-build-deploy-inventory-" + suffix, "[local]\nlocalhost ansible_connection=local", "static"), new BuildTemplate( "bookwright-build-template-" + suffix, - "build-version.yml", + "/test-environment/fixtures/ansible/build-version.yml", "ansible", "build", "1.2.3", diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreEncryptionRotationFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreEncryptionRotationFixtures.java index 6444595..b412f0a 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreEncryptionRotationFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreEncryptionRotationFixtures.java @@ -5,6 +5,7 @@ import io.bookwright.fixtures.semaphore.SemaphoreFixtures.Repository; import io.bookwright.fixtures.semaphore.SemaphoreFixtures.SecretAccessKey; import io.bookwright.fixtures.semaphore.SemaphoreFixtures.Template; +import io.bookwright.config.MainConfig; /** Stable data shared by the phases of a database-encryption key rotation. */ public record SemaphoreEncryptionRotationFixtures( @@ -17,7 +18,7 @@ public record SemaphoreEncryptionRotationFixtures( Template template, String outputMarker) { - public static SemaphoreEncryptionRotationFixtures standard() { + public static SemaphoreEncryptionRotationFixtures from(MainConfig config) { return new SemaphoreEncryptionRotationFixtures( new ProjectRequest("bookwright-encryption-rotation", false, 0), new SecretAccessKey( @@ -35,7 +36,7 @@ public static SemaphoreEncryptionRotationFixtures standard() { "login_password", "bookwright-post-rekey-user", "Bookwright-post-rekey-password-42!"), - new Repository("bookwright-encryption-repository", fixturesRepository(), fixturesDefaultBranch()), + new Repository("bookwright-encryption-repository", config.fixturesRepository(), config.fixturesDefaultBranch()), new Inventory( "bookwright-encryption-inventory", "[local]\nlocalhost ansible_connection=local", diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFileInventoryFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFileInventoryFixtures.java index 1a7bab4..111aa7d 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFileInventoryFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFileInventoryFixtures.java @@ -7,6 +7,7 @@ import io.bookwright.api.model.semaphore.RepositoryRequest; import io.bookwright.api.model.semaphore.TemplateRequest; import io.bookwright.util.TestData; +import io.bookwright.config.MainConfig; /** Typed data for Ansible inventories stored in a Git repository. */ public record SemaphoreFileInventoryFixtures( @@ -19,13 +20,13 @@ public record SemaphoreFileInventoryFixtures( String successfulTaskStatus, String outputMarker) { - public static SemaphoreFileInventoryFixtures from(TestData data) { + public static SemaphoreFileInventoryFixtures from(MainConfig config, TestData data) { String suffix = Long.toUnsignedString(data.testSeed(), 36); return new SemaphoreFileInventoryFixtures( new ProjectRequest("bookwright-file-inventory-" + suffix, false, 0), new AccessKey("bookwright-file-inventory-key-" + suffix, "none"), new Repository( - "bookwright-file-inventory-repository-" + suffix, fixturesRepository(), fixturesDefaultBranch()), + "bookwright-file-inventory-repository-" + suffix, config.fixturesRepository(), config.fixturesDefaultBranch()), new FileInventory( "bookwright-file-inventory-" + suffix, "inventories/localhost.ini", "file"), new FileInventory( diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFixtures.java index fdff5d5..a7c913c 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFixtures.java @@ -44,14 +44,14 @@ public static SemaphoreFixtures from(MainConfig config, TestData data) { "Bw-secret-" + suffix + "-42!"), new Repositories( new Repository( - "bookwright-demo-repository-" + suffix, fixturesRepository(), fixturesDefaultBranch()), + "bookwright-demo-repository-" + suffix, config.fixturesRepository(), config.fixturesDefaultBranch()), new Repository( "bookwright-ref-repository-" + suffix, - fixturesRepository(), + config.fixturesRepository(), "bookwright-fixture-ref"), new Repository( "bookwright-missing-ref-repository-" + suffix, - fixturesRepository(), + config.fixturesRepository(), "bookwright-missing-ref"), new Repository( "bookwright-unavailable-repository-" + suffix, diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreProjectDeletionFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreProjectDeletionFixtures.java index a41405b..199431b 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreProjectDeletionFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreProjectDeletionFixtures.java @@ -6,6 +6,7 @@ import io.bookwright.api.model.semaphore.RepositoryRequest; import io.bookwright.api.model.semaphore.TemplateRequest; import io.bookwright.util.TestData; +import io.bookwright.config.MainConfig; /** Typed data for project deletion with a running or stopped task. */ public record SemaphoreProjectDeletionFixtures( @@ -17,13 +18,13 @@ public record SemaphoreProjectDeletionFixtures( String readyMarker, String stoppedTaskStatus) { - public static SemaphoreProjectDeletionFixtures from(TestData data) { + public static SemaphoreProjectDeletionFixtures from(MainConfig config, TestData data) { String suffix = Long.toUnsignedString(data.testSeed(), 36); return new SemaphoreProjectDeletionFixtures( new ProjectRequest("bookwright-project-delete-" + suffix, false, 0), new AccessKey("bookwright-project-delete-key-" + suffix, "none"), new Repository( - "bookwright-project-delete-repository-" + suffix, fixturesRepository(), fixturesDefaultBranch()), + "bookwright-project-delete-repository-" + suffix, config.fixturesRepository(), config.fixturesDefaultBranch()), new Inventory( "bookwright-project-delete-inventory-" + suffix, "[local]\nlocalhost ansible_connection=local", diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreShellOutputFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreShellOutputFixtures.java index e3f1cc0..2c92f95 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreShellOutputFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreShellOutputFixtures.java @@ -7,6 +7,7 @@ import io.bookwright.api.model.semaphore.TemplateRequest; import io.bookwright.util.TestData; import java.time.Duration; +import io.bookwright.config.MainConfig; /** Typed data and expectations for Bash output capture regressions. */ public record SemaphoreShellOutputFixtures( @@ -17,13 +18,13 @@ public record SemaphoreShellOutputFixtures( Templates templates, Expectations expectations) { - public static SemaphoreShellOutputFixtures from(TestData data) { + public static SemaphoreShellOutputFixtures from(MainConfig config, TestData data) { String suffix = Long.toUnsignedString(data.testSeed(), 36); return new SemaphoreShellOutputFixtures( new ProjectRequest("bookwright-shell-output-" + suffix, false, 0), new AccessKey("bookwright-shell-output-key-" + suffix, "none"), new Repository( - "bookwright-shell-output-repository-" + suffix, fixturesRepository(), fixturesDefaultBranch()), + "bookwright-shell-output-repository-" + suffix, config.fixturesRepository(), config.fixturesDefaultBranch()), new Inventory( "bookwright-shell-output-inventory-" + suffix, "[local]\nlocalhost ansible_connection=local", diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreStaticInventoryFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreStaticInventoryFixtures.java index ca44709..b3dc94c 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreStaticInventoryFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreStaticInventoryFixtures.java @@ -8,6 +8,7 @@ import io.bookwright.api.model.semaphore.TemplateRequest; import io.bookwright.util.TestData; import java.util.List; +import io.bookwright.config.MainConfig; /** Typed data for static inventory group selection during Ansible execution. */ public record SemaphoreStaticInventoryFixtures( @@ -20,13 +21,13 @@ public record SemaphoreStaticInventoryFixtures( Template yamlTemplate, String outputMarker) { - public static SemaphoreStaticInventoryFixtures from(TestData data) { + public static SemaphoreStaticInventoryFixtures from(MainConfig config, TestData data) { String suffix = Long.toUnsignedString(data.testSeed(), 36); return new SemaphoreStaticInventoryFixtures( new ProjectRequest("bookwright-static-inventory-" + suffix, false, 0), new AccessKey("bookwright-static-inventory-key-" + suffix, "none"), new Repository( - "bookwright-static-inventory-repository-" + suffix, fixturesRepository(), fixturesDefaultBranch()), + "bookwright-static-inventory-repository-" + suffix, config.fixturesRepository(), config.fixturesDefaultBranch()), new StaticInventory( "bookwright-ini-inventory-" + suffix, "[bookwright_selected]\n" diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreTerraformFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreTerraformFixtures.java index beda2c9..e185f22 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreTerraformFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreTerraformFixtures.java @@ -16,6 +16,7 @@ import java.security.NoSuchAlgorithmException; import java.util.HexFormat; import java.util.List; +import io.bookwright.config.MainConfig; /** Typed data for Terraform and OpenTofu workspace plan execution. */ public record SemaphoreTerraformFixtures( @@ -27,13 +28,13 @@ public record SemaphoreTerraformFixtures( Tool tofu, String workspaceOutputName) { - public static SemaphoreTerraformFixtures from(TestData data) { + public static SemaphoreTerraformFixtures from(MainConfig config, TestData data) { String suffix = Long.toUnsignedString(data.testSeed(), 36); return new SemaphoreTerraformFixtures( new ProjectRequest("bookwright-terraform-" + suffix, false, 0), new AccessKey("bookwright-terraform-key-" + suffix, "none"), new Repository( - "bookwright-terraform-repository-" + suffix, fixturesRepository(), fixturesDefaultBranch()), + "bookwright-terraform-repository-" + suffix, config.fixturesRepository(), config.fixturesDefaultBranch()), new TerraformVariableGroup( "bookwright-terraform-variables-" + suffix, "TF_VAR_bookwright_secret", diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreUpgradeFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreUpgradeFixtures.java index e4b32d8..82556af 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreUpgradeFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreUpgradeFixtures.java @@ -6,6 +6,7 @@ import io.bookwright.fixtures.semaphore.SemaphoreFixtures.Schedule; import io.bookwright.fixtures.semaphore.SemaphoreFixtures.SecretAccessKey; import io.bookwright.fixtures.semaphore.SemaphoreFixtures.Template; +import io.bookwright.config.MainConfig; /** Stable data shared by the seed and verify processes of an in-place release upgrade. */ public record SemaphoreUpgradeFixtures( @@ -17,7 +18,7 @@ public record SemaphoreUpgradeFixtures( Schedule schedule, String outputMarker) { - public static SemaphoreUpgradeFixtures standard() { + public static SemaphoreUpgradeFixtures from(MainConfig config) { return new SemaphoreUpgradeFixtures( new ProjectRequest("bookwright-release-upgrade", false, 0), new SecretAccessKey( @@ -25,7 +26,7 @@ public static SemaphoreUpgradeFixtures standard() { "login_password", "bookwright-upgrade-user", "Bookwright-upgrade-password-42!"), - new Repository("bookwright-upgrade-repository", fixturesRepository(), fixturesDefaultBranch()), + new Repository("bookwright-upgrade-repository", config.fixturesRepository(), config.fixturesDefaultBranch()), new Inventory( "bookwright-upgrade-inventory", "[local]\nlocalhost ansible_connection=local", diff --git a/src/main/java/io/bookwright/junit/StepsParameterResolver.java b/src/main/java/io/bookwright/junit/StepsParameterResolver.java index 850f727..e1b3d66 100644 --- a/src/main/java/io/bookwright/junit/StepsParameterResolver.java +++ b/src/main/java/io/bookwright/junit/StepsParameterResolver.java @@ -109,13 +109,14 @@ public Object resolveParameter( return HotelDatabaseFixtures.seeded(); } if (type == SemaphoreEncryptionRotationFixtures.class) { - return SemaphoreEncryptionRotationFixtures.standard(); + return SemaphoreEncryptionRotationFixtures.from(io.bookwright.config.Configs.main()); } if (type == SemaphoreBackupFixtures.class) { return SemaphoreBackupFixtures.from(TestDataExtension.getOrCreate(extensionContext)); } if (type == SemaphoreBuildDeployFixtures.class) { - return SemaphoreBuildDeployFixtures.from(TestDataExtension.getOrCreate(extensionContext)); + return SemaphoreBuildDeployFixtures.from( + io.bookwright.config.Configs.main(), TestDataExtension.getOrCreate(extensionContext)); } if (type == SemaphoreConcurrencyFixtures.class) { return SemaphoreConcurrencyFixtures.from(TestDataExtension.getOrCreate(extensionContext)); @@ -125,7 +126,8 @@ public Object resolveParameter( io.bookwright.config.Configs.main(), TestDataExtension.getOrCreate(extensionContext)); } if (type == SemaphoreFileInventoryFixtures.class) { - return SemaphoreFileInventoryFixtures.from(TestDataExtension.getOrCreate(extensionContext)); + return SemaphoreFileInventoryFixtures.from( + io.bookwright.config.Configs.main(), TestDataExtension.getOrCreate(extensionContext)); } if (type == SemaphoreHttpsGitFixtures.class) { return SemaphoreHttpsGitFixtures.from(TestDataExtension.getOrCreate(extensionContext)); @@ -144,7 +146,8 @@ public Object resolveParameter( return SemaphoreOidcFixtures.standard(); } if (type == SemaphoreProjectDeletionFixtures.class) { - return SemaphoreProjectDeletionFixtures.from(TestDataExtension.getOrCreate(extensionContext)); + return SemaphoreProjectDeletionFixtures.from( + io.bookwright.config.Configs.main(), TestDataExtension.getOrCreate(extensionContext)); } if (type == SemaphoreRunnerRoutingFixtures.class) { return SemaphoreRunnerRoutingFixtures.from(TestDataExtension.getOrCreate(extensionContext)); @@ -153,19 +156,22 @@ public Object resolveParameter( return SemaphoreScheduleFixtures.from(TestDataExtension.getOrCreate(extensionContext)); } if (type == SemaphoreShellOutputFixtures.class) { - return SemaphoreShellOutputFixtures.from(TestDataExtension.getOrCreate(extensionContext)); + return SemaphoreShellOutputFixtures.from( + io.bookwright.config.Configs.main(), TestDataExtension.getOrCreate(extensionContext)); } if (type == SemaphoreSshFixtures.class) { return SemaphoreSshFixtures.from(TestDataExtension.getOrCreate(extensionContext)); } if (type == SemaphoreStaticInventoryFixtures.class) { - return SemaphoreStaticInventoryFixtures.from(TestDataExtension.getOrCreate(extensionContext)); + return SemaphoreStaticInventoryFixtures.from( + io.bookwright.config.Configs.main(), TestDataExtension.getOrCreate(extensionContext)); } if (type == SemaphoreSurveyFixtures.class) { return SemaphoreSurveyFixtures.from(TestDataExtension.getOrCreate(extensionContext)); } if (type == SemaphoreTerraformFixtures.class) { - return SemaphoreTerraformFixtures.from(TestDataExtension.getOrCreate(extensionContext)); + return SemaphoreTerraformFixtures.from( + io.bookwright.config.Configs.main(), TestDataExtension.getOrCreate(extensionContext)); } if (type == SemaphoreTotpFixtures.class) { return SemaphoreTotpFixtures.standard(); @@ -174,7 +180,7 @@ public Object resolveParameter( return SemaphoreTokenFixtures.from(TestDataExtension.getOrCreate(extensionContext)); } if (type == SemaphoreUpgradeFixtures.class) { - return SemaphoreUpgradeFixtures.standard(); + return SemaphoreUpgradeFixtures.from(io.bookwright.config.Configs.main()); } if (type == SemaphoreUserLifecycleFixtures.class) { return SemaphoreUserLifecycleFixtures.from(TestDataExtension.getOrCreate(extensionContext)); diff --git a/src/test/java/io/bookwright/tests/framework/FixtureArchitectureTest.java b/src/test/java/io/bookwright/tests/framework/FixtureArchitectureTest.java index e5e4cfa..6f51b96 100644 --- a/src/test/java/io/bookwright/tests/framework/FixtureArchitectureTest.java +++ b/src/test/java/io/bookwright/tests/framework/FixtureArchitectureTest.java @@ -118,13 +118,13 @@ void fixtureDiagnosticsRedactPasswords() { SemaphoreSshFixtures.SshAccessKey sshKey = new SemaphoreSshFixtures.SshAccessKey( "fixture-key", "ssh", "fixture", "ssh-passphrase-secret", "ssh-private-key-secret"); - SemaphoreUpgradeFixtures upgrade = SemaphoreUpgradeFixtures.standard(); + SemaphoreUpgradeFixtures upgrade = SemaphoreUpgradeFixtures.from(Configs.main()); SemaphoreVariableGroupFixtures variableGroup = SemaphoreVariableGroupFixtures.from(new TestData(1L, 2L, "fixture-redaction")); SemaphoreSurveyFixtures survey = SemaphoreSurveyFixtures.from(new TestData(1L, 2L, "fixture-redaction")); SemaphoreTerraformFixtures terraform = - SemaphoreTerraformFixtures.from(new TestData(1L, 2L, "fixture-redaction")); + SemaphoreTerraformFixtures.from(Configs.main(), new TestData(1L, 2L, "fixture-redaction")); SemaphoreUserLifecycleFixtures userLifecycle = SemaphoreUserLifecycleFixtures.from(new TestData(1L, 2L, "fixture-redaction")); From 6c6448a14a3dd5574391529b6078ba5706286e99 Mon Sep 17 00:00:00 2001 From: Nick Date: Sat, 5 Sep 2026 05:48:43 +0300 Subject: [PATCH 05/12] fix: fix template file path --- .../fixtures/semaphore/SemaphoreBuildDeployFixtures.java | 4 ++-- .../fixtures/semaphore/SemaphoreConcurrencyFixtures.java | 2 +- .../semaphore/SemaphoreEncryptionRotationFixtures.java | 2 +- .../fixtures/semaphore/SemaphoreFileInventoryFixtures.java | 4 ++-- .../io/bookwright/fixtures/semaphore/SemaphoreFixtures.java | 4 ++-- .../fixtures/semaphore/SemaphoreHttpsGitFixtures.java | 2 +- .../fixtures/semaphore/SemaphoreIntegrationFixtures.java | 2 +- .../semaphore/SemaphoreProjectDeletionFixtures.java | 2 +- .../fixtures/semaphore/SemaphoreRunnerRoutingFixtures.java | 2 +- .../fixtures/semaphore/SemaphoreShellOutputFixtures.java | 4 ++-- .../semaphore/SemaphoreStaticInventoryFixtures.java | 4 ++-- .../fixtures/semaphore/SemaphoreSurveyFixtures.java | 2 +- .../fixtures/semaphore/SemaphoreTerraformFixtures.java | 6 +++--- .../fixtures/semaphore/SemaphoreUpgradeFixtures.java | 2 +- .../fixtures/semaphore/SemaphoreVariableGroupFixtures.java | 2 +- 15 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreBuildDeployFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreBuildDeployFixtures.java index 7b07067..d7909dc 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreBuildDeployFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreBuildDeployFixtures.java @@ -32,14 +32,14 @@ public static SemaphoreBuildDeployFixtures from(MainConfig config, TestData data "static"), new BuildTemplate( "bookwright-build-template-" + suffix, - "/test-environment/fixtures/ansible/build-version.yml", + "test-environment/fixtures/ansible/build-version.yml", "ansible", "build", "1.2.3", "semaphore-bookwright-build-version"), new DeployTemplate( "bookwright-deploy-template-" + suffix, - "deploy-version.yml", + "test-environment/fixtures/ansible/deploy-version.yml", "ansible", "deploy", "semaphore-bookwright-deploy-version")); diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreConcurrencyFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreConcurrencyFixtures.java index 61cf283..c47b12d 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreConcurrencyFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreConcurrencyFixtures.java @@ -23,7 +23,7 @@ public static SemaphoreConcurrencyFixtures from(TestData data) { return new SemaphoreConcurrencyFixtures( "bookwright-concurrency-" + suffix, "bookwright-parallel-template-" + suffix, - "long-running.yml", + "test-environment/fixtures/ansible/long-running.yml", 1, 2, "semaphore-bookwright-stop-ready", diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreEncryptionRotationFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreEncryptionRotationFixtures.java index b412f0a..3300e28 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreEncryptionRotationFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreEncryptionRotationFixtures.java @@ -41,7 +41,7 @@ public static SemaphoreEncryptionRotationFixtures from(MainConfig config) { "bookwright-encryption-inventory", "[local]\nlocalhost ansible_connection=local", "static"), - new Template("bookwright-encryption-template", "smoke.yml", "ansible", ""), + new Template("bookwright-encryption-template", "test-environment/fixtures/ansible/smoke.yml", "ansible", ""), "semaphore-bookwright-smoke-ok"); } } diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFileInventoryFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFileInventoryFixtures.java index 111aa7d..c16b5c4 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFileInventoryFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFileInventoryFixtures.java @@ -28,13 +28,13 @@ public static SemaphoreFileInventoryFixtures from(MainConfig config, TestData da new Repository( "bookwright-file-inventory-repository-" + suffix, config.fixturesRepository(), config.fixturesDefaultBranch()), new FileInventory( - "bookwright-file-inventory-" + suffix, "inventories/localhost.ini", "file"), + "bookwright-file-inventory-" + suffix, "test-environment/fixtures/ansible/inventories/localhost.ini", "file"), new FileInventory( "bookwright-unsafe-file-inventory-" + suffix, "../bookwright-outside-repository.ini", "file"), new Template( - "bookwright-file-inventory-template-" + suffix, "file-inventory.yml", "ansible", ""), + "bookwright-file-inventory-template-" + suffix, "test-environment/fixtures/ansible/file-inventory.yml", "ansible", ""), "success", "semaphore-bookwright-file-inventory-ok"); } diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFixtures.java index a7c913c..04e941f 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFixtures.java @@ -62,9 +62,9 @@ public static SemaphoreFixtures from(MainConfig config, TestData data) { "[local]\nlocalhost ansible_connection=local", "static"), new Templates( - new Template("bookwright-build-template-" + suffix, "smoke.yml", "ansible", ""), + new Template("bookwright-build-template-" + suffix, "test-environment/fixtures/ansible/smoke.yml", "ansible", ""), new Template( - "bookwright-stoppable-template-" + suffix, "long-running.yml", "ansible", "")), + "bookwright-stoppable-template-" + suffix, "test-environment/fixtures/ansible/long-running.yml", "ansible", "")), new Schedule("bookwright-nightly-schedule-" + suffix, "0 0 * * *", false, ""), Rbac.standard(), new Expectations( diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreHttpsGitFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreHttpsGitFixtures.java index 6645f2a..c9edad1 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreHttpsGitFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreHttpsGitFixtures.java @@ -41,7 +41,7 @@ public static SemaphoreHttpsGitFixtures from(TestData data) { "bookwright-https-git-inventory-" + suffix, "[local]\nlocalhost ansible_connection=local", "static"), - new Template("bookwright-https-git-template-" + suffix, "smoke.yml", "ansible", ""), + new Template("bookwright-https-git-template-" + suffix, "test-environment/fixtures/ansible/smoke.yml", "ansible", ""), "success", "error", "semaphore-bookwright-smoke-ok", diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreIntegrationFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreIntegrationFixtures.java index e61839d..d8089d4 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreIntegrationFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreIntegrationFixtures.java @@ -29,7 +29,7 @@ public static SemaphoreIntegrationFixtures from(TestData data) { "bookwright-webhook-project-" + suffix, "bookwright-webhook-" + suffix, "bookwright-webhook-template-" + suffix, - "integration-webhook.yml", + "test-environment/fixtures/ansible/integration-webhook.yml", new SemaphoreFixtures.SecretAccessKey( "bookwright-webhook-token-" + suffix, "login_password", diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreProjectDeletionFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreProjectDeletionFixtures.java index 199431b..f26ffac 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreProjectDeletionFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreProjectDeletionFixtures.java @@ -30,7 +30,7 @@ public static SemaphoreProjectDeletionFixtures from(MainConfig config, TestData "[local]\nlocalhost ansible_connection=local", "static"), new Template( - "bookwright-project-delete-template-" + suffix, "project-deletion.yml", "ansible", ""), + "bookwright-project-delete-template-" + suffix, "test-environment/fixtures/ansible/project-deletion.yml", "ansible", ""), "semaphore-bookwright-project-delete-ready", "stopped"); } diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreRunnerRoutingFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreRunnerRoutingFixtures.java index 9abaeeb..e3b43cb 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreRunnerRoutingFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreRunnerRoutingFixtures.java @@ -31,7 +31,7 @@ public static SemaphoreRunnerRoutingFixtures from(TestData data) { "bookwright-missing", "bookwright-tagged-template-" + suffix, "bookwright-unmatched-template-" + suffix, - "long-running.yml", + "test-environment/fixtures/ansible/long-running.yml", "semaphore-bookwright-stop-ready", 2, 1, diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreShellOutputFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreShellOutputFixtures.java index 2c92f95..cba051e 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreShellOutputFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreShellOutputFixtures.java @@ -32,12 +32,12 @@ public static SemaphoreShellOutputFixtures from(MainConfig config, TestData data new Templates( new Template( "bookwright-shell-output-template-" + suffix, - "bash/capture-output/normal.sh", + "test-environment/fixtures/ansible/bash/capture-output/normal.sh", "bash", ""), new Template( "bookwright-background-shell-output-template-" + suffix, - "bash/capture-output/background.sh", + "test-environment/fixtures/ansible/bash/capture-output/background.sh", "bash", "")), new Expectations( diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreStaticInventoryFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreStaticInventoryFixtures.java index b3dc94c..43493e0 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreStaticInventoryFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreStaticInventoryFixtures.java @@ -39,7 +39,7 @@ public static SemaphoreStaticInventoryFixtures from(MainConfig config, TestData "excluded-host"), new Template( "bookwright-ini-inventory-template-" + suffix, - "smoke.yml", + "test-environment/fixtures/ansible/smoke.yml", "ansible", "", "bookwright_selected"), @@ -62,7 +62,7 @@ public static SemaphoreStaticInventoryFixtures from(MainConfig config, TestData "yaml-excluded-host"), new Template( "bookwright-yaml-inventory-template-" + suffix, - "smoke.yml", + "test-environment/fixtures/ansible/smoke.yml", "ansible", "", "bookwright_yaml_selected"), diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreSurveyFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreSurveyFixtures.java index 0687b07..8dc9c2a 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreSurveyFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreSurveyFixtures.java @@ -72,7 +72,7 @@ public static SemaphoreSurveyFixtures from(TestData data) { null)); return new SemaphoreSurveyFixtures( "bookwright-survey-template-" + suffix, - "survey-overrides.yml", + "test-environment/fixtures/ansible/survey-overrides.yml", surveyVariables, new AnsibleTemplateParameters( true, false, true, true, true, true, false, List.of(), List.of(), List.of()), diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreTerraformFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreTerraformFixtures.java index e185f22..87b0da7 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreTerraformFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreTerraformFixtures.java @@ -45,15 +45,15 @@ public static SemaphoreTerraformFixtures from(MainConfig config, TestData data) new WorkspaceInventory( "bookwright-terraform-workspace-" + suffix, "bookwright-tf-" + suffix, - "terraform-workspace"), + "test-environment/fixtures/ansible/terraform-workspace"), new ToolTemplate( - "bookwright-terraform-template-" + suffix, "terraform-workspace", "terraform")), + "bookwright-terraform-template-" + suffix, "test-environment/fixtures/ansible/terraform-workspace", "terraform")), new Tool( new WorkspaceInventory( "bookwright-tofu-workspace-" + suffix, "bookwright-tofu-" + suffix, "tofu-workspace"), - new ToolTemplate("bookwright-tofu-template-" + suffix, "terraform-workspace", "tofu")), + new ToolTemplate("bookwright-tofu-template-" + suffix, "test-environment/fixtures/ansible/terraform-workspace", "tofu")), "semaphore_bookwright_workspace"); } diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreUpgradeFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreUpgradeFixtures.java index 82556af..84a308a 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreUpgradeFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreUpgradeFixtures.java @@ -31,7 +31,7 @@ public static SemaphoreUpgradeFixtures from(MainConfig config) { "bookwright-upgrade-inventory", "[local]\nlocalhost ansible_connection=local", "static"), - new Template("bookwright-upgrade-template", "smoke.yml", "ansible", ""), + new Template("bookwright-upgrade-template", "test-environment/fixtures/ansible/smoke.yml", "ansible", ""), new Schedule("bookwright-upgrade-schedule", "0 0 * * *", false, ""), "semaphore-bookwright-smoke-ok"); } diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreVariableGroupFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreVariableGroupFixtures.java index a4918d5..6aeca0c 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreVariableGroupFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreVariableGroupFixtures.java @@ -39,7 +39,7 @@ public static SemaphoreVariableGroupFixtures from(TestData data) { variableSecret, environmentSecret, "bookwright-variable-template-" + suffix, - "variables.yml", + "test-environment/fixtures/ansible/variables.yml", "semaphore-bookwright-variable-group-ok", "Environment variables key can not be empty"); } From 988d95416731876891eba347994a3085a50004ac Mon Sep 17 00:00:00 2001 From: Nick Date: Sat, 5 Sep 2026 05:53:29 +0300 Subject: [PATCH 06/12] refactor --- scripts/docker_executor.sh | 4 ++-- .../semaphore/SemaphoreBuildDeployFixtures.java | 6 ++++-- .../SemaphoreEncryptionRotationFixtures.java | 13 ++++++++++--- .../semaphore/SemaphoreFileInventoryFixtures.java | 15 +++++++++++---- .../fixtures/semaphore/SemaphoreFixtures.java | 15 ++++++++++++--- .../semaphore/SemaphoreHttpsGitFixtures.java | 6 +++++- .../SemaphoreProjectDeletionFixtures.java | 11 ++++++++--- .../semaphore/SemaphoreShellOutputFixtures.java | 6 ++++-- .../SemaphoreStaticInventoryFixtures.java | 6 ++++-- .../semaphore/SemaphoreTerraformFixtures.java | 15 +++++++++++---- .../semaphore/SemaphoreUpgradeFixtures.java | 13 ++++++++++--- 11 files changed, 81 insertions(+), 29 deletions(-) diff --git a/scripts/docker_executor.sh b/scripts/docker_executor.sh index 9e5b2d1..81b6ef5 100755 --- a/scripts/docker_executor.sh +++ b/scripts/docker_executor.sh @@ -15,8 +15,8 @@ external() { main() { command="$1" shift || true - - case "$command" in + + case "$command" in external) external ;; diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreBuildDeployFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreBuildDeployFixtures.java index d7909dc..7f58e54 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreBuildDeployFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreBuildDeployFixtures.java @@ -6,9 +6,9 @@ import io.bookwright.api.model.semaphore.RepositoryRequest; import io.bookwright.api.model.semaphore.TaskRequest; import io.bookwright.api.model.semaphore.TemplateRequest; +import io.bookwright.config.MainConfig; import io.bookwright.util.TestData; import java.util.List; -import io.bookwright.config.MainConfig; /** Typed data for a manually selected Build to Deploy artifact-version chain. */ public record SemaphoreBuildDeployFixtures( @@ -25,7 +25,9 @@ public static SemaphoreBuildDeployFixtures from(MainConfig config, TestData data new ProjectRequest("bookwright-build-deploy-" + suffix, false, 0), new AccessKey("bookwright-build-deploy-key-" + suffix, "none"), new Repository( - "bookwright-build-deploy-repository-" + suffix, config.fixturesRepository(), config.fixturesDefaultBranch()), + "bookwright-build-deploy-repository-" + suffix, + config.fixturesRepository(), + config.fixturesDefaultBranch()), new Inventory( "bookwright-build-deploy-inventory-" + suffix, "[local]\nlocalhost ansible_connection=local", diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreEncryptionRotationFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreEncryptionRotationFixtures.java index 3300e28..37b6d2d 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreEncryptionRotationFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreEncryptionRotationFixtures.java @@ -1,11 +1,11 @@ package io.bookwright.fixtures.semaphore; import io.bookwright.api.model.semaphore.ProjectRequest; +import io.bookwright.config.MainConfig; import io.bookwright.fixtures.semaphore.SemaphoreFixtures.Inventory; import io.bookwright.fixtures.semaphore.SemaphoreFixtures.Repository; import io.bookwright.fixtures.semaphore.SemaphoreFixtures.SecretAccessKey; import io.bookwright.fixtures.semaphore.SemaphoreFixtures.Template; -import io.bookwright.config.MainConfig; /** Stable data shared by the phases of a database-encryption key rotation. */ public record SemaphoreEncryptionRotationFixtures( @@ -36,12 +36,19 @@ public static SemaphoreEncryptionRotationFixtures from(MainConfig config) { "login_password", "bookwright-post-rekey-user", "Bookwright-post-rekey-password-42!"), - new Repository("bookwright-encryption-repository", config.fixturesRepository(), config.fixturesDefaultBranch()), + new Repository( + "bookwright-encryption-repository", + config.fixturesRepository(), + config.fixturesDefaultBranch()), new Inventory( "bookwright-encryption-inventory", "[local]\nlocalhost ansible_connection=local", "static"), - new Template("bookwright-encryption-template", "test-environment/fixtures/ansible/smoke.yml", "ansible", ""), + new Template( + "bookwright-encryption-template", + "test-environment/fixtures/ansible/smoke.yml", + "ansible", + ""), "semaphore-bookwright-smoke-ok"); } } diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFileInventoryFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFileInventoryFixtures.java index c16b5c4..d6ac63f 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFileInventoryFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFileInventoryFixtures.java @@ -6,8 +6,8 @@ import io.bookwright.api.model.semaphore.ProjectRequest; import io.bookwright.api.model.semaphore.RepositoryRequest; import io.bookwright.api.model.semaphore.TemplateRequest; -import io.bookwright.util.TestData; import io.bookwright.config.MainConfig; +import io.bookwright.util.TestData; /** Typed data for Ansible inventories stored in a Git repository. */ public record SemaphoreFileInventoryFixtures( @@ -26,15 +26,22 @@ public static SemaphoreFileInventoryFixtures from(MainConfig config, TestData da new ProjectRequest("bookwright-file-inventory-" + suffix, false, 0), new AccessKey("bookwright-file-inventory-key-" + suffix, "none"), new Repository( - "bookwright-file-inventory-repository-" + suffix, config.fixturesRepository(), config.fixturesDefaultBranch()), + "bookwright-file-inventory-repository-" + suffix, + config.fixturesRepository(), + config.fixturesDefaultBranch()), new FileInventory( - "bookwright-file-inventory-" + suffix, "test-environment/fixtures/ansible/inventories/localhost.ini", "file"), + "bookwright-file-inventory-" + suffix, + "test-environment/fixtures/ansible/inventories/localhost.ini", + "file"), new FileInventory( "bookwright-unsafe-file-inventory-" + suffix, "../bookwright-outside-repository.ini", "file"), new Template( - "bookwright-file-inventory-template-" + suffix, "test-environment/fixtures/ansible/file-inventory.yml", "ansible", ""), + "bookwright-file-inventory-template-" + suffix, + "test-environment/fixtures/ansible/file-inventory.yml", + "ansible", + ""), "success", "semaphore-bookwright-file-inventory-ok"); } diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFixtures.java index 04e941f..2274fb0 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFixtures.java @@ -44,7 +44,9 @@ public static SemaphoreFixtures from(MainConfig config, TestData data) { "Bw-secret-" + suffix + "-42!"), new Repositories( new Repository( - "bookwright-demo-repository-" + suffix, config.fixturesRepository(), config.fixturesDefaultBranch()), + "bookwright-demo-repository-" + suffix, + config.fixturesRepository(), + config.fixturesDefaultBranch()), new Repository( "bookwright-ref-repository-" + suffix, config.fixturesRepository(), @@ -62,9 +64,16 @@ public static SemaphoreFixtures from(MainConfig config, TestData data) { "[local]\nlocalhost ansible_connection=local", "static"), new Templates( - new Template("bookwright-build-template-" + suffix, "test-environment/fixtures/ansible/smoke.yml", "ansible", ""), new Template( - "bookwright-stoppable-template-" + suffix, "test-environment/fixtures/ansible/long-running.yml", "ansible", "")), + "bookwright-build-template-" + suffix, + "test-environment/fixtures/ansible/smoke.yml", + "ansible", + ""), + new Template( + "bookwright-stoppable-template-" + suffix, + "test-environment/fixtures/ansible/long-running.yml", + "ansible", + "")), new Schedule("bookwright-nightly-schedule-" + suffix, "0 0 * * *", false, ""), Rbac.standard(), new Expectations( diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreHttpsGitFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreHttpsGitFixtures.java index c9edad1..eb96aa5 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreHttpsGitFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreHttpsGitFixtures.java @@ -41,7 +41,11 @@ public static SemaphoreHttpsGitFixtures from(TestData data) { "bookwright-https-git-inventory-" + suffix, "[local]\nlocalhost ansible_connection=local", "static"), - new Template("bookwright-https-git-template-" + suffix, "test-environment/fixtures/ansible/smoke.yml", "ansible", ""), + new Template( + "bookwright-https-git-template-" + suffix, + "test-environment/fixtures/ansible/smoke.yml", + "ansible", + ""), "success", "error", "semaphore-bookwright-smoke-ok", diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreProjectDeletionFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreProjectDeletionFixtures.java index f26ffac..90dcb81 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreProjectDeletionFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreProjectDeletionFixtures.java @@ -5,8 +5,8 @@ import io.bookwright.api.model.semaphore.ProjectRequest; import io.bookwright.api.model.semaphore.RepositoryRequest; import io.bookwright.api.model.semaphore.TemplateRequest; -import io.bookwright.util.TestData; import io.bookwright.config.MainConfig; +import io.bookwright.util.TestData; /** Typed data for project deletion with a running or stopped task. */ public record SemaphoreProjectDeletionFixtures( @@ -24,13 +24,18 @@ public static SemaphoreProjectDeletionFixtures from(MainConfig config, TestData new ProjectRequest("bookwright-project-delete-" + suffix, false, 0), new AccessKey("bookwright-project-delete-key-" + suffix, "none"), new Repository( - "bookwright-project-delete-repository-" + suffix, config.fixturesRepository(), config.fixturesDefaultBranch()), + "bookwright-project-delete-repository-" + suffix, + config.fixturesRepository(), + config.fixturesDefaultBranch()), new Inventory( "bookwright-project-delete-inventory-" + suffix, "[local]\nlocalhost ansible_connection=local", "static"), new Template( - "bookwright-project-delete-template-" + suffix, "test-environment/fixtures/ansible/project-deletion.yml", "ansible", ""), + "bookwright-project-delete-template-" + suffix, + "test-environment/fixtures/ansible/project-deletion.yml", + "ansible", + ""), "semaphore-bookwright-project-delete-ready", "stopped"); } diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreShellOutputFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreShellOutputFixtures.java index cba051e..f25fdea 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreShellOutputFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreShellOutputFixtures.java @@ -5,9 +5,9 @@ import io.bookwright.api.model.semaphore.ProjectRequest; import io.bookwright.api.model.semaphore.RepositoryRequest; import io.bookwright.api.model.semaphore.TemplateRequest; +import io.bookwright.config.MainConfig; import io.bookwright.util.TestData; import java.time.Duration; -import io.bookwright.config.MainConfig; /** Typed data and expectations for Bash output capture regressions. */ public record SemaphoreShellOutputFixtures( @@ -24,7 +24,9 @@ public static SemaphoreShellOutputFixtures from(MainConfig config, TestData data new ProjectRequest("bookwright-shell-output-" + suffix, false, 0), new AccessKey("bookwright-shell-output-key-" + suffix, "none"), new Repository( - "bookwright-shell-output-repository-" + suffix, config.fixturesRepository(), config.fixturesDefaultBranch()), + "bookwright-shell-output-repository-" + suffix, + config.fixturesRepository(), + config.fixturesDefaultBranch()), new Inventory( "bookwright-shell-output-inventory-" + suffix, "[local]\nlocalhost ansible_connection=local", diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreStaticInventoryFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreStaticInventoryFixtures.java index 43493e0..20f1825 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreStaticInventoryFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreStaticInventoryFixtures.java @@ -6,9 +6,9 @@ import io.bookwright.api.model.semaphore.ProjectRequest; import io.bookwright.api.model.semaphore.RepositoryRequest; import io.bookwright.api.model.semaphore.TemplateRequest; +import io.bookwright.config.MainConfig; import io.bookwright.util.TestData; import java.util.List; -import io.bookwright.config.MainConfig; /** Typed data for static inventory group selection during Ansible execution. */ public record SemaphoreStaticInventoryFixtures( @@ -27,7 +27,9 @@ public static SemaphoreStaticInventoryFixtures from(MainConfig config, TestData new ProjectRequest("bookwright-static-inventory-" + suffix, false, 0), new AccessKey("bookwright-static-inventory-key-" + suffix, "none"), new Repository( - "bookwright-static-inventory-repository-" + suffix, config.fixturesRepository(), config.fixturesDefaultBranch()), + "bookwright-static-inventory-repository-" + suffix, + config.fixturesRepository(), + config.fixturesDefaultBranch()), new StaticInventory( "bookwright-ini-inventory-" + suffix, "[bookwright_selected]\n" diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreTerraformFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreTerraformFixtures.java index 87b0da7..046bef5 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreTerraformFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreTerraformFixtures.java @@ -10,13 +10,13 @@ import io.bookwright.api.model.semaphore.TerraformTemplateParameters; import io.bookwright.api.model.semaphore.VariableGroupRequest; import io.bookwright.api.model.semaphore.VariableGroupSecretRequest; +import io.bookwright.config.MainConfig; import io.bookwright.util.TestData; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.HexFormat; import java.util.List; -import io.bookwright.config.MainConfig; /** Typed data for Terraform and OpenTofu workspace plan execution. */ public record SemaphoreTerraformFixtures( @@ -34,7 +34,9 @@ public static SemaphoreTerraformFixtures from(MainConfig config, TestData data) new ProjectRequest("bookwright-terraform-" + suffix, false, 0), new AccessKey("bookwright-terraform-key-" + suffix, "none"), new Repository( - "bookwright-terraform-repository-" + suffix, config.fixturesRepository(), config.fixturesDefaultBranch()), + "bookwright-terraform-repository-" + suffix, + config.fixturesRepository(), + config.fixturesDefaultBranch()), new TerraformVariableGroup( "bookwright-terraform-variables-" + suffix, "TF_VAR_bookwright_secret", @@ -47,13 +49,18 @@ public static SemaphoreTerraformFixtures from(MainConfig config, TestData data) "bookwright-tf-" + suffix, "test-environment/fixtures/ansible/terraform-workspace"), new ToolTemplate( - "bookwright-terraform-template-" + suffix, "test-environment/fixtures/ansible/terraform-workspace", "terraform")), + "bookwright-terraform-template-" + suffix, + "test-environment/fixtures/ansible/terraform-workspace", + "terraform")), new Tool( new WorkspaceInventory( "bookwright-tofu-workspace-" + suffix, "bookwright-tofu-" + suffix, "tofu-workspace"), - new ToolTemplate("bookwright-tofu-template-" + suffix, "test-environment/fixtures/ansible/terraform-workspace", "tofu")), + new ToolTemplate( + "bookwright-tofu-template-" + suffix, + "test-environment/fixtures/ansible/terraform-workspace", + "tofu")), "semaphore_bookwright_workspace"); } diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreUpgradeFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreUpgradeFixtures.java index 84a308a..2dd0d86 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreUpgradeFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreUpgradeFixtures.java @@ -1,12 +1,12 @@ package io.bookwright.fixtures.semaphore; import io.bookwright.api.model.semaphore.ProjectRequest; +import io.bookwright.config.MainConfig; import io.bookwright.fixtures.semaphore.SemaphoreFixtures.Inventory; import io.bookwright.fixtures.semaphore.SemaphoreFixtures.Repository; import io.bookwright.fixtures.semaphore.SemaphoreFixtures.Schedule; import io.bookwright.fixtures.semaphore.SemaphoreFixtures.SecretAccessKey; import io.bookwright.fixtures.semaphore.SemaphoreFixtures.Template; -import io.bookwright.config.MainConfig; /** Stable data shared by the seed and verify processes of an in-place release upgrade. */ public record SemaphoreUpgradeFixtures( @@ -26,12 +26,19 @@ public static SemaphoreUpgradeFixtures from(MainConfig config) { "login_password", "bookwright-upgrade-user", "Bookwright-upgrade-password-42!"), - new Repository("bookwright-upgrade-repository", config.fixturesRepository(), config.fixturesDefaultBranch()), + new Repository( + "bookwright-upgrade-repository", + config.fixturesRepository(), + config.fixturesDefaultBranch()), new Inventory( "bookwright-upgrade-inventory", "[local]\nlocalhost ansible_connection=local", "static"), - new Template("bookwright-upgrade-template", "test-environment/fixtures/ansible/smoke.yml", "ansible", ""), + new Template( + "bookwright-upgrade-template", + "test-environment/fixtures/ansible/smoke.yml", + "ansible", + ""), new Schedule("bookwright-upgrade-schedule", "0 0 * * *", false, ""), "semaphore-bookwright-smoke-ok"); } From 3e66e6e60fbd4e5e6dcb79c5d3903e73432b132f Mon Sep 17 00:00:00 2001 From: Nick Date: Sat, 5 Sep 2026 06:13:47 +0300 Subject: [PATCH 07/12] fix: fix terraform workspace --- .../fixtures/semaphore/SemaphoreTerraformFixtures.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreTerraformFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreTerraformFixtures.java index 046bef5..8425a59 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreTerraformFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreTerraformFixtures.java @@ -47,7 +47,7 @@ public static SemaphoreTerraformFixtures from(MainConfig config, TestData data) new WorkspaceInventory( "bookwright-terraform-workspace-" + suffix, "bookwright-tf-" + suffix, - "test-environment/fixtures/ansible/terraform-workspace"), + "terraform-workspace"), new ToolTemplate( "bookwright-terraform-template-" + suffix, "test-environment/fixtures/ansible/terraform-workspace", From 457df091ccf8f02f3b0b629265eb40ac7c5b0f23 Mon Sep 17 00:00:00 2001 From: Nick Date: Sat, 5 Sep 2026 07:05:01 +0300 Subject: [PATCH 08/12] feat: run tests against an application pull request build Split the test source from the application source. TEST_REPOSITORY / TEST_BRANCH (git.fixtures.repository / git.fixtures.branch) keep selecting which fixtures and tests to use; a new APP_REPOSITORY / APP_PR group selects which version of the application to test. Without an explicit link the pipeline behaves exactly as before: the application repository is not cloned, nothing is built, no temporary image is created and the profile manifest image is used. A test pull request links itself to an application pull request declaratively in application-under-test.yml, or through CI inputs. CI then resolves the HEAD SHA of that pull request, reuses ghcr.io//semaphore-ci:ci-pr-- when it already exists and otherwise checks out, builds and pushes it. Changing only the tests never rebuilds the application; a new application commit yields a new image. Temporary images live in their own registry namespace, so release tags are never read or overwritten, and closed pull requests are cleaned up on a schedule. Application pull request updates reach the linked test pull requests through a repository_dispatch receiver; the link is always explicit and never inferred from branch names. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/_prepare-app-image.yml | 127 ++++++++ .github/workflows/application-pr.yml | 131 ++++++++ .github/workflows/ci.yml | 46 ++- .github/workflows/cleanup-pr-images.yml | 135 +++++++++ README.md | 27 +- application-under-test.yml | 21 ++ docs/application-pr-testing.md | 250 +++++++++++++++ scripts/app-source.sh | 367 +++++++++++++++++++++++ scripts/tests/test_app_source.py | 343 +++++++++++++++++++++ test-environment/profile | 50 ++- 10 files changed, 1488 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/_prepare-app-image.yml create mode 100644 .github/workflows/application-pr.yml create mode 100644 .github/workflows/cleanup-pr-images.yml create mode 100644 application-under-test.yml create mode 100644 docs/application-pr-testing.md create mode 100755 scripts/app-source.sh create mode 100644 scripts/tests/test_app_source.py diff --git a/.github/workflows/_prepare-app-image.yml b/.github/workflows/_prepare-app-image.yml new file mode 100644 index 0000000..cf2ac8e --- /dev/null +++ b/.github/workflows/_prepare-app-image.yml @@ -0,0 +1,127 @@ +name: Prepare application image + +# Resolves which version of the application the test jobs must run against. +# +# Normal mode (no explicit link to an application pull request) does nothing at all: the +# application repository is not read, no image is built and no temporary image is created. The +# test jobs then keep using the image declared by the profile manifest. +# +# Pull request mode resolves the HEAD commit of the linked application pull request, reuses the +# already published temporary image for that commit when it exists, and otherwise builds and +# pushes it. + +on: + workflow_call: + inputs: + application_repository: + description: Application repository as owner/name; overrides application-under-test.yml + required: false + type: string + default: "" + application_pull_request: + description: Application pull request number; overrides application-under-test.yml + required: false + type: string + default: "" + outputs: + app_source: + description: docker-image or pull-request + value: ${{ jobs.prepare.outputs.app_source }} + app_repository: + description: Resolved application repository, empty in normal mode + value: ${{ jobs.prepare.outputs.app_repository }} + app_pr: + description: Resolved application pull request number, empty in normal mode + value: ${{ jobs.prepare.outputs.app_pr }} + app_sha: + description: HEAD commit of the application pull request, empty in normal mode + value: ${{ jobs.prepare.outputs.app_sha }} + app_image: + description: Temporary application image, empty in normal mode + value: ${{ jobs.prepare.outputs.app_image }} + secrets: + application_repository_token: + description: >- + Token able to read the application repository. Only required when that repository is + private; the built-in GITHUB_TOKEN is used otherwise. + required: false + +jobs: + prepare: + name: Resolve application source + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + packages: write + outputs: + app_source: ${{ steps.link.outputs.app_source }} + app_repository: ${{ steps.link.outputs.app_repository }} + app_pr: ${{ steps.link.outputs.app_pr }} + app_sha: ${{ steps.image.outputs.app_sha }} + app_image: ${{ steps.image.outputs.app_image }} + env: + APP_REPOSITORY: ${{ inputs.application_repository }} + APP_PR: ${{ inputs.application_pull_request }} + APP_IMAGE_REPOSITORY: ghcr.io/${{ github.repository }}/semaphore-ci + steps: + - name: Checkout tests + uses: actions/checkout@v7 + + - name: Resolve application link + id: link + run: scripts/app-source.sh link + + - name: Report normal mode + if: steps.link.outputs.app_source != 'pull-request' + run: | + printf 'Application source: Docker image\n' + printf 'Application image: profile manifest default\n' + printf 'Application build: skipped\n' + + - name: Set up Buildx + if: steps.link.outputs.app_source == 'pull-request' + uses: docker/setup-buildx-action@v3 + + - name: Log in to the temporary image registry + if: steps.link.outputs.app_source == 'pull-request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Resolve, reuse or build the application image + id: image + if: steps.link.outputs.app_source == 'pull-request' + env: + GH_TOKEN: ${{ secrets.application_repository_token || secrets.GITHUB_TOKEN }} + run: scripts/app-source.sh ensure + + - name: Summary + env: + APP_SOURCE: ${{ steps.link.outputs.app_source }} + APP_REPOSITORY: ${{ steps.link.outputs.app_repository }} + APP_PR: ${{ steps.link.outputs.app_pr }} + APP_SHA: ${{ steps.image.outputs.app_sha }} + APP_IMAGE: ${{ steps.image.outputs.app_image }} + APP_BUILD_PERFORMED: ${{ steps.image.outputs.app_build_performed }} + run: | + { + if [ "$APP_SOURCE" = "pull-request" ]; then + printf '### Application source: Pull Request\n\n' + printf -- '- repository: `%s`\n' "$APP_REPOSITORY" + printf -- '- pull request: #%s\n' "$APP_PR" + printf -- '- SHA: `%s`\n' "$APP_SHA" + printf -- '- image: `%s`\n' "$APP_IMAGE" + if [ "$APP_BUILD_PERFORMED" = "true" ]; then + printf -- '- build: performed\n' + else + printf -- '- build: skipped, the image for this commit already existed\n' + fi + else + printf '### Application source: Docker image\n\n' + printf -- '- the application repository was not cloned and no image was built\n' + printf -- '- the profile manifest image is used, as before\n' + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/application-pr.yml b/.github/workflows/application-pr.yml new file mode 100644 index 0000000..69d705e --- /dev/null +++ b/.github/workflows/application-pr.yml @@ -0,0 +1,131 @@ +name: Application PR trigger + +# Runs the integration tests of every test pull request that is explicitly linked to the +# application pull request named in the event payload. +# +# The application repository sends the event; see docs/application-pr-testing.md for the +# workflow snippet it needs. Only test pull requests whose application-under-test.yml declares +# this exact application pull request are started: a change of an arbitrary branch of the +# application repository starts nothing, and the link is never inferred from branch names. + +on: + repository_dispatch: + types: + - application-pr-updated + workflow_dispatch: + inputs: + application_repository: + description: Application repository as owner/name + required: true + type: string + default: semaphoreui/semaphore + application_pull_request: + description: Application pull request number + required: true + type: string + +permissions: + contents: read + +concurrency: + group: application-pr-${{ github.event.client_payload.pull_request || inputs.application_pull_request }} + cancel-in-progress: false + +jobs: + dispatch: + name: Start linked test pull requests + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + actions: write + pull-requests: read + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + EVENT_APP_REPOSITORY: ${{ github.event.client_payload.repository || inputs.application_repository }} + EVENT_APP_PR: ${{ github.event.client_payload.pull_request || inputs.application_pull_request }} + EVENT_APP_SHA: ${{ github.event.client_payload.sha }} + steps: + - name: Checkout tests + uses: actions/checkout@v7 + + - name: Validate the event payload + run: | + set -eu + case "$EVENT_APP_PR" in + ''|*[!0-9]*) + printf 'Invalid application pull request in the event payload: %s\n' "$EVENT_APP_PR" >&2 + exit 1 + ;; + esac + case "$EVENT_APP_REPOSITORY" in + */*) ;; + *) + printf 'Invalid application repository in the event payload: %s\n' "$EVENT_APP_REPOSITORY" >&2 + exit 1 + ;; + esac + printf 'Application repository: %s\n' "$EVENT_APP_REPOSITORY" + printf 'Application PR: #%s\n' "$EVENT_APP_PR" + [ -z "$EVENT_APP_SHA" ] || printf 'Application SHA: %s\n' "$EVENT_APP_SHA" + + - name: Start every linked test pull request + run: | + set -eu + work_dir=$(mktemp -d) + started=0 + inspected=0 + + gh pr list --state open --limit 100 \ + --json number,headRefName,isCrossRepository \ + --jq '.[] | [.number, .headRefName, (.isCrossRepository | tostring)] | @tsv' \ + > "$work_dir/pulls.tsv" + + while IFS=$'\t' read -r pr_number head_ref cross_repository; do + [ -n "$pr_number" ] || continue + inspected=$((inspected + 1)) + + # A fork branch cannot be used as a workflow_dispatch ref; such pull requests keep + # running on their own pull_request events instead. + if [ "$cross_repository" = "true" ]; then + printf 'Test PR #%s: skipped, the head branch lives in a fork\n' "$pr_number" + continue + fi + + declaration="$work_dir/aut-$pr_number.yml" + if ! gh api "repos/$GITHUB_REPOSITORY/contents/application-under-test.yml?ref=$head_ref" \ + --jq '.content' > "$work_dir/aut-$pr_number.b64" 2>/dev/null; then + printf 'Test PR #%s: skipped, no application-under-test.yml on %s\n' "$pr_number" "$head_ref" + continue + fi + base64 -d < "$work_dir/aut-$pr_number.b64" > "$declaration" + + if ! link=$(APP_REPOSITORY= APP_PR= APP_SOURCE_FILE="$declaration" \ + scripts/app-source.sh link 2>"$work_dir/link-error"); then + printf 'Test PR #%s: skipped, application-under-test.yml is invalid\n' "$pr_number" + sed 's/^/ /' "$work_dir/link-error" || true + continue + fi + + linked_pr=$(printf '%s\n' "$link" | sed -n 's/^APP_PR=//p') + linked_repository=$(printf '%s\n' "$link" | sed -n 's/^APP_REPOSITORY=//p') + if [ "$linked_pr" != "$EVENT_APP_PR" ] || [ "$linked_repository" != "$EVENT_APP_REPOSITORY" ]; then + printf 'Test PR #%s: not linked to %s#%s\n' "$pr_number" "$EVENT_APP_REPOSITORY" "$EVENT_APP_PR" + continue + fi + + printf 'Test PR #%s: linked to %s#%s, starting CI on %s\n' \ + "$pr_number" "$EVENT_APP_REPOSITORY" "$EVENT_APP_PR" "$head_ref" + gh workflow run ci.yml --ref "$head_ref" \ + --field "application_repository=$EVENT_APP_REPOSITORY" \ + --field "application_pull_request=$EVENT_APP_PR" + started=$((started + 1)) + done < "$work_dir/pulls.tsv" + + rm -rf "$work_dir" + printf 'Inspected %s open test pull requests, started %s runs.\n' "$inspected" "$started" + { + printf '### Application PR %s#%s\n\n' "$EVENT_APP_REPOSITORY" "$EVENT_APP_PR" + printf -- '- open test pull requests inspected: %s\n' "$inspected" + printf -- '- linked test pull requests started: %s\n' "$started" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a63a0f..a1bd004 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,15 +5,39 @@ on: push: branches: - main + workflow_dispatch: + inputs: + application_repository: + description: Application repository as owner/name; overrides application-under-test.yml + required: false + type: string + default: "" + application_pull_request: + description: Application pull request number; overrides application-under-test.yml + required: false + type: string + default: "" permissions: contents: read concurrency: - group: ci-${{ github.ref }} + group: ci-${{ github.ref }}-${{ inputs.application_pull_request || 'default' }} cancel-in-progress: true jobs: + app-image: + name: Application source + permissions: + contents: read + packages: write + uses: ./.github/workflows/_prepare-app-image.yml + with: + application_repository: ${{ inputs.application_repository || '' }} + application_pull_request: ${{ inputs.application_pull_request || '' }} + secrets: + application_repository_token: ${{ secrets.APPLICATION_REPOSITORY_TOKEN }} + quality: name: Framework quality gate runs-on: ubuntu-latest @@ -60,15 +84,32 @@ jobs: core-sqlite: name: Core API + UI · SQLite - needs: quality + needs: + - quality + - app-image runs-on: ubuntu-latest timeout-minutes: 35 + permissions: + contents: read + packages: read env: PROFILE: core-sqlite-local + APP_IMAGE: ${{ needs.app-image.outputs.app_image }} + APP_REPOSITORY: ${{ needs.app-image.outputs.app_repository }} + APP_PR: ${{ needs.app-image.outputs.app_pr }} + APP_SHA: ${{ needs.app-image.outputs.app_sha }} steps: - name: Checkout uses: actions/checkout@v7 + - name: Log in to the temporary image registry + if: needs.app-image.outputs.app_source == 'pull-request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up Java 21 uses: actions/setup-java@v5 with: @@ -136,6 +177,7 @@ jobs: name: Build Allure report if: ${{ always() }} needs: + - app-image - quality - core-sqlite permissions: diff --git a/.github/workflows/cleanup-pr-images.yml b/.github/workflows/cleanup-pr-images.yml new file mode 100644 index 0000000..9f42323 --- /dev/null +++ b/.github/workflows/cleanup-pr-images.yml @@ -0,0 +1,135 @@ +name: Cleanup temporary application images + +# Removes temporary application images built for application pull requests that are closed or +# merged. Only the semaphore-ci package of this test repository is touched; release images of +# semaphoreui/semaphore live in a different registry namespace and are never inspected here. +# +# Deleting a package version needs a token with delete:packages, which the built-in GITHUB_TOKEN +# does not have. Store one as the GHCR_CLEANUP_TOKEN secret. Without it the workflow only +# reports what it would delete. + +on: + schedule: + - cron: "0 4 * * *" + workflow_dispatch: + inputs: + dry_run: + description: Only report the deletion candidates + required: false + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: cleanup-pr-images + cancel-in-progress: false + +jobs: + cleanup: + name: Delete images of closed application pull requests + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + packages: read + env: + GH_TOKEN: ${{ secrets.GHCR_CLEANUP_TOKEN || secrets.GITHUB_TOKEN }} + PACKAGE_OWNER: ${{ github.repository_owner }} + PACKAGE_NAME: ${{ github.event.repository.name }}/semaphore-ci + DEFAULT_APP_REPOSITORY: ${{ vars.APPLICATION_REPOSITORY || 'semaphoreui/semaphore' }} + DRY_RUN: ${{ inputs.dry_run || !secrets.GHCR_CLEANUP_TOKEN }} + # Grace period after the application pull request was closed or merged, so that a run + # started just before the merge can still pull its image. + RETENTION_HOURS: "24" + steps: + - name: Delete stale temporary images + run: | + set -eu + work_dir=$(mktemp -d) + + owner_type=organization + if [ "$(gh api "users/$PACKAGE_OWNER" --jq '.type')" = "User" ]; then + owner_type=user + fi + case "$owner_type" in + organization) versions_path="orgs/$PACKAGE_OWNER/packages/container/$(printf '%s' "$PACKAGE_NAME" | sed 's|/|%2F|g')/versions" ;; + user) versions_path="users/$PACKAGE_OWNER/packages/container/$(printf '%s' "$PACKAGE_NAME" | sed 's|/|%2F|g')/versions" ;; + esac + + if ! gh api --paginate "$versions_path" \ + --jq '.[] | [(.id | tostring), (.metadata.container.tags | join(","))] | @tsv' \ + > "$work_dir/versions.tsv" 2>"$work_dir/error"; then + if grep -qi 'not found' "$work_dir/error"; then + printf 'No temporary image package exists yet; nothing to clean up.\n' + exit 0 + fi + cat "$work_dir/error" >&2 + exit 1 + fi + + now=$(date -u +%s) + candidates=0 + deleted=0 + kept=0 + + while IFS=$'\t' read -r version_id tags; do + [ -n "$version_id" ] || continue + + # Only tags produced by scripts/app-source.sh are considered: ci-pr--. + pr_number=$(printf '%s\n' "$tags" | tr ',' '\n' | sed -n 's/^ci-pr-\([0-9][0-9]*\)-[0-9a-f]\{40\}$/\1/p' | sed -n '1p') + if [ -z "$pr_number" ]; then + kept=$((kept + 1)) + continue + fi + + pr_state=$(gh api "repos/$DEFAULT_APP_REPOSITORY/pulls/$pr_number" \ + --jq '[.state, (.closed_at // "")] | @tsv' 2>/dev/null || true) + if [ -z "$pr_state" ]; then + printf 'Version %s (%s): application PR #%s is unreachable, keeping the image\n' \ + "$version_id" "$tags" "$pr_number" + kept=$((kept + 1)) + continue + fi + + state=$(printf '%s' "$pr_state" | cut -f1) + closed_at=$(printf '%s' "$pr_state" | cut -f2) + if [ "$state" != "closed" ] || [ -z "$closed_at" ]; then + kept=$((kept + 1)) + continue + fi + + closed_epoch=$(date -u -d "$closed_at" +%s) + age_hours=$(( (now - closed_epoch) / 3600 )) + if [ "$age_hours" -lt "$RETENTION_HOURS" ]; then + printf 'Version %s (%s): application PR #%s closed %sh ago, within the retention window\n' \ + "$version_id" "$tags" "$pr_number" "$age_hours" + kept=$((kept + 1)) + continue + fi + + candidates=$((candidates + 1)) + if [ "$DRY_RUN" = "true" ]; then + printf 'Version %s (%s): would delete, application PR #%s closed %sh ago\n' \ + "$version_id" "$tags" "$pr_number" "$age_hours" + continue + fi + + printf 'Version %s (%s): deleting, application PR #%s closed %sh ago\n' \ + "$version_id" "$tags" "$pr_number" "$age_hours" + gh api --method DELETE "$versions_path/$version_id" + deleted=$((deleted + 1)) + done < "$work_dir/versions.tsv" + + rm -rf "$work_dir" + { + printf '### Temporary application images\n\n' + printf -- '- package: `ghcr.io/%s/%s`\n' "$PACKAGE_OWNER" "$PACKAGE_NAME" + printf -- '- deletion candidates: %s\n' "$candidates" + printf -- '- deleted: %s\n' "$deleted" + printf -- '- kept: %s\n' "$kept" + if [ "$DRY_RUN" = "true" ]; then + printf -- '- dry run: no version was deleted (set the GHCR_CLEANUP_TOKEN secret to enable deletion)\n' + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/README.md b/README.md index ecf205f..5a7c7eb 100644 --- a/README.md +++ b/README.md @@ -211,10 +211,35 @@ GitHub Actions разделены по стоимости и назначени - `CI` запускается для каждого pull request и push в `main`: сначала выполняет framework quality gate, затем core API suite и короткий Chromium UI smoke на `core-sqlite-local`; - `Configuration matrix` ежедневно в `01:30 UTC` и вручную проверяет PostgreSQL, MySQL, MariaDB, production-like PostgreSQL с persistent runner, SSH, приватный HTTPS Git, прямой и HTTPS/subpath OIDC, LDAPS, TOTP и ротацию database encryption keyring; -- `Release upgrade` еженедельно по воскресеньям в `03:30 UTC` и вручную проверяет обновление `v2.19.8 → v2.19.12` на SQLite и PostgreSQL. +- `Release upgrade` еженедельно по воскресеньям в `03:30 UTC` и вручную проверяет обновление `v2.19.8 → v2.19.12` на SQLite и PostgreSQL; +- `Application PR trigger` принимает `repository_dispatch` из основного репозитория и запускает CI для тестовых PR, явно связанных с изменившимся PR приложения; +- `Cleanup temporary application images` ежедневно в `04:00 UTC` удаляет временные images закрытых и смерженных PR приложения. Matrix jobs используют отдельные GitHub-hosted runners и выполняются параллельно с `fail-fast: false`. JUnit, HTML-отчёты, Allure results и диагностика контейнеров при падении сохраняются как artifacts. Upgrade workflow не входит в PR gate; зелёный job должен означать и сохранность данных, и полную финализацию task output. +### Источник тестов и источник приложения + +Две настройки независимы. `TEST_REPOSITORY` / `TEST_BRANCH` (`git.fixtures.repository` / +`git.fixtures.branch`) по-прежнему определяют только то, какие фикстуры и тесты использовать. +Отдельная группа `APP_REPOSITORY` / `APP_PR` определяет, какую версию приложения тестировать. + +Если application PR не задан, поведение не меняется: основной репозиторий не клонируется, +приложение не собирается, временный Docker image не создаётся, используется image из манифеста +профиля. Чтобы прогнать тесты против конкретного PR основного репозитория, достаточно +раскомментировать блок в `application-under-test.yml` тестового PR: + +```yaml +application: + repository: semaphoreui/semaphore + pull_request: 123 +``` + +CI определяет HEAD SHA этого PR, переиспользует уже опубликованный +`ghcr.io/semaphoreui/integration-tests/semaphore-ci:ci-pr-123-` и собирает приложение только +тогда, когда image для этого commit ещё не существует. Изменение только тестов повторной сборки +не вызывает. Полное описание, включая автозапуск, авторизацию и очистку временных images, — +в [`docs/application-pr-testing.md`](docs/application-pr-testing.md). + При ручном запуске `Configuration matrix` можно включить inputs `include_schedule_investigation` и/или `include_shell_output_investigation`. Тогда к матрице только для этого run добавятся соответствующие известные красные defect-профили, чтобы подтвердить проблему на Linux и собрать diff --git a/application-under-test.yml b/application-under-test.yml new file mode 100644 index 0000000..8a963f8 --- /dev/null +++ b/application-under-test.yml @@ -0,0 +1,21 @@ +# Explicit link between this test repository and a pull request of the application repository. +# +# By default the block below stays commented out. The pipeline then runs in normal mode: the +# application repository is not cloned, nothing is built and the Docker image declared by the +# profile manifest (test-environment/profiles//profile.yaml) is used as before. +# +# To run the tests of a test pull request against the application built from a specific +# application pull request, uncomment the block and set the pull request number. CI resolves the +# HEAD commit of that pull request, reuses the matching temporary image when it already exists, +# and otherwise builds and pushes it. +# +# application: +# repository: semaphoreui/semaphore +# pull_request: 123 +# +# repository is optional and defaults to semaphoreui/semaphore. +# +# The same link can be provided as CI inputs instead of this file (APP_REPOSITORY / APP_PR, or +# the inputs of the "CI" workflow_dispatch). CI inputs take precedence over this file. +# +# See docs/application-pr-testing.md for the full workflow. diff --git a/docs/application-pr-testing.md b/docs/application-pr-testing.md new file mode 100644 index 0000000..98f574a --- /dev/null +++ b/docs/application-pr-testing.md @@ -0,0 +1,250 @@ +# Тестирование Pull Request основного репозитория + +Тестовый и основной репозитории остаются независимыми: submodule не используются, тесты не +переносятся в основной репозиторий, а приложение — в тестовый. Разделены две независимые +настройки. + +| Что определяет | Настройка | Где задаётся | +| --- | --- | --- | +| **Какие тесты запускать** | `TEST_REPOSITORY` / `TEST_BRANCH` (`git.fixtures.repository` / `git.fixtures.branch`) | [MainConfig.java](../src/main/java/io/bookwright/config/MainConfig.java), stand properties, `-D`-параметры | +| **Какую версию приложения тестировать** | `APP_REPOSITORY` / `APP_PR` либо [`application-under-test.yml`](../application-under-test.yml) | CI-переменные, `workflow_dispatch`, декларативный файл | + +Семантика `TEST_REPOSITORY` / `TEST_BRANCH` не изменилась. + +## Два режима + +### Обычный режим (по умолчанию) + +Application PR не указан. Основной репозиторий не клонируется, приложение не собирается, +временный Docker image не создаётся. Используется image из манифеста профиля +(`test-environment/profiles//profile.yaml`, ключ `semaphore_image`) — ровно как раньше. + +```text +clone tests → pull semaphore_image → start application → run tests +``` + +Никаких дополнительных действий при обычной разработке тестов не требуется. + +### PR-режим + +Тестовый прогон явно связан с Pull Request основного репозитория. Pipeline определяет HEAD SHA +этого PR, вычисляет тег временного image, переиспользует его при наличии и собирает только при +отсутствии. + +```text +APP_PR → HEAD SHA → image exists? → (нет: checkout PR → build → push) → start application → run tests +``` + +## Связывание тестового PR с PR приложения + +Связь всегда **явная**. Она никогда не выводится из названия ветки, слова `feature`, совпадения +названий веток или самого факта изменения тестовой ветки. + +### Вариант 1 — декларативный файл (предпочтительный) + +В корне тестового репозитория лежит [`application-under-test.yml`](../application-under-test.yml). +По умолчанию содержимое закомментировано, что соответствует обычному режиму. В тестовом PR +достаточно раскомментировать блок: + +```yaml +application: + repository: semaphoreui/semaphore + pull_request: 123 +``` + +`repository` необязателен и по умолчанию равен `semaphoreui/semaphore`. Принимаются как +`owner/name`, так и полные URL (`https://github.com/semaphoreui/semaphore.git`, +`git@github.com:semaphoreui/semaphore.git`). + +Файл со сломанным синтаксисом приводит к ошибке pipeline, а не к молчаливому откату в обычный +режим. + +### Вариант 2 — CI-переменные + +`APP_REPOSITORY` и `APP_PR` имеют приоритет над файлом. В GitHub Actions они задаются входами +`workflow_dispatch` у workflow **CI**: + +```bash +gh workflow run ci.yml --ref feature/BOOK-123 \ + --field application_repository=semaphoreui/semaphore \ + --field application_pull_request=123 +``` + +## Идентификация и изоляция временных images + +Тег временного image содержит номер PR и полный SHA его HEAD commit: + +```text +ghcr.io/semaphoreui/integration-tests/semaphore-ci:ci-pr-123-abc123456789... +``` + +* два разных commit одного PR дают разные images; +* несколько пар application/test PR никогда не делят один image; +* временные images лежат в отдельном namespace GHCR тестового репозитория, поэтому release-теги + `semaphoreui/semaphore` не читаются, не перезаписываются и вообще не затрагиваются. + +Namespace переопределяется переменной `APP_IMAGE_REPOSITORY`, префикс тега — `APP_IMAGE_TAG_PREFIX`. + +## Повторное использование образа + +Перед сборкой проверяется наличие image для вычисленного SHA: + +| Ситуация | Поведение | +| --- | --- | +| Изменился только тестовый PR, SHA приложения прежний | image существует → `pull → test`, сборка не выполняется | +| В application PR появился новый commit | новый тег → `build → push → test` | +| Application PR не указан | ни клонирования, ни сборки, ни временного image | + +## Автоматический запуск + +### При изменении PR приложения + +Workflow [`application-pr.yml`](../.github/workflows/application-pr.yml) принимает событие +`repository_dispatch` типа `application-pr-updated`, находит **все открытые тестовые PR, явно +связанные с этим PR приложения**, и запускает для них CI. Тестовые PR без связи или связанные с +другим application PR не запускаются, изменение произвольной ветки основного репозитория не +запускает ничего. + +Чтобы включить автозапуск, в основной репозиторий `semaphoreui/semaphore` нужно один раз добавить +`.github/workflows/notify-integration-tests.yml`: + +```yaml +name: Notify integration tests + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +jobs: + notify: + runs-on: ubuntu-latest + steps: + - name: Notify the test repository + env: + GH_TOKEN: ${{ secrets.INTEGRATION_TESTS_DISPATCH_TOKEN }} + run: | + gh api repos/semaphoreui/integration-tests/dispatches \ + --field event_type=application-pr-updated \ + --field 'client_payload[repository]=${{ github.repository }}' \ + --field 'client_payload[pull_request]=${{ github.event.pull_request.number }}' \ + --field 'client_payload[sha]=${{ github.event.pull_request.head.sha }}' +``` + +`INTEGRATION_TESTS_DISPATCH_TOKEN` — токен с правом `contents: write` на тестовый репозиторий +(fine-grained PAT или GitHub App installation token). Токен хранится только в secrets и не +передаётся через параметры командной строки. + +Тот же workflow запускается вручную: + +```bash +gh workflow run application-pr.yml \ + --field application_repository=semaphoreui/semaphore \ + --field application_pull_request=123 +``` + +**Ограничение fork**: у тестового PR из fork `GITHUB_TOKEN` доступен только на чтение, поэтому +такой PR нельзя ни запустить через `workflow_dispatch` (его ветки нет в тестовом репозитории), ни +использовать для push временного image. Такие PR продолжают проверяться собственным событием +`pull_request` в обычном режиме; в логе `Application PR trigger` они отмечаются явно. Для +PR-режима ветку тестового PR нужно держать в самом тестовом репозитории. + +### При изменении тестового PR + +Обычное событие `pull_request` workflow [`ci.yml`](../.github/workflows/ci.yml). Job +`Application source` резолвит связь, переиспользует существующий image и запускает тесты. Если +SHA приложения не изменился, сборка не выполняется. + +## Авторизация + +| Секрет / переменная | Назначение | Обязателен | +| --- | --- | --- | +| `GITHUB_TOKEN` (встроенный) | чтение публичного основного репозитория, push временного image в GHCR тестового репозитория | да, выдаётся автоматически | +| `APPLICATION_REPOSITORY_TOKEN` | чтение и checkout основного репозитория, если он private | только для private | +| `GHCR_CLEANUP_TOKEN` | удаление временных images (`delete:packages`) | только для очистки | +| `vars.APPLICATION_REPOSITORY` | основной репозиторий для очистки, по умолчанию `semaphoreui/semaphore` | нет | + +Токены передаются только через переменные окружения и secrets. При checkout PR используется +git credential helper, читающий токен из окружения, поэтому токен не попадает ни в командную +строку, ни в репозиторий. + +## Очистка временных images + +Workflow [`cleanup-pr-images.yml`](../.github/workflows/cleanup-pr-images.yml) выполняется +ежедневно и удаляет версии пакета `semaphore-ci`, чей тег соответствует закрытому или +смерженному application PR, спустя окно ожидания (`RETENTION_HOURS`, по умолчанию 24 часа). +Обрабатываются только теги вида `ci-pr--` в namespace тестового репозитория — +release images не затрагиваются. Без секрета `GHCR_CLEANUP_TOKEN` workflow работает в режиме +dry-run и только сообщает кандидатов на удаление. + +## Локальный запуск + +Резолв без каких-либо побочных эффектов: + +```bash +APP_PR=123 scripts/app-source.sh resolve +``` + +Сборка локального образа без публикации и прогон профиля против него: + +```bash +export APP_PR=123 +export APP_IMAGE_REPOSITORY=local/semaphore-ci +export APP_BUILD_PUSH=false +eval "$(scripts/app-source.sh ensure | grep '^APP_')" + +test-environment/profile up core-sqlite-local +test-environment/profile test core-sqlite-local +``` + +`test-environment/profile` берёт image из `APP_IMAGE`, если переменная задана, и из манифеста +профиля в противном случае. Полезные переменные сборки: `APP_BUILD_PLATFORM` (по умолчанию +`linux/amd64`), `APP_DOCKERFILE` (по умолчанию `deployment/docker/server/Dockerfile`), +`APP_BUILD_PUSH`. + +## Логирование + +Обычный режим: + +```text +Application source: Docker image +Application image: semaphoreui/semaphore:v2.19.12 +Application build: skipped +``` + +PR-режим с переиспользованием: + +```text +Application source: Pull Request +Application repository: semaphoreui/semaphore +Application PR: #123 +Application SHA: abc123456789... +Application image: ghcr.io/semaphoreui/integration-tests/semaphore-ci:ci-pr-123-abc123456789... +Application image already exists +Application build: skipped +``` + +PR-режим со сборкой: + +```text +Application image not found +Building application... +Application build: completed +``` + +Режим также попадает в Allure environment: `application.source`, `application.repository`, +`application.pull.request`, `semaphore.image`, `semaphore.source.commit`. + +## Обработка ошибок + +| Ситуация | Поведение | +| --- | --- | +| PR приложения не существует | `Application PR #123 not found in `, pipeline падает | +| Нет доступа к репозиторию | `Unable to access application repository `, pipeline падает | +| Не удалось определить SHA | `Unable to resolve the HEAD SHA of application PR #123`, pipeline падает | +| PR получил новый commit во время сборки | сборка прерывается с явным сообщением о рассинхронизации | +| Не удалось собрать image | pipeline падает, Docker build logs остаются в выводе шага | +| Не удалось push-нуть image | pipeline падает после проверки, что image действительно отсутствует в registry | +| Image недоступен для pull | тег считается отсутствующим, выполняется сборка и push | diff --git a/scripts/app-source.sh b/scripts/app-source.sh new file mode 100755 index 0000000..9486452 --- /dev/null +++ b/scripts/app-source.sh @@ -0,0 +1,367 @@ +#!/bin/sh +# +# Resolves which version of the Semaphore application the tests must run against. +# +# Two independent settings exist in this repository: +# +# * the test source - git.fixtures.repository / git.fixtures.branch (TEST_REPOSITORY / +# TEST_BRANCH). It selects which fixtures and test cases are used and is untouched here. +# * the application source - resolved by this script. By default the profile manifest image +# is used and the application repository is never cloned or built. When a test run is +# explicitly linked to a pull request of the application repository, the image is built +# from that pull request HEAD commit and reused across runs. +# +# Usage: +# scripts/app-source.sh link Resolve only the explicit link (no GitHub API, no registry). +# scripts/app-source.sh resolve Resolve the application source and print a human readable +# report plus KEY=value lines on stdout. +# scripts/app-source.sh env Print only the KEY=value lines. +# scripts/app-source.sh ensure Resolve, then build and push the application image when it +# does not exist yet. Prints the same report. +# +# The explicit link between a test run and an application pull request is taken from, in order +# of precedence: +# +# 1. the APP_PR / APP_REPOSITORY environment variables (CI inputs); +# 2. the declarative application-under-test.yml file of this repository. +# +# When neither defines a pull request the script stays in normal mode. It never infers the +# application pull request from branch names. + +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +repository_dir=$(CDPATH= cd -- "$script_dir/.." && pwd) + +DEFAULT_APP_REPOSITORY=semaphoreui/semaphore +DEFAULT_IMAGE_TAG_PREFIX=ci-pr +DEFAULT_APP_DOCKERFILE=deployment/docker/server/Dockerfile + +fail() { + printf 'app-source: %s\n' "$1" >&2 + exit 1 +} + +usage() { + sed -n '3,32p' "$0" | sed 's/^#\{0,1\} \{0,1\}//' +} + +# semaphoreui/semaphore, https://github.com/semaphoreui/semaphore.git and +# git@github.com:semaphoreui/semaphore.git all normalise to semaphoreui/semaphore. +normalise_repository() { + value=$1 + value=${value%.git} + case "$value" in + http://*|https://*) + value=${value#*://} + value=${value#*/} + ;; + *@*:*) + value=${value#*:} + ;; + esac + value=${value#/} + value=${value%/} + + case "$value" in + ''|*/*/*|*[!A-Za-z0-9._/-]*) fail "invalid application repository: $1" ;; + */*) ;; + *) fail "invalid application repository: $1 (expected owner/name)" ;; + esac + printf '%s' "$value" +} + +# Minimal reader for the fixed two-level shape of application-under-test.yml: +# +# application: +# repository: semaphoreui/semaphore +# pull_request: 123 +# +# Comments and blank lines are ignored; anything else is reported as an error rather than +# silently skipped, so a malformed link never degrades into a normal-mode run. +read_declaration() { + key=$1 + file=$2 + awk -v wanted="$key" ' + { line = $0; sub(/[[:space:]]+$/, "", line) } + line ~ /^[[:space:]]*#/ { next } + line == "" { next } + line == "application:" { inside = 1; next } + line ~ /^[^[:space:]]/ { inside = 0; next } + inside && line ~ /^ [A-Za-z_]+:/ { + key = line + sub(/^ /, "", key) + sub(/:.*$/, "", key) + value = line + sub(/^ [A-Za-z_]+:[[:space:]]*/, "", value) + gsub(/^["'"'"']|["'"'"']$/, "", value) + if (key == wanted) { print value } + next + } + inside { printf "app-source: unexpected line in %s: %s\n", FILENAME, $0 > "/dev/stderr"; exit 3 } + ' "$file" +} + +resolve_link() { + app_repository=${APP_REPOSITORY:-} + app_pr=${APP_PR:-} + + declaration_file=${APP_SOURCE_FILE:-$repository_dir/application-under-test.yml} + if [ -f "$declaration_file" ]; then + if [ -z "$app_pr" ]; then + app_pr=$(read_declaration pull_request "$declaration_file") + fi + if [ -z "$app_repository" ]; then + app_repository=$(read_declaration repository "$declaration_file") + fi + fi + + case "$app_pr" in + '') app_source=docker-image ;; + *[!0-9]*|0) fail "invalid application pull request number: $app_pr" ;; + *) app_source=pull-request ;; + esac + + if [ "$app_source" = "docker-image" ]; then + app_repository= + return 0 + fi + + [ -n "$app_repository" ] || app_repository=$DEFAULT_APP_REPOSITORY + app_repository=$(normalise_repository "$app_repository") +} + +# Temporary images live in their own registry namespace so that release tags of +# semaphoreui/semaphore are never read, written or overwritten by this pipeline. +resolve_image_repository() { + if [ -n "${APP_IMAGE_REPOSITORY:-}" ]; then + printf '%s' "$APP_IMAGE_REPOSITORY" + return 0 + fi + tests_repository=${GITHUB_REPOSITORY:-semaphoreui/integration-tests} + printf 'ghcr.io/%s/semaphore-ci' "$(printf '%s' "$tests_repository" | tr '[:upper:]' '[:lower:]')" +} + +resolve_sha() { + command -v gh >/dev/null 2>&1 || fail "the GitHub CLI (gh) is required to resolve application PR #$app_pr" + + if ! api_error=$(gh api "repos/$app_repository/pulls/$app_pr" --jq '.head.sha' 2>&1 >"$sha_file"); then + case "$api_error" in + *"Not Found"*|*"404"*) + # GitHub answers 404 both for a missing pull request and for a repository the token + # cannot see, so probe the repository itself to report the accurate reason. + if gh api "repos/$app_repository" >/dev/null 2>&1; then + fail "Application PR #$app_pr not found in $app_repository" + fi + fail "Unable to access application repository $app_repository" + ;; + *"Bad credentials"*|*"401"*|*"403"*|*"HTTP 403"*|*"gh auth login"*|*"authentication"*) + fail "Unable to access application repository $app_repository" + ;; + *) + printf '%s\n' "$api_error" >&2 + fail "Unable to resolve the HEAD SHA of application PR #$app_pr" + ;; + esac + fi + + app_sha=$(cat "$sha_file") + case "$app_sha" in + ''|null) fail "Unable to resolve the HEAD SHA of application PR #$app_pr" ;; + *[!0-9a-f]*) fail "Unable to resolve the HEAD SHA of application PR #$app_pr (unexpected value: $app_sha)" ;; + esac + [ "${#app_sha}" -eq 40 ] \ + || fail "Unable to resolve the HEAD SHA of application PR #$app_pr (unexpected value: $app_sha)" +} + +image_exists() { + command -v docker >/dev/null 2>&1 || fail "docker is required to inspect $app_image" + docker manifest inspect "$app_image" >/dev/null 2>&1 +} + +checkout_pull_request() { + checkout_dir=$1 + mkdir -p "$checkout_dir" + + # The token is read from the environment by the credential helper instead of being passed on + # the command line or written into the repository. + git -C "$checkout_dir" init --quiet + git -C "$checkout_dir" remote add origin "https://github.com/$app_repository.git" + if ! git -C "$checkout_dir" \ + -c "credential.helper=" \ + -c "credential.helper=!f() { test \"\$1\" = get && printf 'username=x-access-token\npassword=%s\n' \"\${GH_TOKEN:-\${GITHUB_TOKEN:-}}\"; }; f" \ + fetch --quiet --depth 1 origin "refs/pull/$app_pr/head"; then + fail "Unable to access application repository $app_repository (fetch of refs/pull/$app_pr/head failed)" + fi + git -C "$checkout_dir" checkout --quiet FETCH_HEAD + + fetched_sha=$(git -C "$checkout_dir" rev-parse HEAD) + [ "$fetched_sha" = "$app_sha" ] \ + || fail "application PR #$app_pr moved during the run: expected $app_sha, fetched $fetched_sha" +} + +build_and_push() { + command -v docker >/dev/null 2>&1 || fail "docker is required to build $app_image" + + build_root=$(mktemp -d "${TMPDIR:-/tmp}/app-source.XXXXXX") + # shellcheck disable=SC2064 + trap "rm -rf '$build_root'" EXIT INT TERM + checkout_dir="$build_root/source" + checkout_pull_request "$checkout_dir" + + dockerfile=${APP_DOCKERFILE:-$DEFAULT_APP_DOCKERFILE} + [ -f "$checkout_dir/$dockerfile" ] \ + || fail "application Dockerfile $dockerfile does not exist in $app_repository@$app_sha" + + set -- buildx build \ + --file "$checkout_dir/$dockerfile" \ + --platform "${APP_BUILD_PLATFORM:-linux/amd64}" \ + --tag "$app_image" \ + --provenance false + if [ "${APP_BUILD_CACHE:-}" = "gha" ]; then + set -- "$@" --cache-from type=gha --cache-to type=gha,mode=max + fi + if [ "${APP_BUILD_PUSH:-true}" = "true" ]; then + set -- "$@" --push + else + set -- "$@" --load + fi + set -- "$@" "$checkout_dir" + + if ! docker "$@"; then + fail "Unable to build the application image $app_image; see the Docker build logs above" + fi + + if [ "${APP_BUILD_PUSH:-true}" = "true" ] && ! image_exists; then + fail "Unable to push the application image $app_image" + fi + + rm -rf "$build_root" + trap - EXIT INT TERM +} + +report() { + if [ "$app_source" = "docker-image" ]; then + printf 'Application source: Docker image\n' + printf 'Application image: %s\n' "${app_image:-profile manifest default}" + printf 'Application build: skipped\n' + return 0 + fi + + printf 'Application source: Pull Request\n' + printf 'Application repository: %s\n' "$app_repository" + printf 'Application PR: #%s\n' "$app_pr" + printf 'Application SHA: %s\n' "$app_sha" + printf 'Application image: %s\n' "$app_image" + if [ "$app_image_exists" = "true" ]; then + printf 'Application image already exists\n' + printf 'Application build: skipped\n' + else + printf 'Application image not found\n' + printf 'Building application...\n' + fi +} + +print_env() { + printf 'APP_SOURCE=%s\n' "$app_source" + printf 'APP_REPOSITORY=%s\n' "$app_repository" + printf 'APP_PR=%s\n' "$app_pr" + printf 'APP_SHA=%s\n' "$app_sha" + printf 'APP_IMAGE=%s\n' "$app_image" + printf 'APP_IMAGE_EXISTS=%s\n' "$app_image_exists" + printf 'APP_BUILD_REQUIRED=%s\n' "$app_build_required" + printf 'APP_BUILD_PERFORMED=%s\n' "$app_build_performed" +} + +publish_github_outputs() { + [ -n "${GITHUB_OUTPUT:-}" ] || return 0 + { + printf 'app_source=%s\n' "$app_source" + printf 'app_repository=%s\n' "$app_repository" + printf 'app_pr=%s\n' "$app_pr" + printf 'app_sha=%s\n' "$app_sha" + printf 'app_image=%s\n' "$app_image" + printf 'app_image_exists=%s\n' "$app_image_exists" + printf 'app_build_required=%s\n' "$app_build_required" + printf 'app_build_performed=%s\n' "$app_build_performed" + } >> "$GITHUB_OUTPUT" +} + +resolve() { + app_sha= + # A manually provided APP_IMAGE stays untouched in normal mode; it is the documented escape + # hatch for running against an arbitrary already published image. + app_image=${APP_IMAGE:-} + app_image_exists=false + app_build_required=false + app_build_performed=false + + resolve_link + [ "$app_source" = "pull-request" ] || return 0 + + work_dir=$(mktemp -d "${TMPDIR:-/tmp}/app-source.XXXXXX") + sha_file="$work_dir/sha" + resolve_sha + rm -rf "$work_dir" + + app_image="$(resolve_image_repository):${APP_IMAGE_TAG_PREFIX:-$DEFAULT_IMAGE_TAG_PREFIX}-$app_pr-$app_sha" + if image_exists; then + app_image_exists=true + else + app_build_required=true + fi +} + +action=${1:-} +case "$action" in + link) + # Link resolution only: no GitHub API call, no registry access. Used by CI to decide + # whether any application-source work is needed at all. + app_sha= + app_image=${APP_IMAGE:-} + app_image_exists=false + app_build_required=false + app_build_performed=false + resolve_link + # The temporary image name is only known after the HEAD SHA has been resolved. + [ "$app_source" = "docker-image" ] || app_image= + print_env + publish_github_outputs + ;; + resolve) + resolve + report + print_env + publish_github_outputs + ;; + env) + resolve + print_env + publish_github_outputs + ;; + ensure) + resolve + report + if [ "$app_build_required" = "true" ]; then + build_and_push + app_image_exists=true + app_build_required=false + app_build_performed=true + printf 'Application build: completed\n' + printf 'Application image: %s\n' "$app_image" + fi + print_env + publish_github_outputs + ;; + help|-h|--help) + usage + ;; + '') + usage >&2 + exit 2 + ;; + *) + usage >&2 + fail "unknown action: $action" + ;; +esac diff --git a/scripts/tests/test_app_source.py b/scripts/tests/test_app_source.py new file mode 100644 index 0000000..c260f2e --- /dev/null +++ b/scripts/tests/test_app_source.py @@ -0,0 +1,343 @@ +"""Behaviour tests for scripts/app-source.sh. + +The script talks to the GitHub API through `gh` and to the registry through `docker`. Both are +replaced by stubs on PATH, so the tests cover the resolution logic, the reuse decision and the +error handling without any network access or Docker build. +""" + +import os +from pathlib import Path +import stat +import subprocess +import tempfile +import unittest + + +REPOSITORY_ROOT = Path(__file__).parents[2] +SCRIPT = REPOSITORY_ROOT / "scripts" / "app-source.sh" +HEAD_SHA = "ffdb25923c69e3d6e3f62c555fd339014ae03864" + + +class AppSourceTestCase(unittest.TestCase): + def setUp(self): + self._temporary_directory = tempfile.TemporaryDirectory() + self.root = Path(self._temporary_directory.name) + self.stub_dir = self.root / "bin" + self.stub_dir.mkdir() + self.addCleanup(self._temporary_directory.cleanup) + + def write_stub(self, name, body): + path = self.stub_dir / name + path.write_text("#!/bin/sh\n" + body, encoding="utf-8") + path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + def stub_gh(self, sha=HEAD_SHA, pull_status=0, repository_status=0): + self.write_stub( + "gh", + f""" +case "$2" in + */pulls/*) + if [ {pull_status} -ne 0 ]; then + printf 'gh: Not Found (HTTP 404)\\n' >&2 + exit {pull_status} + fi + printf '{sha}\\n' + ;; + *) + exit {repository_status} + ;; +esac +""", + ) + + def stub_docker(self, manifest_status=1): + self.write_stub("docker", f"exit {manifest_status}\n") + + def declaration(self, content): + path = self.root / "application-under-test.yml" + path.write_text(content, encoding="utf-8") + return path + + def run_script(self, action="env", environment=None, expect_success=True): + env = { + "PATH": f"{self.stub_dir}:{os.environ['PATH']}", + "HOME": str(self.root), + "APP_SOURCE_FILE": str(self.root / "missing.yml"), + } + env.update(environment or {}) + completed = subprocess.run( + [str(SCRIPT), action], + capture_output=True, + text=True, + env=env, + check=False, + ) + if expect_success: + self.assertEqual( + 0, completed.returncode, msg=f"stdout={completed.stdout}\nstderr={completed.stderr}" + ) + return completed + + @staticmethod + def parse(output): + values = {} + for line in output.splitlines(): + if "=" in line and line.split("=", 1)[0].isupper(): + key, value = line.split("=", 1) + values[key] = value + return values + + +class NormalModeTest(AppSourceTestCase): + def test_without_a_link_nothing_is_resolved_or_built(self): + self.stub_gh() + self.stub_docker() + + result = self.run_script("resolve") + values = self.parse(result.stdout) + + self.assertEqual("docker-image", values["APP_SOURCE"]) + self.assertEqual("", values["APP_IMAGE"]) + self.assertEqual("", values["APP_SHA"]) + self.assertEqual("false", values["APP_BUILD_REQUIRED"]) + self.assertIn("Application source: Docker image", result.stdout) + self.assertIn("Application build: skipped", result.stdout) + + def test_commented_out_declaration_stays_in_normal_mode(self): + self.stub_gh() + self.stub_docker() + declaration = self.declaration( + "# application:\n# repository: semaphoreui/semaphore\n# pull_request: 123\n" + ) + + values = self.parse( + self.run_script(environment={"APP_SOURCE_FILE": str(declaration)}).stdout + ) + + self.assertEqual("docker-image", values["APP_SOURCE"]) + self.assertEqual("", values["APP_PR"]) + + def test_the_shipped_declaration_template_keeps_normal_mode(self): + self.stub_gh() + self.stub_docker() + + values = self.parse( + self.run_script( + environment={ + "APP_SOURCE_FILE": str(REPOSITORY_ROOT / "application-under-test.yml") + } + ).stdout + ) + + self.assertEqual("docker-image", values["APP_SOURCE"]) + + def test_an_explicit_app_image_is_preserved(self): + self.stub_gh() + self.stub_docker() + + values = self.parse( + self.run_script(environment={"APP_IMAGE": "semaphoreui/semaphore:v2.19.12"}).stdout + ) + + self.assertEqual("docker-image", values["APP_SOURCE"]) + self.assertEqual("semaphoreui/semaphore:v2.19.12", values["APP_IMAGE"]) + + +class PullRequestModeTest(AppSourceTestCase): + def test_environment_link_resolves_sha_and_image(self): + self.stub_gh() + self.stub_docker(manifest_status=1) + + values = self.parse( + self.run_script( + environment={ + "APP_PR": "123", + "APP_IMAGE_REPOSITORY": "ghcr.io/semaphoreui/integration-tests/semaphore-ci", + } + ).stdout + ) + + self.assertEqual("pull-request", values["APP_SOURCE"]) + self.assertEqual("semaphoreui/semaphore", values["APP_REPOSITORY"]) + self.assertEqual(HEAD_SHA, values["APP_SHA"]) + self.assertEqual( + f"ghcr.io/semaphoreui/integration-tests/semaphore-ci:ci-pr-123-{HEAD_SHA}", + values["APP_IMAGE"], + ) + + def test_declarative_link_resolves_the_same_way(self): + self.stub_gh() + self.stub_docker() + declaration = self.declaration( + "application:\n repository: semaphoreui/semaphore\n pull_request: 123\n" + ) + + values = self.parse( + self.run_script(environment={"APP_SOURCE_FILE": str(declaration)}).stdout + ) + + self.assertEqual("pull-request", values["APP_SOURCE"]) + self.assertEqual("123", values["APP_PR"]) + self.assertEqual("semaphoreui/semaphore", values["APP_REPOSITORY"]) + + def test_repository_urls_are_normalised(self): + self.stub_gh() + self.stub_docker() + + for value in ( + "semaphoreui/semaphore", + "https://github.com/semaphoreui/semaphore.git", + "git@github.com:semaphoreui/semaphore.git", + ): + with self.subTest(repository=value): + values = self.parse( + self.run_script( + environment={"APP_PR": "123", "APP_REPOSITORY": value} + ).stdout + ) + self.assertEqual("semaphoreui/semaphore", values["APP_REPOSITORY"]) + + def test_ci_inputs_win_over_the_declarative_file(self): + self.stub_gh() + self.stub_docker() + declaration = self.declaration("application:\n pull_request: 111\n") + + values = self.parse( + self.run_script( + environment={"APP_SOURCE_FILE": str(declaration), "APP_PR": "222"} + ).stdout + ) + + self.assertEqual("222", values["APP_PR"]) + + def test_link_action_resolves_without_gh_or_docker(self): + declaration = self.declaration("application:\n pull_request: 123\n") + + values = self.parse( + self.run_script("link", environment={"APP_SOURCE_FILE": str(declaration)}).stdout + ) + + self.assertEqual("pull-request", values["APP_SOURCE"]) + self.assertEqual("123", values["APP_PR"]) + self.assertEqual("", values["APP_SHA"]) + self.assertEqual("", values["APP_IMAGE"]) + + +class ImageReuseTest(AppSourceTestCase): + def test_an_existing_image_is_reused_without_building(self): + self.stub_gh() + self.stub_docker(manifest_status=0) + + result = self.run_script("ensure", environment={"APP_PR": "123"}) + values = self.parse(result.stdout) + + self.assertEqual("true", values["APP_IMAGE_EXISTS"]) + self.assertEqual("false", values["APP_BUILD_REQUIRED"]) + self.assertEqual("false", values["APP_BUILD_PERFORMED"]) + self.assertIn("Application image already exists", result.stdout) + self.assertIn("Application build: skipped", result.stdout) + + def test_a_missing_image_requests_a_build(self): + self.stub_gh() + self.stub_docker(manifest_status=1) + + result = self.run_script("resolve", environment={"APP_PR": "123"}) + values = self.parse(result.stdout) + + self.assertEqual("false", values["APP_IMAGE_EXISTS"]) + self.assertEqual("true", values["APP_BUILD_REQUIRED"]) + self.assertIn("Application image not found", result.stdout) + self.assertIn("Building application...", result.stdout) + + def test_a_new_commit_of_the_same_pull_request_yields_a_new_image(self): + other_sha = "abc123456789abc123456789abc123456789abcd" + self.stub_docker() + + self.stub_gh(sha=HEAD_SHA) + first = self.parse(self.run_script(environment={"APP_PR": "123"}).stdout)["APP_IMAGE"] + self.stub_gh(sha=other_sha) + second = self.parse(self.run_script(environment={"APP_PR": "123"}).stdout)["APP_IMAGE"] + + self.assertNotEqual(first, second) + self.assertTrue(first.endswith(HEAD_SHA)) + self.assertTrue(second.endswith(other_sha)) + + def test_different_pull_requests_do_not_share_an_image(self): + self.stub_gh() + self.stub_docker() + + images = { + self.parse(self.run_script(environment={"APP_PR": number}).stdout)["APP_IMAGE"] + for number in ("100", "101", "102") + } + + self.assertEqual(3, len(images)) + + def test_temporary_images_use_their_own_namespace(self): + self.stub_gh() + self.stub_docker() + + image = self.parse( + self.run_script( + environment={"APP_PR": "123", "GITHUB_REPOSITORY": "semaphoreui/integration-tests"} + ).stdout + )["APP_IMAGE"] + + self.assertEqual( + f"ghcr.io/semaphoreui/integration-tests/semaphore-ci:ci-pr-123-{HEAD_SHA}", image + ) + self.assertNotIn("semaphoreui/semaphore:", image) + + +class ErrorHandlingTest(AppSourceTestCase): + def test_a_missing_pull_request_fails(self): + self.stub_gh(pull_status=1, repository_status=0) + self.stub_docker() + + result = self.run_script(environment={"APP_PR": "123"}, expect_success=False) + + self.assertEqual(1, result.returncode) + self.assertIn("Application PR #123 not found", result.stderr) + + def test_an_unreachable_repository_fails(self): + self.stub_gh(pull_status=1, repository_status=1) + self.stub_docker() + + result = self.run_script(environment={"APP_PR": "123"}, expect_success=False) + + self.assertEqual(1, result.returncode) + self.assertIn("Unable to access application repository", result.stderr) + + def test_an_unusable_sha_fails(self): + self.stub_gh(sha="null") + self.stub_docker() + + result = self.run_script(environment={"APP_PR": "123"}, expect_success=False) + + self.assertEqual(1, result.returncode) + self.assertIn("Unable to resolve the HEAD SHA", result.stderr) + + def test_a_malformed_declaration_fails_instead_of_degrading(self): + self.stub_gh() + self.stub_docker() + declaration = self.declaration("application:\n pull_request 123\n") + + result = self.run_script( + environment={"APP_SOURCE_FILE": str(declaration)}, expect_success=False + ) + + self.assertNotEqual(0, result.returncode) + self.assertIn("unexpected line", result.stderr) + + def test_a_non_numeric_pull_request_fails(self): + self.stub_gh() + self.stub_docker() + + result = self.run_script(environment={"APP_PR": "feature/BOOK-123"}, expect_success=False) + + self.assertEqual(1, result.returncode) + self.assertIn("invalid application pull request number", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test-environment/profile b/test-environment/profile index 020875c..f6c5ea8 100755 --- a/test-environment/profile +++ b/test-environment/profile @@ -30,6 +30,10 @@ verifies the persisted data and task execution. encryption-rotation-test is available only for encryption rotation profiles. It creates data with the old primary key, hot-reloads a new primary, rekeys database secrets, removes the retired key and verifies the persisted task fixture. + +The application image comes from the profile manifest. Set APP_IMAGE to run the +same profile against another image, for example one built from a pull request of +the application repository by scripts/app-source.sh. EOF } @@ -43,6 +47,28 @@ manifest_value() { sed -n "s/^${key}:[[:space:]]*//p" "$manifest_file" | sed -n '1p' } +# The application under test normally comes from the profile manifest. APP_IMAGE overrides it +# with a temporary image built from an application pull request; see scripts/app-source.sh. +effective_semaphore_image() { + if [ -n "${APP_IMAGE:-}" ]; then + printf '%s' "$APP_IMAGE" + else + manifest_value semaphore_image + fi +} + +report_application_source() { + if [ -n "${APP_IMAGE:-}" ]; then + printf 'Application source: Pull Request\n' + [ -z "${APP_REPOSITORY:-}" ] || printf 'Application repository: %s\n' "$APP_REPOSITORY" + [ -z "${APP_PR:-}" ] || printf 'Application PR: #%s\n' "$APP_PR" + [ -z "${APP_SHA:-}" ] || printf 'Application SHA: %s\n' "$APP_SHA" + else + printf 'Application source: Docker image\n' + fi + printf 'Application image: %s\n' "$selected_semaphore_image" +} + select_profile() { profile_id=$1 case "$profile_id" in @@ -58,7 +84,7 @@ select_profile() { readiness_url=$(manifest_value readiness_url) stand=$(manifest_value stand) setup_service=$(manifest_value setup_service) - selected_semaphore_image=$(manifest_value semaphore_image) + selected_semaphore_image=$(effective_semaphore_image) selected_schedule_timezone=$(manifest_value schedule_timezone) [ -n "$selected_schedule_timezone" ] || selected_schedule_timezone=UTC selected_test_task=$(manifest_value test_task) @@ -295,7 +321,7 @@ write_allure_environment() { allure_dir="$repository_dir/build/allure-results" mkdir -p "$allure_dir" - image=$(manifest_value semaphore_image) + image=$selected_semaphore_image image_reference=$(docker image inspect "$image" --format '{{index .RepoDigests 0}}' 2>/dev/null || true) [ -n "$image_reference" ] || image_reference=$image @@ -329,9 +355,19 @@ write_allure_environment() { { printf 'profile=%s\n' "$profile_id" - printf 'semaphore.version=%s\n' "$(manifest_value semaphore_version)" - printf 'semaphore.image=%s\n' "$image_reference" - printf 'semaphore.source.commit=%s\n' "$(manifest_value source_commit)" + if [ -n "${APP_IMAGE:-}" ]; then + printf 'application.source=pull-request\n' + printf 'application.repository=%s\n' "${APP_REPOSITORY:-}" + printf 'application.pull.request=%s\n' "${APP_PR:-}" + printf 'semaphore.version=%s\n' "pr-${APP_PR:-unknown}" + printf 'semaphore.image=%s\n' "$image_reference" + printf 'semaphore.source.commit=%s\n' "${APP_SHA:-}" + else + printf 'application.source=docker-image\n' + printf 'semaphore.version=%s\n' "$(manifest_value semaphore_version)" + printf 'semaphore.image=%s\n' "$image_reference" + printf 'semaphore.source.commit=%s\n' "$(manifest_value source_commit)" + fi printf 'semaphore.edition=%s\n' "$(manifest_value edition)" printf 'installation=%s\n' "$(manifest_value installation)" printf 'architecture=%s\n' "$(uname -m)" @@ -452,6 +488,7 @@ case "$action" in ;; up) select_profile "${2:-}" + report_application_source prepare_ssh_fixture prepare_tls_fixture prepare_git_https_fixture @@ -467,6 +504,7 @@ case "$action" in select_profile "${2:-}" [ "$selected_encryption_fixture" != "generated" ] \ || fail "profile '$profile_id' requires: test-environment/profile encryption-rotation-test $profile_id" + report_application_source shift 2 prepare_ssh_fixture prepare_tls_fixture @@ -515,7 +553,7 @@ case "$action" in await_ready run_upgrade_phase seed - selected_semaphore_image=$(manifest_value semaphore_image) + selected_semaphore_image=$(effective_semaphore_image) compose up --detach --force-recreate semaphore await_ready write_allure_environment From f514fe5ec851724cceeda69adb31bf384aeb40f7 Mon Sep 17 00:00:00 2001 From: Nick Date: Sat, 5 Sep 2026 08:10:49 +0300 Subject: [PATCH 09/12] fix: pick the right authority for the image reuse check The registry stays the authority when the image is pushed, because the build and the test jobs run on different CI machines and a locally present image says nothing about what the test job can pull. With APP_BUILD_PUSH=false the image never leaves the machine, so the local image store is the authority and a rebuild is correctly skipped on the second run. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/app-source.sh | 9 ++++++++- scripts/tests/test_app_source.py | 33 ++++++++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/scripts/app-source.sh b/scripts/app-source.sh index 9486452..d718a95 100755 --- a/scripts/app-source.sh +++ b/scripts/app-source.sh @@ -176,7 +176,14 @@ resolve_sha() { image_exists() { command -v docker >/dev/null 2>&1 || fail "docker is required to inspect $app_image" - docker manifest inspect "$app_image" >/dev/null 2>&1 + if [ "${APP_BUILD_PUSH:-true}" = "true" ]; then + # The registry is the authority: build and test run on different CI machines, so a locally + # present image says nothing about what the test job will be able to pull. + docker manifest inspect "$app_image" >/dev/null 2>&1 + else + # APP_BUILD_PUSH=false keeps the image on this machine, so the local store is the authority. + docker image inspect "$app_image" >/dev/null 2>&1 + fi } checkout_pull_request() { diff --git a/scripts/tests/test_app_source.py b/scripts/tests/test_app_source.py index c260f2e..63a0dc0 100644 --- a/scripts/tests/test_app_source.py +++ b/scripts/tests/test_app_source.py @@ -50,8 +50,17 @@ def stub_gh(self, sha=HEAD_SHA, pull_status=0, repository_status=0): """, ) - def stub_docker(self, manifest_status=1): - self.write_stub("docker", f"exit {manifest_status}\n") + def stub_docker(self, manifest_status=1, local_status=1): + self.write_stub( + "docker", + f""" +case "$1" in + manifest) exit {manifest_status} ;; + image) exit {local_status} ;; + *) exit 0 ;; +esac +""", + ) def declaration(self, content): path = self.root / "application-under-test.yml" @@ -249,6 +258,26 @@ def test_a_missing_image_requests_a_build(self): self.assertIn("Application image not found", result.stdout) self.assertIn("Building application...", result.stdout) + def test_the_registry_decides_when_the_image_is_pushed(self): + self.stub_gh() + self.stub_docker(manifest_status=1, local_status=0) + + values = self.parse(self.run_script(environment={"APP_PR": "123"}).stdout) + + self.assertEqual("false", values["APP_IMAGE_EXISTS"]) + self.assertEqual("true", values["APP_BUILD_REQUIRED"]) + + def test_the_local_store_decides_when_the_image_is_not_pushed(self): + self.stub_gh() + self.stub_docker(manifest_status=1, local_status=0) + + values = self.parse( + self.run_script(environment={"APP_PR": "123", "APP_BUILD_PUSH": "false"}).stdout + ) + + self.assertEqual("true", values["APP_IMAGE_EXISTS"]) + self.assertEqual("false", values["APP_BUILD_REQUIRED"]) + def test_a_new_commit_of_the_same_pull_request_yields_a_new_image(self): other_sha = "abc123456789abc123456789abc123456789abcd" self.stub_docker() From d527512a92add674a52394fc982155482f613abb Mon Sep 17 00:00:00 2001 From: Nick Date: Sat, 5 Sep 2026 11:58:48 +0300 Subject: [PATCH 10/12] fix: keep a merged declaration from hijacking the normal pipeline application-under-test.yml travels with the test pull request, so an uncommented declaration reaches the default branch on merge. Left alone it would make main build a long-closed application pull request forever, and every branch cut from main would inherit the same stale link - exactly the regression the normal scenario must not have. Two independent guards close this: * the declaration is only read for a test pull request. ci.yml passes use_declaration_file only on pull_request events, so a push to main, a scheduled run or a manual run without inputs always resolves to normal mode. CI inputs keep working regardless. * a closed or merged application pull request falls back to normal mode. Such a pull request has no version left to test - its commits are either abandoned or already on the application default branch - so pinning the tests to it would be wrong. The reason is logged and shown in the run summary. The run summary also reminds the author to comment the block out before merging when the link came from the file. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/_prepare-app-image.yml | 24 ++++++- .github/workflows/ci.yml | 3 + application-under-test.yml | 5 ++ docs/application-pr-testing.md | 22 +++++++ scripts/app-source.sh | 54 +++++++++++++-- scripts/tests/test_app_source.py | 84 +++++++++++++++++++++++- 6 files changed, 184 insertions(+), 8 deletions(-) diff --git a/.github/workflows/_prepare-app-image.yml b/.github/workflows/_prepare-app-image.yml index cf2ac8e..ed414d4 100644 --- a/.github/workflows/_prepare-app-image.yml +++ b/.github/workflows/_prepare-app-image.yml @@ -23,6 +23,14 @@ on: required: false type: string default: "" + use_declaration_file: + description: >- + Honour application-under-test.yml. Only a test pull request may do so: the file travels + with it and reaches the default branch on merge, where a leftover declaration must not + resurrect a build of an old application pull request. + required: false + type: boolean + default: false outputs: app_source: description: docker-image or pull-request @@ -55,7 +63,9 @@ jobs: contents: read packages: write outputs: - app_source: ${{ steps.link.outputs.app_source }} + # The link step only reads the declaration; the image step is what knows whether the + # linked pull request is still open, so its resolution wins when it ran. + app_source: ${{ steps.image.outputs.app_source || steps.link.outputs.app_source }} app_repository: ${{ steps.link.outputs.app_repository }} app_pr: ${{ steps.link.outputs.app_pr }} app_sha: ${{ steps.image.outputs.app_sha }} @@ -63,6 +73,7 @@ jobs: env: APP_REPOSITORY: ${{ inputs.application_repository }} APP_PR: ${{ inputs.application_pull_request }} + APP_LINK_FROM_FILE: ${{ inputs.use_declaration_file }} APP_IMAGE_REPOSITORY: ghcr.io/${{ github.repository }}/semaphore-ci steps: - name: Checkout tests @@ -100,7 +111,9 @@ jobs: - name: Summary env: - APP_SOURCE: ${{ steps.link.outputs.app_source }} + APP_SOURCE: ${{ steps.image.outputs.app_source || steps.link.outputs.app_source }} + APP_PR_STATE: ${{ steps.image.outputs.app_pr_state }} + APP_LINK_SOURCE: ${{ steps.link.outputs.app_link_source }} APP_REPOSITORY: ${{ steps.link.outputs.app_repository }} APP_PR: ${{ steps.link.outputs.app_pr }} APP_SHA: ${{ steps.image.outputs.app_sha }} @@ -119,8 +132,15 @@ jobs: else printf -- '- build: skipped, the image for this commit already existed\n' fi + if [ "$APP_LINK_SOURCE" = "declaration-file" ]; then + printf -- '- **before merging this test pull request, comment the `application` block out in `application-under-test.yml`**\n' + fi else printf '### Application source: Docker image\n\n' + if [ -n "$APP_PR_STATE" ] && [ "$APP_PR_STATE" != "open" ]; then + printf -- '- application pull request #%s is **%s**, so there is no version left to test\n' "$APP_PR" "$APP_PR_STATE" + printf -- '- the run fell back to normal mode\n' + fi printf -- '- the application repository was not cloned and no image was built\n' printf -- '- the profile manifest image is used, as before\n' fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1bd004..3b0af87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,9 @@ jobs: with: application_repository: ${{ inputs.application_repository || '' }} application_pull_request: ${{ inputs.application_pull_request || '' }} + # A push to main and a manual run without inputs must behave exactly as before, even if a + # merged test pull request left an active declaration behind. + use_declaration_file: ${{ github.event_name == 'pull_request' }} secrets: application_repository_token: ${{ secrets.APPLICATION_REPOSITORY_TOKEN }} diff --git a/application-under-test.yml b/application-under-test.yml index 8a963f8..3a528a5 100644 --- a/application-under-test.yml +++ b/application-under-test.yml @@ -15,6 +15,11 @@ # # repository is optional and defaults to semaphoreui/semaphore. # +# Comment the block out again before merging the test pull request, so that the default branch +# keeps this file in its neutral state. Forgetting to do so is not dangerous: a run that is not +# a test pull request ignores this file completely, and a closed or merged application pull +# request falls back to normal mode. CI reminds you about it in the run summary. +# # The same link can be provided as CI inputs instead of this file (APP_REPOSITORY / APP_PR, or # the inputs of the "CI" workflow_dispatch). CI inputs take precedence over this file. # diff --git a/docs/application-pr-testing.md b/docs/application-pr-testing.md index 98f574a..d1c7f41 100644 --- a/docs/application-pr-testing.md +++ b/docs/application-pr-testing.md @@ -59,6 +59,26 @@ application: Файл со сломанным синтаксисом приводит к ошибке pipeline, а не к молчаливому откату в обычный режим. +#### Что происходит после merge тестового PR + +Файл едет вместе с тестовым PR, поэтому раскомментированный блок попадёт в `main`. Само по себе +это безвредно: две независимые защиты гарантируют, что унаследованная связь не превратит обычный +прогон в сборку старого PR приложения. + +1. **Связь из файла читается только в тестовом PR.** `ci.yml` передаёт + `use_declaration_file: ${{ github.event_name == 'pull_request' }}`, поэтому push в `main`, + scheduled-прогоны и ручные запуски без inputs файл игнорируют полностью и всегда работают в + обычном режиме. CI-переменные при этом продолжают действовать. +2. **Закрытый или смерженный PR приложения откатывается в обычный режим.** У такого PR не + осталось версии для тестирования: его коммиты либо заброшены, либо уже в основной ветке + приложения. Pipeline громко пишет причину и берёт image из манифеста профиля вместо того, + чтобы навсегда прибить тесты к устаревшему коммиту. Эта же защита лечит ветки, срезанные от + `main` с унаследованной связью. + +Тем не менее файл рекомендуется вернуть в закомментированное состояние перед merge — это чище и +избавляет от лишнего запроса к GitHub API. CI напоминает об этом в summary каждого прогона, где +связь пришла из файла. + ### Вариант 2 — CI-переменные `APP_REPOSITORY` и `APP_PR` имеют приоритет над файлом. В GitHub Actions они задаются входами @@ -94,6 +114,8 @@ Namespace переопределяется переменной `APP_IMAGE_REPOS | Изменился только тестовый PR, SHA приложения прежний | image существует → `pull → test`, сборка не выполняется | | В application PR появился новый commit | новый тег → `build → push → test` | | Application PR не указан | ни клонирования, ни сборки, ни временного image | +| Application PR закрыт или смержен | откат в обычный режим, сборки нет | +| Прогон не является тестовым PR | файл связи игнорируется, обычный режим | ## Автоматический запуск diff --git a/scripts/app-source.sh b/scripts/app-source.sh index d718a95..0c06481 100755 --- a/scripts/app-source.sh +++ b/scripts/app-source.sh @@ -27,6 +27,13 @@ # # When neither defines a pull request the script stays in normal mode. It never infers the # application pull request from branch names. +# +# Two rules keep a declaration that was merged into the default branch harmless: +# +# * APP_LINK_FROM_FILE=false ignores the file entirely. Every context that is not a test pull +# request sets it, so the default branch always runs in normal mode. +# * a closed or merged application pull request falls back to normal mode, because such a +# pull request has no version left to test. set -eu @@ -43,7 +50,7 @@ fail() { } usage() { - sed -n '3,32p' "$0" | sed 's/^#\{0,1\} \{0,1\}//' + sed -n '3,40p' "$0" | sed 's/^#\{0,1\} \{0,1\}//' } # semaphoreui/semaphore, https://github.com/semaphoreui/semaphore.git and @@ -105,11 +112,19 @@ read_declaration() { resolve_link() { app_repository=${APP_REPOSITORY:-} app_pr=${APP_PR:-} + app_link_source=none + [ -z "$app_pr" ] || app_link_source=ci-input declaration_file=${APP_SOURCE_FILE:-$repository_dir/application-under-test.yml} - if [ -f "$declaration_file" ]; then + # The declaration lives in the test pull request and therefore reaches the default branch once + # that pull request is merged. Contexts that are not a test pull request - a push to the + # default branch, a scheduled run, a release verification - set APP_LINK_FROM_FILE=false, so a + # declaration left behind by a merge can never turn the normal pipeline into a build of some + # old application pull request. + if [ "${APP_LINK_FROM_FILE:-true}" = "true" ] && [ -f "$declaration_file" ]; then if [ -z "$app_pr" ]; then app_pr=$(read_declaration pull_request "$declaration_file") + [ -z "$app_pr" ] || app_link_source=declaration-file fi if [ -z "$app_repository" ]; then app_repository=$(read_declaration repository "$declaration_file") @@ -124,6 +139,7 @@ resolve_link() { if [ "$app_source" = "docker-image" ]; then app_repository= + app_link_source=none return 0 fi @@ -145,7 +161,8 @@ resolve_image_repository() { resolve_sha() { command -v gh >/dev/null 2>&1 || fail "the GitHub CLI (gh) is required to resolve application PR #$app_pr" - if ! api_error=$(gh api "repos/$app_repository/pulls/$app_pr" --jq '.head.sha' 2>&1 >"$sha_file"); then + if ! api_error=$(gh api "repos/$app_repository/pulls/$app_pr" \ + --jq '[.head.sha, .state] | @tsv' 2>&1 >"$sha_file"); then case "$api_error" in *"Not Found"*|*"404"*) # GitHub answers 404 both for a missing pull request and for a repository the token @@ -165,7 +182,8 @@ resolve_sha() { esac fi - app_sha=$(cat "$sha_file") + app_pr_state=$(cut -f2 "$sha_file") + app_sha=$(cut -f1 "$sha_file") case "$app_sha" in ''|null) fail "Unable to resolve the HEAD SHA of application PR #$app_pr" ;; *[!0-9a-f]*) fail "Unable to resolve the HEAD SHA of application PR #$app_pr (unexpected value: $app_sha)" ;; @@ -249,6 +267,13 @@ build_and_push() { report() { if [ "$app_source" = "docker-image" ]; then + if [ -n "$app_pr" ]; then + printf 'Application PR: #%s in %s is %s\n' "$app_pr" "$app_repository" "$app_pr_state" + printf 'A closed or merged pull request has no version left to test.\n' + if [ "$app_link_source" = "declaration-file" ]; then + printf 'Comment the application block out in application-under-test.yml to silence this.\n' + fi + fi printf 'Application source: Docker image\n' printf 'Application image: %s\n' "${app_image:-profile manifest default}" printf 'Application build: skipped\n' @@ -267,12 +292,17 @@ report() { printf 'Application image not found\n' printf 'Building application...\n' fi + if [ "$app_link_source" = "declaration-file" ]; then + printf 'Reminder: comment the application block out in application-under-test.yml before merging this test pull request.\n' + fi } print_env() { printf 'APP_SOURCE=%s\n' "$app_source" printf 'APP_REPOSITORY=%s\n' "$app_repository" printf 'APP_PR=%s\n' "$app_pr" + printf 'APP_PR_STATE=%s\n' "$app_pr_state" + printf 'APP_LINK_SOURCE=%s\n' "$app_link_source" printf 'APP_SHA=%s\n' "$app_sha" printf 'APP_IMAGE=%s\n' "$app_image" printf 'APP_IMAGE_EXISTS=%s\n' "$app_image_exists" @@ -286,6 +316,8 @@ publish_github_outputs() { printf 'app_source=%s\n' "$app_source" printf 'app_repository=%s\n' "$app_repository" printf 'app_pr=%s\n' "$app_pr" + printf 'app_pr_state=%s\n' "$app_pr_state" + printf 'app_link_source=%s\n' "$app_link_source" printf 'app_sha=%s\n' "$app_sha" printf 'app_image=%s\n' "$app_image" printf 'app_image_exists=%s\n' "$app_image_exists" @@ -296,6 +328,7 @@ publish_github_outputs() { resolve() { app_sha= + app_pr_state= # A manually provided APP_IMAGE stays untouched in normal mode; it is the documented escape # hatch for running against an arbitrary already published image. app_image=${APP_IMAGE:-} @@ -311,6 +344,18 @@ resolve() { resolve_sha rm -rf "$work_dir" + # A closed or merged application pull request has no version left to test: its commits are + # either abandoned or already on the application default branch. Building it would pin the + # tests to a stale commit forever, so the run falls back to the normal mode it would have + # used without any link at all. This is what makes a declaration left behind by a merge + # harmless on any branch that inherits it. + if [ "$app_pr_state" != "open" ]; then + app_source=docker-image + app_sha= + app_image=${APP_IMAGE:-} + return 0 + fi + app_image="$(resolve_image_repository):${APP_IMAGE_TAG_PREFIX:-$DEFAULT_IMAGE_TAG_PREFIX}-$app_pr-$app_sha" if image_exists; then app_image_exists=true @@ -325,6 +370,7 @@ case "$action" in # Link resolution only: no GitHub API call, no registry access. Used by CI to decide # whether any application-source work is needed at all. app_sha= + app_pr_state= app_image=${APP_IMAGE:-} app_image_exists=false app_build_required=false diff --git a/scripts/tests/test_app_source.py b/scripts/tests/test_app_source.py index 63a0dc0..ef5e465 100644 --- a/scripts/tests/test_app_source.py +++ b/scripts/tests/test_app_source.py @@ -31,7 +31,7 @@ def write_stub(self, name, body): path.write_text("#!/bin/sh\n" + body, encoding="utf-8") path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - def stub_gh(self, sha=HEAD_SHA, pull_status=0, repository_status=0): + def stub_gh(self, sha=HEAD_SHA, pull_status=0, repository_status=0, state="open"): self.write_stub( "gh", f""" @@ -41,7 +41,7 @@ def stub_gh(self, sha=HEAD_SHA, pull_status=0, repository_status=0): printf 'gh: Not Found (HTTP 404)\\n' >&2 exit {pull_status} fi - printf '{sha}\\n' + printf '{sha}\\t{state}\\n' ;; *) exit {repository_status} @@ -318,6 +318,86 @@ def test_temporary_images_use_their_own_namespace(self): self.assertNotIn("semaphoreui/semaphore:", image) +class MergedDeclarationTest(AppSourceTestCase): + """A declaration reaches the default branch once the test pull request is merged.""" + + def test_a_context_that_is_not_a_test_pull_request_ignores_the_file(self): + self.stub_gh() + self.stub_docker() + declaration = self.declaration( + "application:\n repository: semaphoreui/semaphore\n pull_request: 123\n" + ) + + values = self.parse( + self.run_script( + environment={ + "APP_SOURCE_FILE": str(declaration), + "APP_LINK_FROM_FILE": "false", + } + ).stdout + ) + + self.assertEqual("docker-image", values["APP_SOURCE"]) + self.assertEqual("", values["APP_PR"]) + self.assertEqual("", values["APP_IMAGE"]) + + def test_ci_inputs_still_apply_when_the_file_is_ignored(self): + self.stub_gh() + self.stub_docker() + + values = self.parse( + self.run_script( + environment={"APP_LINK_FROM_FILE": "false", "APP_PR": "123"} + ).stdout + ) + + self.assertEqual("pull-request", values["APP_SOURCE"]) + self.assertEqual("123", values["APP_PR"]) + + def test_a_merged_application_pull_request_falls_back_to_normal_mode(self): + self.stub_gh(state="closed") + self.stub_docker() + + result = self.run_script("resolve", environment={"APP_PR": "123"}) + values = self.parse(result.stdout) + + self.assertEqual("docker-image", values["APP_SOURCE"]) + self.assertEqual("closed", values["APP_PR_STATE"]) + self.assertEqual("", values["APP_IMAGE"]) + self.assertEqual("false", values["APP_BUILD_REQUIRED"]) + self.assertIn("no version left to test", result.stdout) + self.assertIn("Application build: skipped", result.stdout) + + def test_an_open_application_pull_request_still_builds(self): + self.stub_gh(state="open") + self.stub_docker() + + values = self.parse(self.run_script(environment={"APP_PR": "123"}).stdout) + + self.assertEqual("pull-request", values["APP_SOURCE"]) + self.assertEqual("open", values["APP_PR_STATE"]) + self.assertEqual("true", values["APP_BUILD_REQUIRED"]) + + def test_the_declarative_link_reminds_to_revert_before_merge(self): + self.stub_gh() + self.stub_docker() + declaration = self.declaration("application:\n pull_request: 123\n") + + result = self.run_script("resolve", environment={"APP_SOURCE_FILE": str(declaration)}) + + self.assertEqual("declaration-file", self.parse(result.stdout)["APP_LINK_SOURCE"]) + self.assertIn("comment the application block out", result.stdout) + + def test_a_ci_input_link_does_not_remind(self): + self.stub_gh() + self.stub_docker() + + result = self.run_script("resolve", environment={"APP_PR": "123"}) + + self.assertEqual("ci-input", self.parse(result.stdout)["APP_LINK_SOURCE"]) + self.assertNotIn("comment the application block out", result.stdout) + + class ErrorHandlingTest(AppSourceTestCase): def test_a_missing_pull_request_fails(self): self.stub_gh(pull_status=1, repository_status=0) From 781c41369981a4bbad93fb882a60b4f3d8c46c5b Mon Sep 17 00:00:00 2001 From: Nick Date: Sat, 5 Sep 2026 12:06:49 +0300 Subject: [PATCH 11/12] refactor: declare the application pull request in the test PR description The declaration file travelled with the test pull request and reached the default branch on merge, so it needed two guards to stay harmless. The pull request description has no such problem: it is not repository content, it never merges, and nothing downstream can inherit it. It is now the single place a developer declares the link, and application-under-test.yml is gone along with the APP_LINK_FROM_FILE guard it required. One trailer line in the test pull request description is the whole interface: Application-PR: semaphoreui/semaphore#123 "#123", "123" and the full pull request URL are accepted equally, the key is case insensitive, and text inside HTML comments is ignored so a pull request template may carry a commented-out example. Two declarations are an error rather than a silent pick, and the trailer must start a line so prose cannot trigger it. APP_REPOSITORY / APP_PR stay as CI plumbing: the application pull request trigger starts a run for a branch, where no pull request description is in context. The fallback for a closed or merged application pull request stays, because a test pull request can outlive the application pull request it was written for. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/_prepare-app-image.yml | 28 +-- .github/workflows/application-pr.yml | 25 ++- .github/workflows/ci.yml | 10 +- README.md | 13 +- application-under-test.yml | 26 --- docs/application-pr-testing.md | 94 +++++---- scripts/app-source.sh | 171 +++++++++++------ scripts/tests/test_app_source.py | 234 ++++++++++++----------- 8 files changed, 328 insertions(+), 273 deletions(-) delete mode 100644 application-under-test.yml diff --git a/.github/workflows/_prepare-app-image.yml b/.github/workflows/_prepare-app-image.yml index ed414d4..3c5ef26 100644 --- a/.github/workflows/_prepare-app-image.yml +++ b/.github/workflows/_prepare-app-image.yml @@ -2,6 +2,10 @@ name: Prepare application image # Resolves which version of the application the test jobs must run against. # +# The link is declared as an "Application-PR:" trailer in the description of the test pull +# request. It lives outside the repository content on purpose, so merging a test pull request can +# never leave an active link behind on the default branch. +# # Normal mode (no explicit link to an application pull request) does nothing at all: the # application repository is not read, no image is built and no temporary image is created. The # test jobs then keep using the image declared by the profile manifest. @@ -14,23 +18,23 @@ on: workflow_call: inputs: application_repository: - description: Application repository as owner/name; overrides application-under-test.yml + description: Application repository as owner/name; overrides the pull request description required: false type: string default: "" application_pull_request: - description: Application pull request number; overrides application-under-test.yml + description: Application pull request number; overrides the pull request description required: false type: string default: "" - use_declaration_file: + pull_request_body: description: >- - Honour application-under-test.yml. Only a test pull request may do so: the file travels - with it and reaches the default branch on merge, where a leftover declaration must not - resurrect a build of an old application pull request. + Description of the test pull request. It is scanned for the "Application-PR:" trailer + that declares which application pull request to test. Empty outside a pull request, + which is exactly why a link can never leak into the default branch. required: false - type: boolean - default: false + type: string + default: "" outputs: app_source: description: docker-image or pull-request @@ -73,7 +77,9 @@ jobs: env: APP_REPOSITORY: ${{ inputs.application_repository }} APP_PR: ${{ inputs.application_pull_request }} - APP_LINK_FROM_FILE: ${{ inputs.use_declaration_file }} + # Untrusted text: only ever bound to an environment variable, never interpolated into a + # shell command. + APP_LINK_BODY: ${{ inputs.pull_request_body }} APP_IMAGE_REPOSITORY: ghcr.io/${{ github.repository }}/semaphore-ci steps: - name: Checkout tests @@ -132,14 +138,12 @@ jobs: else printf -- '- build: skipped, the image for this commit already existed\n' fi - if [ "$APP_LINK_SOURCE" = "declaration-file" ]; then - printf -- '- **before merging this test pull request, comment the `application` block out in `application-under-test.yml`**\n' - fi else printf '### Application source: Docker image\n\n' if [ -n "$APP_PR_STATE" ] && [ "$APP_PR_STATE" != "open" ]; then printf -- '- application pull request #%s is **%s**, so there is no version left to test\n' "$APP_PR" "$APP_PR_STATE" printf -- '- the run fell back to normal mode\n' + printf -- '- remove the `Application-PR:` line from this pull request description to silence this\n' fi printf -- '- the application repository was not cloned and no image was built\n' printf -- '- the profile manifest image is used, as before\n' diff --git a/.github/workflows/application-pr.yml b/.github/workflows/application-pr.yml index 69d705e..7a8e4e3 100644 --- a/.github/workflows/application-pr.yml +++ b/.github/workflows/application-pr.yml @@ -1,12 +1,12 @@ name: Application PR trigger -# Runs the integration tests of every test pull request that is explicitly linked to the -# application pull request named in the event payload. +# Runs the integration tests of every test pull request whose description declares an +# "Application-PR:" trailer pointing at the application pull request named in the event payload. # # The application repository sends the event; see docs/application-pr-testing.md for the -# workflow snippet it needs. Only test pull requests whose application-under-test.yml declares -# this exact application pull request are started: a change of an arbitrary branch of the -# application repository starts nothing, and the link is never inferred from branch names. +# workflow snippet it needs. Only test pull requests whose description declares this exact +# application pull request are started: a change of an arbitrary branch of the application +# repository starts nothing, and the link is never inferred from branch names. on: repository_dispatch: @@ -76,6 +76,8 @@ jobs: started=0 inspected=0 + # Every open test pull request is listed with its description, which is where the + # Application-PR trailer lives. gh pr list --state open --limit 100 \ --json number,headRefName,isCrossRepository \ --jq '.[] | [.number, .headRefName, (.isCrossRepository | tostring)] | @tsv' \ @@ -92,17 +94,12 @@ jobs: continue fi - declaration="$work_dir/aut-$pr_number.yml" - if ! gh api "repos/$GITHUB_REPOSITORY/contents/application-under-test.yml?ref=$head_ref" \ - --jq '.content' > "$work_dir/aut-$pr_number.b64" 2>/dev/null; then - printf 'Test PR #%s: skipped, no application-under-test.yml on %s\n' "$pr_number" "$head_ref" - continue - fi - base64 -d < "$work_dir/aut-$pr_number.b64" > "$declaration" + body_file="$work_dir/body-$pr_number.md" + gh pr view "$pr_number" --json body --jq '.body // ""' > "$body_file" - if ! link=$(APP_REPOSITORY= APP_PR= APP_SOURCE_FILE="$declaration" \ + if ! link=$(APP_REPOSITORY= APP_PR= APP_LINK_BODY_FILE="$body_file" \ scripts/app-source.sh link 2>"$work_dir/link-error"); then - printf 'Test PR #%s: skipped, application-under-test.yml is invalid\n' "$pr_number" + printf 'Test PR #%s: skipped, the Application-PR declaration is invalid\n' "$pr_number" sed 's/^/ /' "$work_dir/link-error" || true continue fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b0af87..d9c2aa1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,12 +8,12 @@ on: workflow_dispatch: inputs: application_repository: - description: Application repository as owner/name; overrides application-under-test.yml + description: Application repository as owner/name; overrides the pull request description required: false type: string default: "" application_pull_request: - description: Application pull request number; overrides application-under-test.yml + description: Application pull request number; overrides the pull request description required: false type: string default: "" @@ -35,9 +35,9 @@ jobs: with: application_repository: ${{ inputs.application_repository || '' }} application_pull_request: ${{ inputs.application_pull_request || '' }} - # A push to main and a manual run without inputs must behave exactly as before, even if a - # merged test pull request left an active declaration behind. - use_declaration_file: ${{ github.event_name == 'pull_request' }} + # Empty for a push to main, a scheduled run or a manual run, so those keep behaving + # exactly as before. + pull_request_body: ${{ github.event.pull_request.body }} secrets: application_repository_token: ${{ secrets.APPLICATION_REPOSITORY_TOKEN }} diff --git a/README.md b/README.md index 5a7c7eb..3125f45 100644 --- a/README.md +++ b/README.md @@ -225,15 +225,16 @@ Matrix jobs используют отдельные GitHub-hosted runners и в Если application PR не задан, поведение не меняется: основной репозиторий не клонируется, приложение не собирается, временный Docker image не создаётся, используется image из манифеста -профиля. Чтобы прогнать тесты против конкретного PR основного репозитория, достаточно -раскомментировать блок в `application-under-test.yml` тестового PR: +профиля. Чтобы прогнать тесты против конкретного PR основного репозитория, достаточно добавить +одну строку в **описание тестового PR**: -```yaml -application: - repository: semaphoreui/semaphore - pull_request: 123 +```text +Application-PR: semaphoreui/semaphore#123 ``` +Описание PR выбрано намеренно: в отличие от файла в репозитории оно не попадает в `main` при +merge, поэтому забытая связь не может повлиять на обычные прогоны. + CI определяет HEAD SHA этого PR, переиспользует уже опубликованный `ghcr.io/semaphoreui/integration-tests/semaphore-ci:ci-pr-123-` и собирает приложение только тогда, когда image для этого commit ещё не существует. Изменение только тестов повторной сборки diff --git a/application-under-test.yml b/application-under-test.yml deleted file mode 100644 index 3a528a5..0000000 --- a/application-under-test.yml +++ /dev/null @@ -1,26 +0,0 @@ -# Explicit link between this test repository and a pull request of the application repository. -# -# By default the block below stays commented out. The pipeline then runs in normal mode: the -# application repository is not cloned, nothing is built and the Docker image declared by the -# profile manifest (test-environment/profiles//profile.yaml) is used as before. -# -# To run the tests of a test pull request against the application built from a specific -# application pull request, uncomment the block and set the pull request number. CI resolves the -# HEAD commit of that pull request, reuses the matching temporary image when it already exists, -# and otherwise builds and pushes it. -# -# application: -# repository: semaphoreui/semaphore -# pull_request: 123 -# -# repository is optional and defaults to semaphoreui/semaphore. -# -# Comment the block out again before merging the test pull request, so that the default branch -# keeps this file in its neutral state. Forgetting to do so is not dangerous: a run that is not -# a test pull request ignores this file completely, and a closed or merged application pull -# request falls back to normal mode. CI reminds you about it in the run summary. -# -# The same link can be provided as CI inputs instead of this file (APP_REPOSITORY / APP_PR, or -# the inputs of the "CI" workflow_dispatch). CI inputs take precedence over this file. -# -# See docs/application-pr-testing.md for the full workflow. diff --git a/docs/application-pr-testing.md b/docs/application-pr-testing.md index d1c7f41..dce0cac 100644 --- a/docs/application-pr-testing.md +++ b/docs/application-pr-testing.md @@ -7,7 +7,7 @@ | Что определяет | Настройка | Где задаётся | | --- | --- | --- | | **Какие тесты запускать** | `TEST_REPOSITORY` / `TEST_BRANCH` (`git.fixtures.repository` / `git.fixtures.branch`) | [MainConfig.java](../src/main/java/io/bookwright/config/MainConfig.java), stand properties, `-D`-параметры | -| **Какую версию приложения тестировать** | `APP_REPOSITORY` / `APP_PR` либо [`application-under-test.yml`](../application-under-test.yml) | CI-переменные, `workflow_dispatch`, декларативный файл | +| **Какую версию приложения тестировать** | строка `Application-PR:` в описании тестового PR | описание PR; `APP_REPOSITORY` / `APP_PR` как служебный механизм CI | Семантика `TEST_REPOSITORY` / `TEST_BRANCH` не изменилась. @@ -15,8 +15,8 @@ ### Обычный режим (по умолчанию) -Application PR не указан. Основной репозиторий не клонируется, приложение не собирается, -временный Docker image не создаётся. Используется image из манифеста профиля +В описании тестового PR нет строки `Application-PR:`. Основной репозиторий не клонируется, +приложение не собирается, временный Docker image не создаётся. Используется image из манифеста профиля (`test-environment/profiles//profile.yaml`, ключ `semaphore_image`) — ровно как раньше. ```text @@ -40,49 +40,50 @@ APP_PR → HEAD SHA → image exists? → (нет: checkout PR → build → pus Связь всегда **явная**. Она никогда не выводится из названия ветки, слова `feature`, совпадения названий веток или самого факта изменения тестовой ветки. -### Вариант 1 — декларативный файл (предпочтительный) +Единственное место, где разработчик её задаёт, — **описание тестового PR**. Достаточно добавить +одну строку: -В корне тестового репозитория лежит [`application-under-test.yml`](../application-under-test.yml). -По умолчанию содержимое закомментировано, что соответствует обычному режиму. В тестовом PR -достаточно раскомментировать блок: - -```yaml -application: - repository: semaphoreui/semaphore - pull_request: 123 +```text +Application-PR: semaphoreui/semaphore#123 ``` -`repository` необязателен и по умолчанию равен `semaphoreui/semaphore`. Принимаются как -`owner/name`, так и полные URL (`https://github.com/semaphoreui/semaphore.git`, -`git@github.com:semaphoreui/semaphore.git`). +Всё. Дальше CI делает остальное. -Файл со сломанным синтаксисом приводит к ошибке pipeline, а не к молчаливому откату в обычный -режим. +### Почему именно описание PR -#### Что происходит после merge тестового PR +Описание PR не является частью содержимого репозитория и **не попадает в `main` при merge**. +Поэтому забытая связь физически не может превратить обычный прогон `main` в сборку давно +закрытого PR приложения, а ветки, срезанные от `main`, ничего не наследуют. Файл в репозитории +такой гарантии не даёт — он мержится вместе с PR. + +### Принимаемые формы + +| Запись | Смысл | +| --- | --- | +| `Application-PR: semaphoreui/semaphore#123` | репозиторий и номер явно | +| `Application-PR: #123` | репозиторий по умолчанию — `semaphoreui/semaphore` | +| `Application-PR: 123` | то же самое | +| `Application-PR: https://github.com/semaphoreui/semaphore/pull/123` | ссылка целиком, можно с `/files` | -Файл едет вместе с тестовым PR, поэтому раскомментированный блок попадёт в `main`. Само по себе -это безвредно: две независимые защиты гарантируют, что унаследованная связь не превратит обычный -прогон в сборку старого PR приложения. +Ключ нечувствителен к регистру и допускает `Application PR:` и `Application_PR:`. Строка должна +начинать строку описания — упоминание `Application-PR:` внутри предложения связью не считается. +Текст внутри HTML-комментариев игнорируется, поэтому шаблон PR может содержать +закомментированный пример. -1. **Связь из файла читается только в тестовом PR.** `ci.yml` передаёт - `use_declaration_file: ${{ github.event_name == 'pull_request' }}`, поэтому push в `main`, - scheduled-прогоны и ручные запуски без inputs файл игнорируют полностью и всегда работают в - обычном режиме. CI-переменные при этом продолжают действовать. -2. **Закрытый или смерженный PR приложения откатывается в обычный режим.** У такого PR не - осталось версии для тестирования: его коммиты либо заброшены, либо уже в основной ветке - приложения. Pipeline громко пишет причину и берёт image из манифеста профиля вместо того, - чтобы навсегда прибить тесты к устаревшему коммиту. Эта же защита лечит ветки, срезанные от - `main` с унаследованной связью. +Две и более строки `Application-PR:` — ошибка pipeline, а не молчаливый выбор одной из них. -Тем не менее файл рекомендуется вернуть в закомментированное состояние перед merge — это чище и -избавляет от лишнего запроса к GitHub API. CI напоминает об этом в summary каждого прогона, где -связь пришла из файла. +### Что происходит после merge PR приложения -### Вариант 2 — CI-переменные +Пока тестовый PR открыт, его PR приложения может быть смержен. У такого PR не осталось версии +для тестирования: коммиты уже в основной ветке приложения. Pipeline громко пишет причину и +откатывается в обычный режим — берёт image из манифеста профиля, вместо того чтобы навсегда +прибить тесты к устаревшему коммиту. Строку из описания после этого стоит убрать. -`APP_REPOSITORY` и `APP_PR` имеют приоритет над файлом. В GitHub Actions они задаются входами -`workflow_dispatch` у workflow **CI**: +### CI-переменные + +`APP_REPOSITORY` и `APP_PR` — служебный механизм CI, а не способ ручного объявления связи. Через +них workflow автозапуска стартует прогон для ветки, где контекста PR (а значит и описания) нет. +Они имеют приоритет над описанием. Тот же путь доступен вручную: ```bash gh workflow run ci.yml --ref feature/BOOK-123 \ @@ -113,17 +114,17 @@ Namespace переопределяется переменной `APP_IMAGE_REPOS | --- | --- | | Изменился только тестовый PR, SHA приложения прежний | image существует → `pull → test`, сборка не выполняется | | В application PR появился новый commit | новый тег → `build → push → test` | -| Application PR не указан | ни клонирования, ни сборки, ни временного image | +| В описании нет `Application-PR:` | ни клонирования, ни сборки, ни временного image | | Application PR закрыт или смержен | откат в обычный режим, сборки нет | -| Прогон не является тестовым PR | файл связи игнорируется, обычный режим | +| Прогон не является тестовым PR | описания нет в контексте, обычный режим | ## Автоматический запуск ### При изменении PR приложения Workflow [`application-pr.yml`](../.github/workflows/application-pr.yml) принимает событие -`repository_dispatch` типа `application-pr-updated`, находит **все открытые тестовые PR, явно -связанные с этим PR приложения**, и запускает для них CI. Тестовые PR без связи или связанные с +`repository_dispatch` типа `application-pr-updated`, читает описания всех открытых тестовых PR и +находит **те, что явно объявили связь с этим PR приложения**, после чего запускает для них CI. Тестовые PR без связи или связанные с другим application PR не запускаются, изменение произвольной ветки основного репозитория не запускает ничего. @@ -175,8 +176,8 @@ PR-режима ветку тестового PR нужно держать в с ### При изменении тестового PR -Обычное событие `pull_request` workflow [`ci.yml`](../.github/workflows/ci.yml). Job -`Application source` резолвит связь, переиспользует существующий image и запускает тесты. Если +Обычное событие `pull_request` workflow [`ci.yml`](../.github/workflows/ci.yml). Оно передаёт +описание PR в job `Application source`, который резолвит связь, переиспользует существующий image и запускает тесты. Если SHA приложения не изменился, сборка не выполняется. ## Авторизация @@ -205,6 +206,13 @@ dry-run и только сообщает кандидатов на удален Резолв без каких-либо побочных эффектов: +```bash +APP_LINK_BODY='Application-PR: semaphoreui/semaphore#123' scripts/app-source.sh resolve +``` + +Описание можно передать и файлом — `APP_LINK_BODY_FILE=path`. Для локальных экспериментов проще +использовать служебные `APP_PR` / `APP_REPOSITORY`: + ```bash APP_PR=123 scripts/app-source.sh resolve ``` @@ -266,6 +274,8 @@ Application build: completed | PR приложения не существует | `Application PR #123 not found in `, pipeline падает | | Нет доступа к репозиторию | `Unable to access application repository `, pipeline падает | | Не удалось определить SHA | `Unable to resolve the HEAD SHA of application PR #123`, pipeline падает | +| Две строки `Application-PR:` в описании | pipeline падает, выбор одной из них не делается | +| `Application-PR:` не похож на ссылку на PR | pipeline падает с указанием исходного значения | | PR получил новый commit во время сборки | сборка прерывается с явным сообщением о рассинхронизации | | Не удалось собрать image | pipeline падает, Docker build logs остаются в выводе шага | | Не удалось push-нуть image | pipeline падает после проверки, что image действительно отсутствует в registry | diff --git a/scripts/app-source.sh b/scripts/app-source.sh index 0c06481..8f6faf4 100755 --- a/scripts/app-source.sh +++ b/scripts/app-source.sh @@ -19,21 +19,27 @@ # scripts/app-source.sh ensure Resolve, then build and push the application image when it # does not exist yet. Prints the same report. # -# The explicit link between a test run and an application pull request is taken from, in order -# of precedence: +# The link is declared in the description of the test pull request, as a single trailer line: # -# 1. the APP_PR / APP_REPOSITORY environment variables (CI inputs); -# 2. the declarative application-under-test.yml file of this repository. +# Application-PR: semaphoreui/semaphore#123 # -# When neither defines a pull request the script stays in normal mode. It never infers the -# application pull request from branch names. +# Accepted equally: "#123", "123" and the full pull request URL. The repository defaults to +# semaphoreui/semaphore. The key is case insensitive and also accepts "Application PR:" and +# "Application_PR:". Text inside HTML comments is ignored, so a pull request template may carry +# a commented-out example. # -# Two rules keep a declaration that was merged into the default branch harmless: +# The description is deliberately the only place a developer declares the link: unlike a file in +# the repository it never reaches the default branch when the test pull request is merged, so a +# forgotten link cannot turn the normal pipeline into a build of some old application pull +# request. The description reaches this script through APP_LINK_BODY or APP_LINK_BODY_FILE. # -# * APP_LINK_FROM_FILE=false ignores the file entirely. Every context that is not a test pull -# request sets it, so the default branch always runs in normal mode. -# * a closed or merged application pull request falls back to normal mode, because such a -# pull request has no version left to test. +# APP_PR / APP_REPOSITORY stay available as CI plumbing: the application pull request trigger +# uses them to start a run for a branch, where no pull request description is in context. They +# take precedence over the description. +# +# Without a declared pull request the script stays in normal mode. It never infers the +# application pull request from branch names. A closed or merged application pull request also +# falls back to normal mode, because it has no version left to test. set -eu @@ -50,7 +56,7 @@ fail() { } usage() { - sed -n '3,40p' "$0" | sed 's/^#\{0,1\} \{0,1\}//' + sed -n '3,42p' "$0" | sed 's/^#\{0,1\} \{0,1\}//' } # semaphoreui/semaphore, https://github.com/semaphoreui/semaphore.git and @@ -78,35 +84,94 @@ normalise_repository() { printf '%s' "$value" } -# Minimal reader for the fixed two-level shape of application-under-test.yml: -# -# application: -# repository: semaphoreui/semaphore -# pull_request: 123 -# -# Comments and blank lines are ignored; anything else is reported as an error rather than -# silently skipped, so a malformed link never degrades into a normal-mode run. -read_declaration() { - key=$1 - file=$2 - awk -v wanted="$key" ' - { line = $0; sub(/[[:space:]]+$/, "", line) } - line ~ /^[[:space:]]*#/ { next } - line == "" { next } - line == "application:" { inside = 1; next } - line ~ /^[^[:space:]]/ { inside = 0; next } - inside && line ~ /^ [A-Za-z_]+:/ { - key = line - sub(/^ /, "", key) - sub(/:.*$/, "", key) - value = line - sub(/^ [A-Za-z_]+:[[:space:]]*/, "", value) - gsub(/^["'"'"']|["'"'"']$/, "", value) - if (key == wanted) { print value } - next +# Extracts every "Application-PR:" trailer from the pull request description. Text inside HTML +# comments is stripped first, so the commented-out example of a pull request template is not +# mistaken for a real declaration. +BODY_LINK_AWK=' +{ + line = $0 + sub(/\r$/, "", line) + visible = "" + rest = line + while (rest != "") { + if (in_comment) { + position = index(rest, "-->") + if (position == 0) { rest = ""; break } + rest = substr(rest, position + 3) + in_comment = 0 + } else { + position = index(rest, "\n" + "Ordinary test change.\n" ) + values = self.parse(self.run_script(environment={"APP_LINK_BODY": body}).stdout) + + self.assertEqual("docker-image", values["APP_SOURCE"]) + self.assertEqual("", values["APP_PR"]) + + def test_the_trailer_must_start_a_line(self): + self.stub_gh() + self.stub_docker() + body = "We considered the Application-PR: semaphoreui/semaphore#999 approach but did not." + + values = self.parse(self.run_script(environment={"APP_LINK_BODY": body}).stdout) + self.assertEqual("docker-image", values["APP_SOURCE"]) def test_an_explicit_app_image_is_preserved(self): @@ -152,15 +169,16 @@ def test_an_explicit_app_image_is_preserved(self): self.assertEqual("semaphoreui/semaphore:v2.19.12", values["APP_IMAGE"]) -class PullRequestModeTest(AppSourceTestCase): - def test_environment_link_resolves_sha_and_image(self): +class DescriptionLinkTest(AppSourceTestCase): + def test_owner_name_and_number(self): self.stub_gh() - self.stub_docker(manifest_status=1) + self.stub_docker() + body = "Covers the new runner isolation.\n\nApplication-PR: semaphoreui/semaphore#123\n" values = self.parse( self.run_script( environment={ - "APP_PR": "123", + "APP_LINK_BODY": body, "APP_IMAGE_REPOSITORY": "ghcr.io/semaphoreui/integration-tests/semaphore-ci", } ).stdout @@ -168,62 +186,84 @@ def test_environment_link_resolves_sha_and_image(self): self.assertEqual("pull-request", values["APP_SOURCE"]) self.assertEqual("semaphoreui/semaphore", values["APP_REPOSITORY"]) - self.assertEqual(HEAD_SHA, values["APP_SHA"]) + self.assertEqual("123", values["APP_PR"]) + self.assertEqual("pull-request-body", values["APP_LINK_SOURCE"]) self.assertEqual( f"ghcr.io/semaphoreui/integration-tests/semaphore-ci:ci-pr-123-{HEAD_SHA}", values["APP_IMAGE"], ) - def test_declarative_link_resolves_the_same_way(self): + def test_every_accepted_reference_form(self): + self.stub_gh() + self.stub_docker() + + for reference in ( + "semaphoreui/semaphore#123", + "#123", + "123", + "https://github.com/semaphoreui/semaphore/pull/123", + "https://github.com/semaphoreui/semaphore/pull/123/files", + ): + with self.subTest(reference=reference): + values = self.parse( + self.run_script( + environment={"APP_LINK_BODY": f"Application-PR: {reference}"} + ).stdout + ) + self.assertEqual("pull-request", values["APP_SOURCE"]) + self.assertEqual("123", values["APP_PR"]) + self.assertEqual("semaphoreui/semaphore", values["APP_REPOSITORY"]) + + def test_the_key_is_case_and_separator_insensitive(self): + self.stub_gh() + self.stub_docker() + + for key in ("Application-PR", "application pr", "APPLICATION_PR", " Application-Pr"): + with self.subTest(key=key): + values = self.parse( + self.run_script(environment={"APP_LINK_BODY": f"{key}: #123"}).stdout + ) + self.assertEqual("123", values["APP_PR"]) + + def test_a_windows_style_description_is_accepted(self): self.stub_gh() self.stub_docker() - declaration = self.declaration( - "application:\n repository: semaphoreui/semaphore\n pull_request: 123\n" - ) values = self.parse( - self.run_script(environment={"APP_SOURCE_FILE": str(declaration)}).stdout + self.run_script( + environment={"APP_LINK_BODY": "Summary\r\n\r\nApplication-PR: #123\r\n"} + ).stdout ) - self.assertEqual("pull-request", values["APP_SOURCE"]) self.assertEqual("123", values["APP_PR"]) - self.assertEqual("semaphoreui/semaphore", values["APP_REPOSITORY"]) - def test_repository_urls_are_normalised(self): + def test_the_description_can_be_supplied_as_a_file(self): self.stub_gh() self.stub_docker() + body = self.body_file("Application-PR: semaphoreui/semaphore#123\n") - for value in ( - "semaphoreui/semaphore", - "https://github.com/semaphoreui/semaphore.git", - "git@github.com:semaphoreui/semaphore.git", - ): - with self.subTest(repository=value): - values = self.parse( - self.run_script( - environment={"APP_PR": "123", "APP_REPOSITORY": value} - ).stdout - ) - self.assertEqual("semaphoreui/semaphore", values["APP_REPOSITORY"]) + values = self.parse( + self.run_script(environment={"APP_LINK_BODY_FILE": str(body)}).stdout + ) + + self.assertEqual("123", values["APP_PR"]) - def test_ci_inputs_win_over_the_declarative_file(self): + def test_ci_inputs_win_over_the_description(self): self.stub_gh() self.stub_docker() - declaration = self.declaration("application:\n pull_request: 111\n") values = self.parse( self.run_script( - environment={"APP_SOURCE_FILE": str(declaration), "APP_PR": "222"} + environment={"APP_LINK_BODY": "Application-PR: #111", "APP_PR": "222"} ).stdout ) self.assertEqual("222", values["APP_PR"]) + self.assertEqual("ci-input", values["APP_LINK_SOURCE"]) def test_link_action_resolves_without_gh_or_docker(self): - declaration = self.declaration("application:\n pull_request: 123\n") - values = self.parse( - self.run_script("link", environment={"APP_SOURCE_FILE": str(declaration)}).stdout + self.run_script("link", environment={"APP_LINK_BODY": "Application-PR: #123"}).stdout ) self.assertEqual("pull-request", values["APP_SOURCE"]) @@ -318,47 +358,14 @@ def test_temporary_images_use_their_own_namespace(self): self.assertNotIn("semaphoreui/semaphore:", image) -class MergedDeclarationTest(AppSourceTestCase): - """A declaration reaches the default branch once the test pull request is merged.""" - - def test_a_context_that_is_not_a_test_pull_request_ignores_the_file(self): - self.stub_gh() - self.stub_docker() - declaration = self.declaration( - "application:\n repository: semaphoreui/semaphore\n pull_request: 123\n" - ) - - values = self.parse( - self.run_script( - environment={ - "APP_SOURCE_FILE": str(declaration), - "APP_LINK_FROM_FILE": "false", - } - ).stdout - ) - - self.assertEqual("docker-image", values["APP_SOURCE"]) - self.assertEqual("", values["APP_PR"]) - self.assertEqual("", values["APP_IMAGE"]) - - def test_ci_inputs_still_apply_when_the_file_is_ignored(self): - self.stub_gh() - self.stub_docker() - - values = self.parse( - self.run_script( - environment={"APP_LINK_FROM_FILE": "false", "APP_PR": "123"} - ).stdout - ) - - self.assertEqual("pull-request", values["APP_SOURCE"]) - self.assertEqual("123", values["APP_PR"]) - +class ClosedApplicationPullRequestTest(AppSourceTestCase): def test_a_merged_application_pull_request_falls_back_to_normal_mode(self): self.stub_gh(state="closed") self.stub_docker() - result = self.run_script("resolve", environment={"APP_PR": "123"}) + result = self.run_script( + "resolve", environment={"APP_LINK_BODY": "Application-PR: #123"} + ) values = self.parse(result.stdout) self.assertEqual("docker-image", values["APP_SOURCE"]) @@ -366,6 +373,7 @@ def test_a_merged_application_pull_request_falls_back_to_normal_mode(self): self.assertEqual("", values["APP_IMAGE"]) self.assertEqual("false", values["APP_BUILD_REQUIRED"]) self.assertIn("no version left to test", result.stdout) + self.assertIn("Remove the Application-PR line", result.stdout) self.assertIn("Application build: skipped", result.stdout) def test_an_open_application_pull_request_still_builds(self): @@ -378,25 +386,6 @@ def test_an_open_application_pull_request_still_builds(self): self.assertEqual("open", values["APP_PR_STATE"]) self.assertEqual("true", values["APP_BUILD_REQUIRED"]) - def test_the_declarative_link_reminds_to_revert_before_merge(self): - self.stub_gh() - self.stub_docker() - declaration = self.declaration("application:\n pull_request: 123\n") - - result = self.run_script("resolve", environment={"APP_SOURCE_FILE": str(declaration)}) - - self.assertEqual("declaration-file", self.parse(result.stdout)["APP_LINK_SOURCE"]) - self.assertIn("comment the application block out", result.stdout) - - def test_a_ci_input_link_does_not_remind(self): - self.stub_gh() - self.stub_docker() - - result = self.run_script("resolve", environment={"APP_PR": "123"}) - - self.assertEqual("ci-input", self.parse(result.stdout)["APP_LINK_SOURCE"]) - self.assertNotIn("comment the application block out", result.stdout) - class ErrorHandlingTest(AppSourceTestCase): def test_a_missing_pull_request_fails(self): @@ -426,19 +415,44 @@ def test_an_unusable_sha_fails(self): self.assertEqual(1, result.returncode) self.assertIn("Unable to resolve the HEAD SHA", result.stderr) - def test_a_malformed_declaration_fails_instead_of_degrading(self): + def test_two_declarations_fail_instead_of_picking_one(self): self.stub_gh() self.stub_docker() - declaration = self.declaration("application:\n pull_request 123\n") + body = "Application-PR: #111\nApplication-PR: #222\n" result = self.run_script( - environment={"APP_SOURCE_FILE": str(declaration)}, expect_success=False + environment={"APP_LINK_BODY": body}, expect_success=False ) - self.assertNotEqual(0, result.returncode) - self.assertIn("unexpected line", result.stderr) + self.assertEqual(1, result.returncode) + self.assertIn("declares Application-PR 2 times", result.stderr) + + def test_an_unusable_reference_fails(self): + self.stub_gh() + self.stub_docker() + + for reference in ("feature/BOOK-123", "#0", "https://github.com/semaphoreui/semaphore"): + with self.subTest(reference=reference): + result = self.run_script( + environment={"APP_LINK_BODY": f"Application-PR: {reference}"}, + expect_success=False, + ) + self.assertEqual(1, result.returncode) + self.assertIn("Application-PR in the pull request description", result.stderr) + + def test_a_missing_body_file_fails(self): + self.stub_gh() + self.stub_docker() + + result = self.run_script( + environment={"APP_LINK_BODY_FILE": str(self.root / "absent.md")}, + expect_success=False, + ) + + self.assertEqual(1, result.returncode) + self.assertIn("APP_LINK_BODY_FILE does not exist", result.stderr) - def test_a_non_numeric_pull_request_fails(self): + def test_a_non_numeric_ci_input_fails(self): self.stub_gh() self.stub_docker() From 3a218d4a046ce5475240a2153078471e54d63c7e Mon Sep 17 00:00:00 2001 From: Nick Date: Sat, 5 Sep 2026 12:10:27 +0300 Subject: [PATCH 12/12] fix: rerun CI when the pull request description changes The application pull request is declared in the description, but the default pull_request event types are opened, synchronize and reopened - not edited. Adding the Application-PR line to an existing pull request therefore started nothing, and a manual re-run could not help either: it replays the original event payload, which still carries the description the pull request was opened with. Subscribing to edited makes the declared interface actually usable. The existing concurrency group cancels the superseded run, so an edit costs at most one restart. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 9 +++++++++ docs/application-pr-testing.md | 4 ++++ 2 files changed, 13 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9c2aa1..4b91c6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,15 @@ name: CI on: pull_request: + # The application pull request is declared in the description, so editing the description + # must be able to start a run. A re-run would not do: it replays the original event payload, + # which still carries the description the pull request was opened with. The concurrency group + # below cancels the superseded run, so an edit costs at most one restart. + types: + - opened + - synchronize + - reopened + - edited push: branches: - main diff --git a/docs/application-pr-testing.md b/docs/application-pr-testing.md index dce0cac..51783f5 100644 --- a/docs/application-pr-testing.md +++ b/docs/application-pr-testing.md @@ -72,6 +72,10 @@ Application-PR: semaphoreui/semaphore#123 Две и более строки `Application-PR:` — ошибка pipeline, а не молчаливый выбор одной из них. +Редактирование описания перезапускает CI: `ci.yml` подписан на тип события `edited` вдобавок к +`opened`/`synchronize`/`reopened`. Без этого добавленная после открытия PR строка не подхватилась +бы, а ручной re-run не помог бы — он воспроизводит исходный payload со старым описанием. + ### Что происходит после merge PR приложения Пока тестовый PR открыт, его PR приложения может быть смержен. У такого PR не осталось версии