Skip to content

Commit 9aafb87

Browse files
Initiate and recover coherent beta releases from GitHub (#179)
1 parent 943769f commit 9aafb87

6 files changed

Lines changed: 214 additions & 14 deletions

File tree

.github/workflows/build.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ jobs:
2929
sh -n scripts/generate-homebrew-formula.sh
3030
sh -n scripts/verify-release.sh
3131
sh -n scripts/ci/check-docs-release-audit.sh
32+
bash -n scripts/ci/verify-release-tag-source.sh
3233
bash -n scripts/verify-public-release-assets.sh
3334
bash -n scripts/verify-reproducible-build.sh
3435
pwsh -NoProfile -Command '$null = [scriptblock]::Create((Get-Content -Raw scripts/install.ps1))'

.github/workflows/release-plan-recovery.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ jobs:
6262
gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \
6363
-f ref="refs/tags/$RELEASE_TAG" -f sha="$RELEASE_COMMIT" >/dev/null
6464
fi
65+
scripts/ci/verify-release-tag-source.sh
6566
6667
- name: Start or resume repository-owned publication
6768
if: steps.recovery.outputs.action == 'publish'
@@ -72,6 +73,7 @@ jobs:
7273
RELEASE_COMMIT: ${{ steps.recovery.outputs.commit }}
7374
run: |
7475
set -euo pipefail
76+
scripts/ci/verify-release-tag-source.sh
7577
decision=
7678
for attempt in 1 2 3 4 5 6; do
7779
gh run list --workflow release.yml --limit 100 \
@@ -90,7 +92,8 @@ jobs:
9092
done
9193
if [ "$publication_action" = dispatch ]; then
9294
gh workflow run release.yml --ref "$RELEASE_TAG" \
93-
-f tag="$RELEASE_TAG" -f release_plan="$PLAN_TAG"
95+
-f tag="$RELEASE_TAG" -f release_commit="$RELEASE_COMMIT" \
96+
-f release_plan="$PLAN_TAG"
9497
elif [ "$publication_action" = rerun ]; then
9598
gh run rerun "$run_id"
9699
else

.github/workflows/release.yml

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ on:
1111
tag:
1212
description: 'Release tag (for manual dispatch, e.g. 0.0.1-test or v0.0.1-test)'
1313
required: true
14+
release_commit:
15+
description: 'Exact source commit declared by the immutable release plan'
16+
required: true
1417
release_plan:
1518
description: 'Immutable release-plan tag initiating this recovery run'
1619
required: false
@@ -48,17 +51,22 @@ jobs:
4851
runs-on: ubuntu-latest
4952
outputs:
5053
tag: ${{ steps.resolve.outputs.tag }}
54+
commit: ${{ steps.resolve.outputs.commit }}
5155
steps:
5256
- id: resolve
5357
shell: bash
5458
env:
5559
DISPATCH_TAG: ${{ inputs.tag }}
60+
DISPATCH_COMMIT: ${{ inputs.release_commit }}
61+
PUSH_COMMIT: ${{ github.sha }}
5662
run: |
5763
set -euo pipefail
5864
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
5965
tag="$DISPATCH_TAG"
66+
commit="$DISPATCH_COMMIT"
6067
else
6168
tag="${GITHUB_REF_NAME}"
69+
commit="$PUSH_COMMIT"
6270
fi
6371
6472
if [ -z "$tag" ]; then
@@ -73,9 +81,14 @@ jobs:
7381
echo "::error::Invalid release tag: $raw_tag"
7482
exit 1
7583
fi
84+
if ! printf '%s\n' "$commit" | grep -Eq '^[0-9a-f]{40}$'; then
85+
echo "::error::Release commit must be a full lowercase Git commit SHA"
86+
exit 1
87+
fi
7688
7789
echo "tag=$tag" >> "$GITHUB_OUTPUT"
78-
echo "Resolved release tag: $tag"
90+
echo "commit=$commit" >> "$GITHUB_OUTPUT"
91+
echo "Resolved release tag $tag at immutable commit $commit"
7992
8093
release-preflight:
8194
name: Release preflight
@@ -86,7 +99,14 @@ jobs:
8699
steps:
87100
- uses: actions/checkout@v4
88101
with:
89-
ref: ${{ needs.resolve-release.outputs.tag }}
102+
ref: ${{ needs.resolve-release.outputs.commit }}
103+
104+
- name: Verify expected release source
105+
env:
106+
GH_TOKEN: ${{ github.token }}
107+
RELEASE_TAG: ${{ needs.resolve-release.outputs.tag }}
108+
RELEASE_COMMIT: ${{ needs.resolve-release.outputs.commit }}
109+
run: scripts/ci/verify-release-tag-source.sh
90110

91111
- name: Inspect existing public release assets
92112
id: public_assets
@@ -199,7 +219,7 @@ jobs:
199219
- uses: actions/checkout@v4
200220
with:
201221
fetch-depth: 0
202-
ref: ${{ needs.resolve-release.outputs.tag }}
222+
ref: ${{ needs.resolve-release.outputs.commit }}
203223

204224
- name: Setup PHP
205225
uses: shivammathur/setup-php@v2
@@ -276,7 +296,7 @@ jobs:
276296
steps:
277297
- uses: actions/checkout@v4
278298
with:
279-
ref: ${{ needs.resolve-release.outputs.tag }}
299+
ref: ${{ needs.resolve-release.outputs.commit }}
280300

281301
- name: Download PHAR artifact
282302
uses: actions/download-artifact@v4
@@ -387,7 +407,7 @@ jobs:
387407
steps:
388408
- uses: actions/checkout@v4
389409
with:
390-
ref: ${{ needs.resolve-release.outputs.tag }}
410+
ref: ${{ needs.resolve-release.outputs.commit }}
391411

392412
- name: Download PHAR artifact
393413
uses: actions/download-artifact@v4
@@ -547,7 +567,19 @@ jobs:
547567
steps:
548568
- uses: actions/checkout@v4
549569
with:
550-
ref: ${{ needs.resolve-release.outputs.tag }}
570+
ref: ${{ needs.resolve-release.outputs.commit }}
571+
572+
- name: Resolve exact source identity
573+
env:
574+
EXPECTED_COMMIT: ${{ needs.resolve-release.outputs.commit }}
575+
run: |
576+
set -euo pipefail
577+
head_commit="$(git rev-parse HEAD)"
578+
if [ "$head_commit" != "$EXPECTED_COMMIT" ]; then
579+
printf 'release checkout %s does not match expected commit %s\n' \
580+
"$head_commit" "$EXPECTED_COMMIT" >&2
581+
exit 1
582+
fi
551583
552584
- name: Download release artifacts
553585
uses: actions/download-artifact@v4
@@ -593,6 +625,13 @@ jobs:
593625
working-directory: dist
594626
run: sha256sum -- * > SHA256SUMS
595627

628+
- name: Verify immutable release tag at publication boundary
629+
env:
630+
GH_TOKEN: ${{ github.token }}
631+
RELEASE_TAG: ${{ needs.resolve-release.outputs.tag }}
632+
RELEASE_COMMIT: ${{ needs.resolve-release.outputs.commit }}
633+
run: scripts/ci/verify-release-tag-source.sh
634+
596635
- name: Attest release artifacts
597636
uses: actions/attest-build-provenance@v2
598637
with:

scripts/ci/component-release-recovery.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -783,11 +783,12 @@ def base_state(component: str, tag: str | None = None, plan: dict[str, Any] | No
783783
def resolve_component(
784784
client: PublicClient,
785785
component_name: str,
786-
requested_tag: str | None,
786+
tag: str,
787+
record_commit: str,
788+
plan: dict[str, Any],
787789
) -> tuple[dict[str, Any], dict[str, str]]:
788790
if component_name not in COMPONENTS:
789791
raise RecoveryError(f"unknown release component: {component_name}")
790-
tag, record_commit, plan = discover_plan(client, requested_tag)
791792
branches, recovery_workflows = verify_plan_authority(client, plan)
792793
component = COMPONENTS[component_name]
793794
identity = plan["components"][component_name]
@@ -920,13 +921,13 @@ def main() -> int:
920921
)
921922
)
922923
elif args.command == "resolve":
923-
tag: str | None = None
924+
tag: str | None = args.plan_tag
925+
record_commit: str | None = None
924926
plan: dict[str, Any] | None = None
925927
try:
926-
state, outputs = resolve_component(client, args.component, args.plan_tag)
927-
tag = outputs["plan_tag"]
928-
plan = discover_plan(client, tag)[2]
928+
tag, record_commit, plan = discover_plan(client, args.plan_tag)
929929
args.plan_output.write_bytes(canonical_json(plan))
930+
state, outputs = resolve_component(client, args.component, tag, record_commit, plan)
930931
args.evidence.write_bytes(canonical_json(state))
931932
write_output(args.github_output, outputs)
932933
except RecoveryError as error:
@@ -948,6 +949,8 @@ def main() -> int:
948949
write_output(args.github_output, {"action": "none"})
949950
return 0
950951
failure = base_state(args.component, tag, plan)
952+
if record_commit is not None:
953+
failure["plan_record_commit"] = record_commit
951954
failure.update(
952955
{
953956
"phase": error.phase,
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
fail() {
6+
printf 'Release tag source verification failed: %s\n' "$1" >&2
7+
exit 1
8+
}
9+
10+
release_tag="${RELEASE_TAG:-}"
11+
release_commit="${RELEASE_COMMIT:-}"
12+
repository="${GITHUB_REPOSITORY:-}"
13+
gh_cli="${GH_CLI:-gh}"
14+
15+
[[ "$release_tag" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z][0-9A-Za-z.-]*)?$ ]] \
16+
|| fail "RELEASE_TAG must be an exact release version."
17+
[[ "$release_commit" =~ ^[0-9a-f]{40}$ ]] \
18+
|| fail "RELEASE_COMMIT must be a full lowercase Git commit SHA."
19+
[[ "$repository" =~ ^[0-9A-Za-z_.-]+/[0-9A-Za-z_.-]+$ ]] \
20+
|| fail "GITHUB_REPOSITORY must identify the release repository."
21+
22+
if ! target="$("$gh_cli" api "repos/$repository/git/ref/tags/$release_tag" \
23+
--jq '.object.type + " " + .object.sha' 2>/dev/null)"; then
24+
fail "public tag $release_tag does not exist."
25+
fi
26+
27+
read -r object_type object_sha <<< "$target"
28+
depth=0
29+
while [ "$object_type" = tag ]; do
30+
[[ "$object_sha" =~ ^[0-9a-f]{40}$ ]] \
31+
|| fail "public tag $release_tag has an invalid annotated-tag target."
32+
depth=$((depth + 1))
33+
[ "$depth" -le 16 ] || fail "public tag $release_tag has an invalid annotated-tag chain."
34+
if ! target="$("$gh_cli" api "repos/$repository/git/tags/$object_sha" \
35+
--jq '.object.type + " " + .object.sha' 2>/dev/null)"; then
36+
fail "public tag $release_tag has an unreadable annotated-tag target."
37+
fi
38+
read -r object_type object_sha <<< "$target"
39+
done
40+
41+
[ "$object_type" = commit ] \
42+
|| fail "public tag $release_tag does not resolve to a commit."
43+
[[ "$object_sha" =~ ^[0-9a-f]{40}$ ]] \
44+
|| fail "public tag $release_tag resolves to an invalid commit identity."
45+
[ "$object_sha" = "$release_commit" ] \
46+
|| fail "public tag $release_tag points to $object_sha, not planned commit $release_commit."
47+
48+
printf 'Verified public tag %s at planned commit %s.\n' "$release_tag" "$release_commit"

tests/ReleaseInstallerContractTest.php

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Tests;
66

77
use PHPUnit\Framework\TestCase;
8+
use Symfony\Component\Process\Process;
89

910
final 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

Comments
 (0)