55namespace Tests ;
66
77use PHPUnit \Framework \TestCase ;
8+ use Symfony \Component \Process \Process ;
89
910final class ReleaseInstallerContractTest extends TestCase
1011{
@@ -17,7 +18,7 @@ public function test_installers_are_versioned_release_assets(): void
1718 self ::assertStringContainsString ('0.0.1-test or v0.0.1-test ' , $ releaseWorkflow );
1819 self ::assertStringContainsString ('raw_tag="$tag" ' , $ releaseWorkflow );
1920 self ::assertStringContainsString ('tag="${tag#v}" ' , $ releaseWorkflow );
20- self ::assertStringContainsString ('ref: ${{ needs.resolve-release.outputs.tag }} ' , $ releaseWorkflow );
21+ self ::assertStringContainsString ('ref: ${{ needs.resolve-release.outputs.commit }} ' , $ releaseWorkflow );
2122 self ::assertStringContainsString ('DW_CLI_VERSION: ${{ needs.resolve-release.outputs.tag }} ' , $ releaseWorkflow );
2223 self ::assertStringContainsString ('DW_CLI_COMMIT="$(git rev-parse HEAD)" ' , $ releaseWorkflow );
2324 self ::assertStringContainsString ('release-preflight: ' , $ releaseWorkflow );
@@ -95,9 +96,114 @@ public function test_build_validates_installer_scripts(): void
9596 self ::assertStringContainsString ('sh -n scripts/verify-release.sh ' , $ buildWorkflow );
9697 self ::assertStringContainsString ('bash -n scripts/verify-public-release-assets.sh ' , $ buildWorkflow );
9798 self ::assertStringContainsString ('sh -n scripts/ci/check-docs-release-audit.sh ' , $ buildWorkflow );
99+ self ::assertStringContainsString ('bash -n scripts/ci/verify-release-tag-source.sh ' , $ buildWorkflow );
98100 self ::assertStringContainsString ('scripts/install.ps1 ' , $ buildWorkflow );
99101 }
100102
103+ public function test_release_recovery_retains_the_planned_commit_at_publication (): void
104+ {
105+ $ releaseWorkflow = self ::readRepoFile ('.github/workflows/release.yml ' );
106+ $ recoveryWorkflow = self ::readRepoFile ('.github/workflows/release-plan-recovery.yml ' );
107+
108+ self ::assertStringContainsString ('release_commit: ' , $ releaseWorkflow );
109+ self ::assertStringContainsString ('commit: ${{ steps.resolve.outputs.commit }} ' , $ releaseWorkflow );
110+ self ::assertStringContainsString ('DISPATCH_COMMIT: ${{ inputs.release_commit }} ' , $ releaseWorkflow );
111+ self ::assertStringContainsString ('PUSH_COMMIT: ${{ github.sha }} ' , $ releaseWorkflow );
112+ self ::assertSame (5 , substr_count ($ releaseWorkflow , 'ref: ${{ needs.resolve-release.outputs.commit }} ' ));
113+ self ::assertStringNotContainsString ('|| github.ref }} ' , $ releaseWorkflow );
114+ self ::assertStringContainsString ('EXPECTED_COMMIT: ${{ needs.resolve-release.outputs.commit }} ' , $ releaseWorkflow );
115+ self ::assertSame (2 , substr_count ($ releaseWorkflow , 'RELEASE_COMMIT: ${{ needs.resolve-release.outputs.commit }} ' ));
116+ self ::assertStringContainsString ('-f release_commit="$RELEASE_COMMIT" ' , $ recoveryWorkflow );
117+ self ::assertSame (2 , substr_count ($ recoveryWorkflow , 'scripts/ci/verify-release-tag-source.sh ' ));
118+
119+ $ sourceCheck = strpos ($ releaseWorkflow , 'Resolve exact source identity ' );
120+ $ boundaryCheck = strpos ($ releaseWorkflow , 'Verify immutable release tag at publication boundary ' );
121+ $ attestation = strpos ($ releaseWorkflow , 'Attest release artifacts ' );
122+ $ publication = strpos ($ releaseWorkflow , 'Create GitHub Release ' );
123+ self ::assertIsInt ($ sourceCheck );
124+ self ::assertIsInt ($ boundaryCheck );
125+ self ::assertIsInt ($ attestation );
126+ self ::assertIsInt ($ publication );
127+ self ::assertLessThan ($ boundaryCheck , $ sourceCheck );
128+ self ::assertLessThan ($ attestation , $ boundaryCheck );
129+ self ::assertLessThan ($ publication , $ boundaryCheck );
130+ }
131+
132+ public function test_recovery_reused_push_run_rejects_tag_movement_before_publication (): void
133+ {
134+ $ releaseWorkflow = self ::readRepoFile ('.github/workflows/release.yml ' );
135+ $ plannedCommit = str_repeat ('a ' , 40 );
136+ $ movedCommit = str_repeat ('b ' , 40 );
137+ $ temporary = sys_get_temp_dir ().'/cli-release-tag- ' .bin2hex (random_bytes (4 ));
138+ self ::assertTrue (mkdir ($ temporary ));
139+ $ fakeGh = $ temporary .'/gh ' ;
140+ $ publicationRuns = $ temporary .'/publication-runs.json ' ;
141+ file_put_contents ($ fakeGh , <<<'SH'
142+ #!/usr/bin/env sh
143+ set -eu
144+ printf 'commit %s\n' "$FAKE_TAG_SHA"
145+ SH);
146+ self ::assertTrue (chmod ($ fakeGh , 0755 ));
147+ file_put_contents ($ publicationRuns , json_encode ([
148+ [
149+ 'databaseId ' => 1234 ,
150+ 'displayTitle ' => 'Release 1.2.3-alpha.4 for direct ' ,
151+ 'event ' => 'push ' ,
152+ 'headBranch ' => '1.2.3-alpha.4 ' ,
153+ 'headSha ' => $ plannedCommit ,
154+ 'status ' => 'in_progress ' ,
155+ 'conclusion ' => null ,
156+ ],
157+ ], JSON_THROW_ON_ERROR ));
158+
159+ self ::assertStringContainsString ('PUSH_COMMIT: ${{ github.sha }} ' , $ releaseWorkflow );
160+ self ::assertSame (5 , substr_count ($ releaseWorkflow , 'ref: ${{ needs.resolve-release.outputs.commit }} ' ));
161+ self ::assertSame (2 , substr_count ($ releaseWorkflow , 'RELEASE_COMMIT: ${{ needs.resolve-release.outputs.commit }} ' ));
162+
163+ $ environment = [
164+ 'GH_CLI ' => $ fakeGh ,
165+ 'GITHUB_REPOSITORY ' => 'durable-workflow/cli ' ,
166+ 'RELEASE_TAG ' => '1.2.3-alpha.4 ' ,
167+ 'RELEASE_COMMIT ' => $ plannedCommit ,
168+ ];
169+
170+ try {
171+ $ selection = new Process ([
172+ 'python3 ' ,
173+ dirname (__DIR__ ).'/scripts/ci/component-release-recovery.py ' ,
174+ 'select-publication-run ' ,
175+ '--release-tag ' ,
176+ '1.2.3-alpha.4 ' ,
177+ '--release-commit ' ,
178+ $ plannedCommit ,
179+ '--runs ' ,
180+ $ publicationRuns ,
181+ ], dirname (__DIR__ ));
182+ self ::assertSame (0 , $ selection ->run (), $ selection ->getErrorOutput ());
183+ self ::assertSame ("wait \t1234 \tin_progress \t\n" , $ selection ->getOutput ());
184+
185+ $ exact = new Process (
186+ [dirname (__DIR__ ).'/scripts/ci/verify-release-tag-source.sh ' ],
187+ dirname (__DIR__ ),
188+ $ environment + ['FAKE_TAG_SHA ' => $ plannedCommit ],
189+ );
190+ self ::assertSame (0 , $ exact ->run (), $ exact ->getErrorOutput ());
191+
192+ $ moved = new Process (
193+ [dirname (__DIR__ ).'/scripts/ci/verify-release-tag-source.sh ' ],
194+ dirname (__DIR__ ),
195+ $ environment + ['FAKE_TAG_SHA ' => $ movedCommit ],
196+ );
197+ self ::assertSame (1 , $ moved ->run ());
198+ self ::assertStringContainsString ($ movedCommit , $ moved ->getErrorOutput ());
199+ self ::assertStringContainsString ($ plannedCommit , $ moved ->getErrorOutput ());
200+ } finally {
201+ @unlink ($ fakeGh );
202+ @unlink ($ publicationRuns );
203+ @rmdir ($ temporary );
204+ }
205+ }
206+
101207 public function test_release_includes_checksum_and_attestation_verifier (): void
102208 {
103209 $ verifier = self ::readRepoFile ('scripts/verify-release.sh ' );
0 commit comments