Skip to content

Commit 207e509

Browse files
authored
fix(release): persist extension feeds before CDN publish (#2456)
* fix(release): persist extension feeds before publishing * chore(release): sync BrowserOS neo production feed * fix: address review findings for sync release manifests * fix(release): cover multi-channel snapshot retries * test(release): pin extension feed handoffs * test(release): keep feed snapshot checks version-agnostic * ci(release): pin write-enabled feed actions
1 parent a1ae025 commit 207e509

4 files changed

Lines changed: 195 additions & 91 deletions

File tree

.github/workflows/release-extension-feeds.yml

Lines changed: 70 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -67,41 +67,51 @@ concurrency:
6767
jobs:
6868
feeds:
6969
runs-on: ubuntu-latest
70-
timeout-minutes: 10
70+
# `both` can consume two independent 15-minute snapshot-PR retry budgets.
71+
timeout-minutes: 40
7172
permissions:
72-
contents: read
73+
contents: write
74+
pull-requests: write
7375

7476
steps:
7577
- name: Checkout repository
76-
uses: actions/checkout@v7
78+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
79+
with:
80+
fetch-depth: 0
81+
ref: ${{ github.event.repository.default_branch || 'main' }}
7782

7883
- name: Setup uv
79-
uses: astral-sh/setup-uv@v8.3.2
84+
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
8085

81-
- name: Generate extension update feeds
82-
working-directory: packages/browseros
86+
- name: Generate, persist, and publish extension update feeds
8387
env:
88+
ALLOW_DOWNGRADE: ${{ inputs.allow_downgrade }}
8489
CHANNEL: ${{ inputs.channel }}
90+
DEFAULT_BRANCH: ${{ github.event.repository.default_branch || 'main' }}
91+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
8592
PINS: ${{ inputs.pins }}
8693
PUBLISH: ${{ inputs.publish }}
87-
ALLOW_DOWNGRADE: ${{ inputs.allow_downgrade }}
8894
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
8995
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
9096
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
9197
R2_BUCKET: ${{ secrets.R2_BUCKET }}
92-
# Inputs are validated and appended to an args array, never a command string.
98+
# Each channel is one ordered transaction. For `both`, alpha must be live
99+
# before prod renders because bundled resolution carries newer versions
100+
# across channels from live R2; batching both renders can lose that state.
93101
run: |
94102
set -euo pipefail
95103
96104
case "$CHANNEL" in
97-
alpha|prod|both) ;;
105+
alpha) channels=(alpha) ;;
106+
prod) channels=(prod) ;;
107+
both) channels=(alpha prod) ;;
98108
*)
99109
echo "::error::Invalid channel '$CHANNEL'; expected alpha, prod, or both" >&2
100110
exit 1
101111
;;
102112
esac
103113
104-
args=(--channel "$CHANNEL")
114+
base_args=()
105115
normalized=${PINS//,/ }
106116
normalized=${normalized//$'\n'/ }
107117
normalized=${normalized//$'\r'/ }
@@ -111,32 +121,63 @@ jobs:
111121
echo "::error::Invalid pin '$pin'; expected name=version (for example, agent=0.0.119)" >&2
112122
exit 1
113123
fi
114-
args+=(--set "$pin")
124+
base_args+=(--set "$pin")
115125
done
116-
if [ "$PUBLISH" = "true" ]; then
117-
args+=(--publish)
118-
fi
119126
if [ "$ALLOW_DOWNGRADE" = "true" ]; then
120-
args+=(--allow-downgrade)
127+
base_args+=(--allow-downgrade)
121128
fi
122129
123-
if [ "$CHANNEL" = "both" ]; then
124-
for ch in alpha prod; do
125-
args[1]="$ch"
126-
{
127-
echo "### Extension feed command ($ch)"
128-
echo '```bash'
129-
echo "browseros release extensions ${args[*]}"
130-
echo '```'
131-
} >> "$GITHUB_STEP_SUMMARY"
132-
uv run browseros release extensions "${args[@]}"
133-
done
134-
else
130+
for feed_channel in "${channels[@]}"; do
131+
args=(--channel "$feed_channel" "${base_args[@]}")
135132
{
136-
echo "### Extension feed command"
133+
echo "### Extension feed command ($feed_channel)"
137134
echo '```bash'
138135
echo "browseros release extensions ${args[*]}"
139136
echo '```'
140137
} >> "$GITHUB_STEP_SUMMARY"
141-
uv run browseros release extensions "${args[@]}"
138+
uv run --directory packages/browseros browseros \
139+
release extensions "${args[@]}"
140+
141+
if [ "$feed_channel" = "alpha" ]; then
142+
feed_keys=(
143+
extensions/update-manifest.alpha.xml
144+
extensions/extensions.alpha.json
145+
extensions/bundled-manifest.xml
146+
)
147+
else
148+
feed_keys=(
149+
extensions/update-manifest.xml
150+
extensions/extensions.json
151+
extensions/bundled-manifest.xml
152+
)
153+
fi
154+
155+
snapshot_paths=()
156+
for feed_key in "${feed_keys[@]}"; do
157+
snapshot_paths+=("updates/$feed_key")
158+
done
159+
160+
if [ "$PUBLISH" != "true" ]; then
161+
continue
162+
fi
163+
164+
# The merged snapshot is the durability barrier. A later R2 failure
165+
# is retryable; publishing before this returns would recreate drift.
166+
packages/browseros-agent/scripts/release/commit-update-snapshot.sh \
167+
"$DEFAULT_BRANCH" \
168+
"chore(release): update extension ${feed_channel} feeds" \
169+
"${snapshot_paths[@]}"
170+
171+
publish_flags=(--publish)
172+
if [ "$ALLOW_DOWNGRADE" = "true" ]; then
173+
publish_flags+=(--allow-downgrade)
174+
fi
175+
uv run --directory packages/browseros browseros \
176+
release feeds publish-local \
177+
"${feed_keys[@]}" \
178+
"${publish_flags[@]}"
179+
done
180+
181+
if [ "$PUBLISH" != "true" ]; then
182+
echo "Dry run complete; snapshots were not committed or published"
142183
fi

packages/browseros-agent/scripts/release/release-extensions-workflow.test.ts

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,15 @@ const browserClawWorkflow = readFileSync(
2020
'utf8',
2121
)
2222

23-
function section(start: string, end?: string): string {
24-
const startIndex = workflow.indexOf(start)
23+
function section(
24+
start: string,
25+
end?: string,
26+
source: string = workflow,
27+
): string {
28+
const startIndex = source.indexOf(start)
2529
expect(startIndex).toBeGreaterThanOrEqual(0)
26-
const endIndex = end ? workflow.indexOf(end, startIndex + start.length) : -1
27-
return workflow.slice(startIndex, endIndex >= 0 ? endIndex : undefined)
30+
const endIndex = end ? source.indexOf(end, startIndex + start.length) : -1
31+
return source.slice(startIndex, endIndex >= 0 ? endIndex : undefined)
2832
}
2933

3034
describe('release-extensions workflow', () => {
@@ -220,6 +224,82 @@ describe('release-extensions workflow', () => {
220224
expect(section('on:', '\npermissions:')).not.toMatch(/\n {2}push:/)
221225
})
222226

227+
it('persists manual feed snapshots before publishing their exact files', () => {
228+
const job = section(' feeds:', undefined, feedWorkflow)
229+
const transaction = section(
230+
'- name: Generate, persist, and publish extension update feeds',
231+
undefined,
232+
feedWorkflow,
233+
)
234+
const channelLoopStart = transaction.indexOf(
235+
`for feed_channel in "\${channels[@]}"`,
236+
)
237+
expect(channelLoopStart).toBeGreaterThanOrEqual(0)
238+
const channelLoop = transaction.slice(channelLoopStart)
239+
const renderCommand = [
240+
'uv run --directory packages/browseros browseros \\',
241+
` release extensions "\${args[@]}"`,
242+
].join('\n')
243+
const commitCommand = [
244+
'packages/browseros-agent/scripts/release/commit-update-snapshot.sh \\',
245+
' "$DEFAULT_BRANCH" \\',
246+
` "chore(release): update extension \${feed_channel} feeds" \\`,
247+
` "\${snapshot_paths[@]}"`,
248+
].join('\n')
249+
const publishCommand = [
250+
'uv run --directory packages/browseros browseros \\',
251+
' release feeds publish-local \\',
252+
` "\${feed_keys[@]}" \\`,
253+
` "\${publish_flags[@]}"`,
254+
].join('\n')
255+
const channelLoopEnd = channelLoop.indexOf(
256+
'\n done\n\n if [ "$PUBLISH"',
257+
)
258+
259+
expect(job).toMatch(
260+
/permissions:\n\s+contents: write\n\s+pull-requests: write/,
261+
)
262+
expect(job).toMatch(/uses: actions\/checkout@[0-9a-f]{40} # v7/)
263+
expect(job).toMatch(/uses: astral-sh\/setup-uv@[0-9a-f]{40} # v8\.3\.2/)
264+
expect(job).toContain('timeout-minutes: 40')
265+
expect(job).toContain('fetch-depth: 0')
266+
expect(job).toContain(
267+
`ref: ${'$'}{{ github.event.repository.default_branch || 'main' }}`,
268+
)
269+
expect(transaction).toContain('both) channels=(alpha prod)')
270+
expect(transaction).toContain('extensions/update-manifest.alpha.xml')
271+
expect(transaction).toContain('extensions/extensions.alpha.json')
272+
expect(transaction).toContain('extensions/update-manifest.xml')
273+
expect(transaction).toContain('extensions/extensions.json')
274+
expect(transaction).toContain('extensions/bundled-manifest.xml')
275+
expect(
276+
transaction.match(/extensions\/bundled-manifest\.xml/g),
277+
).toHaveLength(2)
278+
expect(transaction).toContain('snapshot_paths+=("updates/$feed_key")')
279+
expect(transaction).not.toContain('updates/extensions/update-manifest')
280+
expect(transaction).toContain('if [ "$PUBLISH" != "true" ]')
281+
expect(transaction).toContain('continue')
282+
expect(transaction).toContain('commit-update-snapshot.sh')
283+
expect(transaction).toContain('release feeds publish-local')
284+
expect(transaction).toContain('--publish')
285+
expect(transaction).not.toContain('base_args+=(--publish)')
286+
expect(transaction).not.toContain('args+=(--publish)')
287+
expect(transaction).toContain('base_args+=(--allow-downgrade)')
288+
expect(transaction).toContain('publish_flags=(--publish)')
289+
expect(transaction).toContain('publish_flags+=(--allow-downgrade)')
290+
expect(channelLoop.indexOf(renderCommand)).toBeGreaterThanOrEqual(0)
291+
expect(channelLoop.indexOf(commitCommand)).toBeGreaterThanOrEqual(0)
292+
expect(channelLoop.indexOf(publishCommand)).toBeGreaterThanOrEqual(0)
293+
expect(channelLoopEnd).toBeGreaterThanOrEqual(0)
294+
expect(channelLoop.indexOf(renderCommand)).toBeLessThan(
295+
channelLoop.indexOf(commitCommand),
296+
)
297+
expect(channelLoop.indexOf(commitCommand)).toBeLessThan(
298+
channelLoop.indexOf(publishCommand),
299+
)
300+
expect(channelLoop.indexOf(publishCommand)).toBeLessThan(channelLoopEnd)
301+
})
302+
223303
it('requires the BrowserClaw PostHog key and keeps the host optional', () => {
224304
expect(workflow).toMatch(/VITE_CLAW_POSTHOG_KEY:\n\s+required: true/)
225305
expect(workflow).toMatch(/VITE_CLAW_POSTHOG_HOST:\n\s+required: false/)

packages/browseros/bos_build/README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,12 @@ gh workflow run release-extension-feeds.yml \
246246
Pins are optional; extensions not set carry over from the live manifests. The
247247
per-product browser release orchestrators still only stage extension feed
248248
previews; the standalone extension workflow is the automatic alpha entrypoint.
249+
With `publish=true`, the feed workflow merges each channel's exact generated
250+
snapshots into the default branch before it uploads those same files to R2. A
251+
failed snapshot merge therefore leaves that channel's live feeds untouched. For
252+
`channel=both`, alpha completes before production so production sees alpha's
253+
newer bundled versions. If production later fails, alpha remains durably
254+
committed and published; rerun the workflow to resume production.
249255

250256
Locally there are two commands, and the difference matters:
251257

@@ -259,7 +265,9 @@ browseros release extensions --channel alpha --set browserclaw=0.1.4 --publish
259265
```
260266

261267
`release extensions` regenerates the update manifest, `extensions.json`, and the
262-
bundled manifest together, so they cannot drift apart.
268+
bundled manifest together, so they cannot drift apart. Local `--publish` is an
269+
emergency escape hatch and does not persist `updates/` through git; use the feed
270+
workflow for normal publication.
263271

264272
## Servers and nightlies
265273

packages/browseros/bos_build/release/feeds/publisher_test.py

Lines changed: 32 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
render_update_manifest,
2626
)
2727
from .spec import (
28+
EXTENSIONS,
2829
all_feeds,
2930
browser_feeds_for_product,
3031
feed_by_key,
@@ -1075,69 +1076,43 @@ def test_mixed_repair_batch_is_retry_safe(self):
10751076
)
10761077
self.assertEqual(self.client.calls, [])
10771078

1078-
def test_repaired_snapshots_preserve_versions_and_original_payloads(self):
1079+
def test_tracked_snapshots_have_canonical_metadata(self):
10791080
updates = Path(__file__).resolve().parents[5] / "updates"
1080-
server_cases = (
1081-
(
1082-
"appcast-server.xml",
1083-
"0.0.127",
1084-
(
1085-
("BrowserOS Server", "BrowserOS Server (Alpha)"),
1086-
("appcast-server.xml", "appcast-server.alpha.xml"),
1087-
(
1088-
"BrowserOS Server binary updates",
1089-
"BrowserOS Server (Alpha) binary updates",
1090-
),
1091-
),
1092-
"45a2ee4835b11d964584bdfc6c8d3c555d1384cbcbc91e7eb14aa97c3ba8fedf",
1093-
),
1094-
(
1095-
"appcast-claw-server.xml",
1096-
"0.0.15",
1097-
(
1098-
(
1099-
"BrowserOS Claw Server",
1100-
"BrowserOS Claw Server (Alpha)",
1101-
),
1102-
(
1103-
"appcast-claw-server.xml",
1104-
"appcast-claw-server.alpha.xml",
1105-
),
1106-
(
1107-
"BrowserOS Claw Server binary updates",
1108-
"BrowserOS Claw Server (Alpha) binary updates",
1109-
),
1110-
),
1111-
"1df47182d63006294b87323d276896e489f141f2aed1f0266a2119c9ffef3eef",
1112-
),
1113-
)
11141081

1115-
for filename, version, replacements, old_hash in server_cases:
1116-
with self.subTest(filename=filename):
1117-
content = (updates / "server" / filename).read_text()
1118-
spec = feed_by_key(filename)
1082+
# These files are release outputs, so pin stable schema and ownership
1083+
# invariants instead of versions or whole-file hashes. Otherwise every
1084+
# valid snapshot promotion makes the default branch's test suite stale.
1085+
for bundle_id in ("browseros-server", "browserclaw-server"):
1086+
spec = server_feed(bundle_id, "prod")
1087+
with self.subTest(key=spec.key):
1088+
content = (updates / "server" / spec.key).read_text()
11191089
self.assertEqual((spec.kind, spec.channel), ("server", "prod"))
1120-
self.assertEqual(extract_appcast_version(content), version)
1121-
original = content
1122-
for corrected, invalid in replacements:
1123-
original = original.replace(corrected, invalid, 1)
11241090
self.assertEqual(
1125-
hashlib.sha256(original.encode()).hexdigest(), old_hash
1091+
extract_channel_metadata(content),
1092+
(spec.title, spec.link),
1093+
)
1094+
self.assertIn(
1095+
f"<description>{spec.title} binary updates</description>",
1096+
content,
11261097
)
1098+
self.assertIsNotNone(extract_appcast_version(content))
11271099

1128-
manifest_path = updates / "extensions" / "update-manifest.alpha.xml"
1129-
manifest = manifest_path.read_text()
1130-
spec = feed_by_key("extensions/update-manifest.alpha.xml")
1131-
self.assertEqual((spec.kind, spec.channel), ("extensions", "alpha"))
1132-
self.assertEqual(
1133-
set(extract_manifest_versions(manifest).values()),
1134-
{"0.0.139.0", "54.0.0.0", "0.2.15.0"},
1135-
)
1136-
original = manifest.replace("</gupdate>", " </app>\n</gupdate>")
1137-
self.assertEqual(
1138-
hashlib.sha256(original.encode()).hexdigest(),
1139-
"d2a7b386ea9928cb4ae4f0a8537f304db19e7e17e51178feecbe2f5a90f08fb7",
1140-
)
1100+
expected_extension_ids = {
1101+
extension.extension_id
1102+
for extension in EXTENSIONS
1103+
if extension.in_update_feed
1104+
}
1105+
for channel in ("alpha", "prod"):
1106+
spec = update_manifest_feed(channel)
1107+
with self.subTest(key=spec.key):
1108+
manifest = (updates / spec.key).read_text()
1109+
self.assertEqual(
1110+
(spec.kind, spec.channel), ("extensions", channel)
1111+
)
1112+
self.assertEqual(
1113+
set(extract_manifest_versions(manifest)),
1114+
expected_extension_ids,
1115+
)
11411116

11421117
def test_browserclaw_snapshots_use_current_product_title(self):
11431118
updates = Path(__file__).resolve().parents[5] / "updates" / "browser"

0 commit comments

Comments
 (0)