Skip to content

Commit ef005b5

Browse files
authored
Merge pull request #68 from A3S-Lab/chore/legacy-release-relay-20260729
ci: relay monorepo releases for legacy clients
2 parents 96875ac + 9ed21ca commit ef005b5

3 files changed

Lines changed: 338 additions & 1 deletion

File tree

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
name: Relay Monorepo Release
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
tag:
7+
description: "Published A3S-Lab/a3s stable tag to relay"
8+
required: true
9+
type: string
10+
11+
permissions:
12+
contents: write
13+
14+
concurrency:
15+
group: legacy-release-relay-${{ inputs.tag }}
16+
cancel-in-progress: false
17+
18+
env:
19+
SOURCE_REPOSITORY: A3S-Lab/a3s
20+
21+
jobs:
22+
relay:
23+
name: Relay verified CLI archives
24+
runs-on: ubuntu-latest
25+
steps:
26+
- name: Verify and download the monorepo release
27+
id: source
28+
shell: bash
29+
env:
30+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
31+
RELEASE_TAG: ${{ inputs.tag }}
32+
run: |
33+
set -euo pipefail
34+
tag="$RELEASE_TAG"
35+
if ! [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
36+
echo "::error::Relay requires a stable vMAJOR.MINOR.PATCH tag"
37+
exit 1
38+
fi
39+
40+
source_json="$(gh api "repos/${SOURCE_REPOSITORY}/releases/tags/${tag}")"
41+
if ! jq -e --arg tag "$tag" \
42+
'.tag_name == $tag and .draft == false and .prerelease == false' \
43+
<<<"$source_json" >/dev/null; then
44+
echo "::error::Source release ${SOURCE_REPOSITORY} ${tag} must be public and stable"
45+
exit 1
46+
fi
47+
48+
source_release_id="$(jq -er '.id' <<<"$source_json")"
49+
ref_json="$(gh api "repos/${SOURCE_REPOSITORY}/git/ref/tags/${tag}")"
50+
source_type="$(jq -er '.object.type' <<<"$ref_json")"
51+
source_sha="$(jq -er '.object.sha' <<<"$ref_json")"
52+
for _ in 1 2 3 4; do
53+
if [[ "$source_type" == "commit" ]]; then
54+
break
55+
fi
56+
if [[ "$source_type" != "tag" ]]; then
57+
echo "::error::Source tag ${tag} resolves to unsupported object type ${source_type}"
58+
exit 1
59+
fi
60+
tag_json="$(gh api "repos/${SOURCE_REPOSITORY}/git/tags/${source_sha}")"
61+
source_type="$(jq -er '.object.type' <<<"$tag_json")"
62+
source_sha="$(jq -er '.object.sha' <<<"$tag_json")"
63+
done
64+
if [[ "$source_type" != "commit" || ! "$source_sha" =~ ^[0-9a-f]{40}$ ]]; then
65+
echo "::error::Could not resolve source tag ${tag} to one commit"
66+
exit 1
67+
fi
68+
69+
for target in \
70+
aarch64-apple-darwin \
71+
x86_64-apple-darwin \
72+
x86_64-unknown-linux-gnu \
73+
aarch64-unknown-linux-gnu \
74+
x86_64-pc-windows-msvc; do
75+
base="a3s-${tag}-${target}"
76+
printf '%s\n' "${base}.tar.gz" "${base}.sha256"
77+
if [[ "$target" == "x86_64-pc-windows-msvc" ]]; then
78+
printf '%s\n' "${base}.zip"
79+
fi
80+
done | sort > expected-assets.txt
81+
82+
jq -r --arg prefix "a3s-${tag}-" \
83+
'.assets[].name | select(startswith($prefix))' \
84+
<<<"$source_json" | sort > source-assets.txt
85+
if ! diff -u expected-assets.txt source-assets.txt; then
86+
echo "::error::Source release ${tag} does not contain the exact CLI asset set"
87+
exit 1
88+
fi
89+
90+
mkdir source-assets
91+
gh release download "$tag" --repo "$SOURCE_REPOSITORY" \
92+
--dir source-assets --pattern "a3s-${tag}-*"
93+
find source-assets -mindepth 1 -maxdepth 1 -type f \
94+
-exec basename {} \; | sort > downloaded-assets.txt
95+
if ! diff -u expected-assets.txt downloaded-assets.txt; then
96+
echo "::error::Downloaded source assets differ from release metadata"
97+
exit 1
98+
fi
99+
100+
while IFS= read -r asset; do
101+
expected_digest="$(jq -er --arg asset "$asset" \
102+
'[.assets[] | select(.name == $asset)]
103+
| if length == 1 then .[0].digest else empty end' \
104+
<<<"$source_json")"
105+
actual_digest="sha256:$(sha256sum "source-assets/${asset}" | awk '{ print $1 }')"
106+
if [[ "$expected_digest" != "$actual_digest" ]]; then
107+
echo "::error::GitHub digest mismatch for ${asset}"
108+
exit 1
109+
fi
110+
done < expected-assets.txt
111+
112+
for target in \
113+
aarch64-apple-darwin \
114+
x86_64-apple-darwin \
115+
x86_64-unknown-linux-gnu \
116+
aarch64-unknown-linux-gnu \
117+
x86_64-pc-windows-msvc; do
118+
base="a3s-${tag}-${target}"
119+
archives=("${base}.tar.gz")
120+
if [[ "$target" == "x86_64-pc-windows-msvc" ]]; then
121+
archives+=("${base}.zip")
122+
fi
123+
checksum="source-assets/${base}.sha256"
124+
checksum_lines="$(awk 'END { print NR }' "$checksum")"
125+
checksum_entries="$(awk 'NF == 2 { count += 1 } END { print count + 0 }' "$checksum")"
126+
if [[ "$checksum_lines" -ne "${#archives[@]}" \
127+
|| "$checksum_entries" -ne "${#archives[@]}" ]]; then
128+
echo "::error::${base}.sha256 must contain exactly one entry per archive"
129+
exit 1
130+
fi
131+
for archive in "${archives[@]}"; do
132+
expected="$(awk -v asset="$archive" \
133+
'($2 == asset || $2 == "*" asset)
134+
&& length($1) == 64 && $1 !~ /[^0-9a-f]/
135+
{ digest = $1; count += 1 }
136+
END { if (count == 1) print digest }' "$checksum")"
137+
actual="$(sha256sum "source-assets/${archive}" | awk '{ print $1 }')"
138+
if [[ -z "$expected" || "$actual" != "$expected" ]]; then
139+
echo "::error::SHA-256 mismatch for ${archive}"
140+
exit 1
141+
fi
142+
done
143+
done
144+
145+
bridge_root='release-compat/support/managed-srt/'
146+
expected_bridge_files="$RUNNER_TEMP/expected-bridge-files.txt"
147+
printf '%s\n' \
148+
'node_modules/@anthropic-ai/sandbox-runtime/dist/cli.js' \
149+
'node_modules/@anthropic-ai/sandbox-runtime/package.json' \
150+
'package-lock.json' \
151+
'package.json' > "$expected_bridge_files"
152+
153+
verify_bridge_entries() {
154+
local archive="$1"
155+
local kind="$2"
156+
local entries
157+
local actual_bridge_files
158+
actual_bridge_files="$(mktemp)"
159+
if [[ "$kind" == "tar" ]]; then
160+
entries="$(tar -tzf "$archive")"
161+
else
162+
entries="$(unzip -Z1 "$archive")"
163+
fi
164+
awk -v root="$bridge_root" '
165+
{
166+
offset = index($0, root)
167+
if (offset > 0 && substr($0, length($0), 1) != "/") {
168+
print substr($0, offset + length(root))
169+
}
170+
}
171+
' <<<"$entries" | sort > "$actual_bridge_files"
172+
if ! diff -u "$expected_bridge_files" "$actual_bridge_files"; then
173+
echo "::error::${archive} does not contain exactly the inert legacy bridge files"
174+
exit 1
175+
fi
176+
}
177+
178+
for archive in source-assets/*.tar.gz; do
179+
verify_bridge_entries "$archive" tar
180+
done
181+
verify_bridge_entries \
182+
"source-assets/a3s-${tag}-x86_64-pc-windows-msvc.zip" zip
183+
184+
validation_root="$RUNNER_TEMP/legacy-bridge-validation"
185+
mkdir "$validation_root"
186+
tar -xzf "source-assets/a3s-${tag}-x86_64-unknown-linux-gnu.tar.gz" \
187+
-C "$validation_root"
188+
mapfile -t bridge_dirs < <(
189+
find "$validation_root" -type d -path '*/support/managed-srt' -print
190+
)
191+
if [[ "${#bridge_dirs[@]}" -ne 1 ]]; then
192+
echo "::error::Release archive must contain exactly one managed-SRT bridge directory"
193+
exit 1
194+
fi
195+
bridge_dir="${bridge_dirs[0]}"
196+
package="$bridge_dir/node_modules/@anthropic-ai/sandbox-runtime/package.json"
197+
lock="$bridge_dir/package-lock.json"
198+
cli="$bridge_dir/node_modules/@anthropic-ai/sandbox-runtime/dist/cli.js"
199+
for bridge_file in "$bridge_dir/package.json" "$lock" "$package" "$cli"; do
200+
if [[ ! -f "$bridge_file" || -L "$bridge_file" ]]; then
201+
echo "::error::Legacy bridge path is not a plain file: ${bridge_file}"
202+
exit 1
203+
fi
204+
done
205+
if [[ "$(find "$bridge_dir" -type f | wc -l)" -ne 4 \
206+
|| -n "$(find "$bridge_dir" -type l -print -quit)" ]]; then
207+
echo "::error::Legacy bridge contains unexpected files or symlinks"
208+
exit 1
209+
fi
210+
package_version="$(jq -er \
211+
'select(.name == "@anthropic-ai/sandbox-runtime") | .version' "$package")"
212+
lock_version="$(jq -er \
213+
'.packages["node_modules/@anthropic-ai/sandbox-runtime"].version' "$lock")"
214+
dependency_version="$(jq -er \
215+
'.packages[""].dependencies["@anthropic-ai/sandbox-runtime"]' "$lock")"
216+
if [[ "$(jq -er '.lockfileVersion' "$lock")" -ne 3 \
217+
|| "$package_version" != "$lock_version" \
218+
|| "$package_version" != "$dependency_version" ]]; then
219+
echo "::error::Legacy bridge does not satisfy the old updater package contract"
220+
exit 1
221+
fi
222+
if ! grep -Fq 'Anthropic SRT was removed from A3S' "$cli" \
223+
|| ! grep -Fq 'process.exitCode = 1' "$cli" \
224+
|| grep -Eq 'child_process|require\(|^import |eval\(' "$cli"; then
225+
echo "::error::Legacy bridge CLI is not an inert fail-closed marker"
226+
exit 1
227+
fi
228+
229+
set +e
230+
node "$cli" > bridge.stdout 2> bridge.stderr
231+
bridge_status=$?
232+
set -e
233+
if [[ "$bridge_status" -ne 1 || -s bridge.stdout ]] \
234+
|| ! grep -Fq 'Anthropic SRT was removed from A3S' bridge.stderr; then
235+
echo "::error::Legacy bridge CLI did not fail closed"
236+
exit 1
237+
fi
238+
239+
echo "source_sha=${source_sha}" >> "$GITHUB_OUTPUT"
240+
echo "source_release_id=${source_release_id}" >> "$GITHUB_OUTPUT"
241+
242+
- name: Publish the verified legacy relay
243+
shell: bash
244+
env:
245+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
246+
RELEASE_TAG: ${{ inputs.tag }}
247+
SOURCE_SHA: ${{ steps.source.outputs.source_sha }}
248+
SOURCE_RELEASE_ID: ${{ steps.source.outputs.source_release_id }}
249+
run: |
250+
set -euo pipefail
251+
tag="$RELEASE_TAG"
252+
marker="<!-- a3s-legacy-release-relay:v1 source:${SOURCE_REPOSITORY} tag:${tag} sha:${SOURCE_SHA} release-id:${SOURCE_RELEASE_ID} -->"
253+
cat > relay-notes.md <<EOF
254+
# A3S ${tag}
255+
256+
A3S CLI development and releases have moved to
257+
[A3S-Lab/a3s](https://github.com/A3S-Lab/a3s). This compatibility
258+
release mirrors the verified CLI archives from the canonical
259+
[${tag} release](https://github.com/${SOURCE_REPOSITORY}/releases/tag/${tag})
260+
so older A3S installations can update themselves to the new channel.
261+
262+
${marker}
263+
EOF
264+
265+
verify_relay_assets() {
266+
rm -rf relayed-assets
267+
mkdir relayed-assets
268+
gh release download "$tag" --repo "$GITHUB_REPOSITORY" \
269+
--dir relayed-assets --pattern "a3s-${tag}-*"
270+
find relayed-assets -mindepth 1 -maxdepth 1 -type f \
271+
-exec basename {} \; | sort > relayed-assets.txt
272+
if ! diff -u expected-assets.txt relayed-assets.txt; then
273+
echo "::error::Legacy relay ${tag} does not contain the exact CLI asset set"
274+
exit 1
275+
fi
276+
while IFS= read -r asset; do
277+
if ! cmp -s "source-assets/${asset}" "relayed-assets/${asset}"; then
278+
echo "::error::Relayed asset differs from canonical source: ${asset}"
279+
exit 1
280+
fi
281+
done < expected-assets.txt
282+
}
283+
284+
if release_json="$(gh release view "$tag" --repo "$GITHUB_REPOSITORY" \
285+
--json body,isDraft,isPrerelease,name,tagName 2>/dev/null)"; then
286+
if ! jq -e --arg tag "$tag" --arg marker "$marker" \
287+
'.isPrerelease == false and .tagName == $tag and .name == $tag
288+
and (.body | contains($marker))' <<<"$release_json" >/dev/null; then
289+
echo "::error::Existing ${tag} release is not owned by this relay"
290+
exit 1
291+
fi
292+
if jq -e '.isDraft == false' <<<"$release_json" >/dev/null; then
293+
verify_relay_assets
294+
gh release edit "$tag" --repo "$GITHUB_REPOSITORY" \
295+
--draft=false --prerelease=false --latest
296+
exit 0
297+
fi
298+
else
299+
if gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${tag}" >/dev/null 2>&1; then
300+
echo "::error::Tag ${tag} already exists without an owned relay release"
301+
exit 1
302+
fi
303+
gh release create "$tag" --repo "$GITHUB_REPOSITORY" \
304+
--target "$GITHUB_SHA" --draft --prerelease=false --title "$tag" \
305+
--notes-file relay-notes.md
306+
fi
307+
308+
gh release upload "$tag" --repo "$GITHUB_REPOSITORY" \
309+
source-assets/* --clobber
310+
verify_relay_assets
311+
gh release edit "$tag" --repo "$GITHUB_REPOSITORY" \
312+
--draft=false --prerelease=false --latest
313+
314+
for attempt in 1 2 3 4 5; do
315+
latest="$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq '.tag_name')"
316+
if [[ "$latest" == "$tag" ]]; then
317+
exit 0
318+
fi
319+
if [[ "$attempt" -lt 5 ]]; then
320+
sleep 2
321+
fi
322+
done
323+
echo "::error::Legacy relay ${tag} was published but is not the latest release"
324+
exit 1

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- Converted this repository into a compatibility release endpoint after CLI
13+
development moved to `A3S-Lab/a3s`. A manual relay verifies canonical
14+
monorepo archives byte for byte and publishes only GitHub release assets; it
15+
cannot publish crates or update Homebrew.
16+
1017
## [0.10.15] - 2026-07-29
1118

1219
### Fixed

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# a3s
22

3+
> [!IMPORTANT]
4+
> Active development and releases have moved to
5+
> [A3S-Lab/a3s](https://github.com/A3S-Lab/a3s). This repository remains
6+
> available only as a compatibility release endpoint for older A3S clients.
7+
> Use the monorepo for source installs, issues, and pull requests.
8+
39
The umbrella CLI for the [A3S](https://github.com/A3S-Lab) platform.
410

511
`a3s <tool> [args...]` runs the matching A3S product. Code is included in the
@@ -56,7 +62,7 @@ is unavailable.
5662
cargo install a3s
5763

5864
# or from source
59-
cargo install --git https://github.com/A3S-Lab/Cli
65+
cargo install --git https://github.com/A3S-Lab/a3s
6066

6167
# or Homebrew
6268
brew install A3S-Lab/tap/a3s

0 commit comments

Comments
 (0)