|
| 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 |
0 commit comments