Skip to content

Commit 7b7ccfe

Browse files
authored
fix(release): harden the release pipeline before signing secrets land (#1)
* fix(release): harden the signers, release workflow and updater * fix(update): keep HERMES_DATA_DIR_SUFFIX for the refresh after a rollback * fix(release): satisfy CI's shellcheck and describe the code-owner rulesets
1 parent 144dbea commit 7b7ccfe

26 files changed

Lines changed: 760 additions & 72 deletions

‎.github/release/client.sh.sha256‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
f19e3852cca5b675a0871487a035fc5ed42a871df429da10fd688568d1ad526e

‎.github/release/lib.sh‎

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -74,17 +74,69 @@ target_file() {
7474
printf '%s' "$file"
7575
}
7676

77-
# repack <work dir> <out tarball>: packs <work dir>/package as npm pack lays it out (files only, root-owned, fixed
78-
# mtime, the binary 0755 and the rest 0644), so publish-npm.sh's listing check holds for a re-signed tarball.
77+
# release_client <out>: downloads the release service's client (https://anoma.ly/release/client.sh) to <out> and checks
78+
# it against the SHA-256 pinned in client.sh.sha256 before anything runs it, so a change to the served client can't run
79+
# in a job holding an OIDC token until a reviewed change here pins it. RELEASING.md says how to move the pin.
80+
release_client() {
81+
local out=$1 pinned actual
82+
pinned=$(tr -d ' \n' < "$(dirname "${BASH_SOURCE[0]}")/client.sh.sha256")
83+
curl -fsSL --retry 5 --retry-all-errors "${RELEASE_ORIGIN:-https://anoma.ly/release}/client.sh" -o "$out"
84+
actual=$(sha256 "$out" | cut -c1-64)
85+
[ "$actual" = "$pinned" ] || die "The release service's client.sh has SHA-256 $actual, not the pinned $pinned (.github/release/client.sh.sha256)."
86+
}
87+
88+
# members <target> [signed]: the files a target's tarball holds, as npm pack lays them out and in the order repack writes
89+
# them: LICENSE, the binary (opencode-console.exe on Windows), with "signed" the Linux binary's .sig, and package.json.
90+
members() {
91+
local executable=opencode-console
92+
case "$1" in windows-*) executable=opencode-console.exe ;; esac
93+
if [ "${2:-}" = signed ]; then
94+
printf '%s' "package/LICENSE package/bin/$executable package/bin/$executable.sig package/package.json"
95+
else
96+
printf '%s' "package/LICENSE package/bin/$executable package/package.json"
97+
fi
98+
}
99+
100+
# unpack <tarball> <dir> <member>...: extracts a tarball that holds exactly <member>..., each once and each a regular
101+
# file, into <dir>. Anything else (a directory, a link, a duplicate, an extra entry, a name with a newline, which lists as
102+
# two lines or escaped and so never matches) fails the run before a byte of it is written.
103+
unpack() {
104+
local file=$1 dir=$2 names expected member
105+
shift 2
106+
names=$(tar -tzf "$file") || die "$file is not a gzipped tarball."
107+
expected=$(printf '%s\n' "$@" | LC_ALL=C sort)
108+
[ "$(printf '%s\n' "$names" | LC_ALL=C sort)" = "$expected" ] ||
109+
die "$file must hold exactly $*, and holds: $(printf '%s' "$names" | tr '\n' ' ')"
110+
# Every entry a regular file: GNU tar and bsdtar list one with a leading "-" and no "link to" or "->" after it.
111+
while IFS= read -r line; do
112+
case "$line" in -*) ;; *) die "$file has an entry that isn't a regular file: $line" ;; esac
113+
case "$line" in *" link to "* | *" -> "*) die "$file has a link: $line" ;; esac
114+
done < <(tar -tvzf "$file")
115+
mkdir -p "$dir"
116+
tar -xzf "$file" -C "$dir" -- "$@"
117+
for member in "$@"; do
118+
if [ ! -f "$dir/$member" ] || [ -L "$dir/$member" ]; then die "$file's $member didn't unpack as a regular file."; fi
119+
done
120+
}
121+
122+
# repack <work dir> <out tarball> <member>...: packs exactly <member>... from <work dir> as npm pack lays them out
123+
# (files only, root-owned, fixed mtime, the binary 0755 and the rest 0644), so publish-npm.sh's listing check holds for
124+
# a re-signed tarball. The names are the caller's fixed list, never read back from the unpacked tree.
79125
repack() {
80-
local work=$1 out=$2
81-
find "$work/package" -type f -exec chmod 644 {} +
82-
find "$work/package/bin" -type f ! -name '*.sig' -exec chmod 755 {} +
83-
find "$work/package" -type f -exec touch -t 198001010000 {} +
84-
(cd "$work" && find package -type f | LC_ALL=C sort > "$work/files")
126+
local work=$1 out=$2 member
127+
shift 2
128+
for member in "$@"; do
129+
if [ ! -f "$work/$member" ] || [ -L "$work/$member" ]; then die "$work/$member is not a regular file."; fi
130+
case "$member" in
131+
package/bin/*.sig | package/LICENSE | package/package.json) chmod 644 "$work/$member" ;;
132+
package/bin/*) chmod 755 "$work/$member" ;;
133+
*) die "repack doesn't pack $member." ;;
134+
esac
135+
touch -t 198001010000 "$work/$member"
136+
done
85137
if tar --version 2> /dev/null | grep -q GNU; then
86-
tar --owner 0 --group 0 --numeric-owner -czf "$out" -C "$work" -T "$work/files"
138+
tar --owner 0 --group 0 --numeric-owner -czf "$out" -C "$work" -- "$@"
87139
else
88-
COPYFILE_DISABLE=1 tar --no-mac-metadata --uid 0 --gid 0 -czf "$out" -C "$work" -T "$work/files"
140+
COPYFILE_DISABLE=1 tar --no-mac-metadata --uid 0 --gid 0 -czf "$out" -C "$work" -- "$@"
89141
fi
90142
}

‎.github/release/publish-release-service.sh‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ fi
4444

4545
work=$(mktemp -d)
4646
trap 'rm -rf "$work"' EXIT
47-
curl -fsSL --retry 5 --retry-all-errors "$release/client.sh" -o "$work/client.sh"
47+
RELEASE_ORIGIN=$release release_client "$work/client.sh"
4848
# npm's copy is recorded once it's on npm (publish-npm.sh); dev builds aren't published there.
4949
distributions='[{"kind":"direct"}]'
5050
if [ "$CONSOLE_CHANNEL" != dev ]; then

‎.github/release/reserve-tag.sh‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
#!/usr/bin/env bash
2+
# reserve-tag.sh: claims refs/tags/v$CONSOLE_VERSION at $SHA before a stable release publishes anything, so the tag
3+
# `finish` releases can't point at another commit. Creates it through the API; when it already exists, resolves it,
4+
# peeling annotated tags, and fails unless it's $SHA. A rerun of the same run, or a new run at the same commit, finds
5+
# its own tag and goes on.
6+
#
7+
# Environment: CONSOLE_VERSION, SHA, GH_REPO, GH_TOKEN (contents: write).
8+
. "$(dirname "$0")/lib.sh"
9+
10+
: "${CONSOLE_VERSION:?Set CONSOLE_VERSION}" "${SHA:?Set SHA}" "${GH_REPO:?Set GH_REPO}"
11+
[[ "$SHA" =~ ^[0-9a-f]{40}$ ]] || die "SHA must be a full commit SHA, not $SHA"
12+
tag="v$CONSOLE_VERSION"
13+
14+
if gh api -X POST "repos/$GH_REPO/git/refs" -f "ref=refs/tags/$tag" -f "sha=$SHA" > /dev/null 2>&1; then
15+
summary "Reserved $tag at $SHA."
16+
exit 0
17+
fi
18+
19+
# It exists, or the API refused for another reason, which reading it back tells apart.
20+
ref=$(gh api "repos/$GH_REPO/git/ref/tags/$tag") || die "Couldn't create $tag, and it doesn't exist."
21+
type=$(jq -r .object.type <<< "$ref") object=$(jq -r .object.sha <<< "$ref")
22+
for _ in 1 2 3 4 5 6 7 8; do
23+
[ "$type" = tag ] || break
24+
peeled=$(gh api "repos/$GH_REPO/git/tags/$object")
25+
type=$(jq -r .object.type <<< "$peeled") object=$(jq -r .object.sha <<< "$peeled")
26+
done
27+
[ "$type" = commit ] || die "$tag points at a $type, not a commit."
28+
[ "$object" = "$SHA" ] || die "$tag already points at $object, not this run's $SHA. Bump the version, or delete the tag if it was made by mistake."
29+
summary "$tag is already at $SHA; this run goes on."

‎.github/release/sign-linux.sh‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ for target in $TARGETS; do
2929
case "$target" in linux-*) ;; *) continue ;; esac
3030
file="$in/$(tarball_name "$target")"
3131
test -s "$file" || die "Missing $file"
32-
mkdir -p "$work/$target"
33-
tar -xzf "$file" -C "$work/$target" package/package.json package/LICENSE package/bin/opencode-console
32+
read -ra unsigned <<< "$(members "$target")"
33+
unpack "$file" "$work/$target" "${unsigned[@]}"
3434
bin="$work/$target/package/bin/opencode-console"
3535
sha=$(sha256 "$bin" | cut -c1-64)
3636
printf 'anomaly.opencode-console.linux-artifact.v1\n%s\n' "$sha" > "$work/message"
@@ -40,6 +40,7 @@ for target in $TARGETS; do
4040
openssl pkeyutl -verify -rawin -pubin -inkey "$public" \
4141
-in "$work/message" -sigfile "$bin.sig" > /dev/null ||
4242
die "CONSOLE_CLI_RELEASE_SIGNING_KEY does not match linux-signing.pub, the key the updater trusts."
43-
repack "$work/$target" "$out/$(tarball_name "$target")"
43+
read -ra signed <<< "$(members "$target" signed)"
44+
repack "$work/$target" "$out/$(tarball_name "$target")" "${signed[@]}"
4445
echo "Signed $target ($sha)."
4546
done

‎.github/release/sign-macos.sh‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,15 @@ export COPYFILE_DISABLE=1
2626
for target in darwin-arm64 darwin-x64 darwin-x64-baseline; do
2727
file="$in/$(tarball_name "$target")"
2828
test -s "$file" || die "Missing $file"
29-
mkdir -p "$work/$target"
30-
tar -xzf "$file" -C "$work/$target" package/package.json package/LICENSE package/bin/opencode-console
29+
read -ra files <<< "$(members "$target")"
30+
unpack "$file" "$work/$target" "${files[@]}"
3131
bin="$work/$target/package/bin/opencode-console"
3232
file -b "$bin" | grep -q '^Mach-O' || die "$target is not a Mach-O binary"
3333
if dry_run; then
3434
codesign --force --options runtime --entitlements "$release/entitlements/cli.plist" \
3535
--identifier anomaly.opencode-console --sign - "$bin"
3636
codesign --verify --strict --verbose=2 -R='identifier "anomaly.opencode-console"' "$bin"
37-
repack "$work/$target" "$out/$(tarball_name "$target")"
37+
repack "$work/$target" "$out/$(tarball_name "$target")" "${files[@]}"
3838
echo "Dry run: signed $(tarball_name "$target") ad hoc."
3939
continue
4040
fi
@@ -57,6 +57,6 @@ for target in darwin-arm64 darwin-x64 darwin-x64-baseline; do
5757
[ "$attempt" -lt 6 ] || die "$target doesn't satisfy macos-requirement.txt after notarization."
5858
sleep 20
5959
done
60-
repack "$work/$target" "$out/$(tarball_name "$target")"
60+
repack "$work/$target" "$out/$(tarball_name "$target")" "${files[@]}"
6161
echo "Signed and notarized $(tarball_name "$target")."
6262
done

‎.github/release/windows-binaries.sh‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,19 @@
66

77
action=$1
88
for target in windows-x64 windows-x64-baseline; do
9+
read -ra files <<< "$(members "$target")"
910
case "$action" in
1011
unpack)
1112
in=$(posix "$2") work=$(posix "$3")
12-
mkdir -p "$work/$target" "$work/sign"
13-
tar -xzf "$in/$(tarball_name "$target")" -C "$work/$target" package/package.json package/LICENSE package/bin/opencode-console.exe
13+
mkdir -p "$work/sign"
14+
unpack "$in/$(tarball_name "$target")" "$work/$target" "${files[@]}"
1415
cp "$work/$target/package/bin/opencode-console.exe" "$work/sign/opencode-console-$target.exe"
1516
;;
1617
repack)
1718
work=$(posix "$2") out=$(posix "$3")
1819
mkdir -p "$out"
1920
cp "$work/sign/opencode-console-$target.exe" "$work/$target/package/bin/opencode-console.exe"
20-
repack "$work/$target" "$out/$(tarball_name "$target")"
21+
repack "$work/$target" "$out/$(tarball_name "$target")" "${files[@]}"
2122
echo "Signed $(tarball_name "$target")."
2223
;;
2324
*) die "Usage: windows-binaries.sh unpack <binaries> <work> | repack <work> <out>" ;;

‎.github/workflows/promotion-pr.yml‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ jobs:
2626
run: |
2727
set -euo pipefail
2828
29-
pr="$(gh pr list --base production --head dev --state open --json url --jq '.[0].url // empty')"
29+
# --head matches a branch name in any fork, so a fork's own dev could stand in for this one: only a PR
30+
# from this repository's dev counts.
31+
pr="$(gh pr list --base production --head dev --state open --json url,isCrossRepository,headRepository \
32+
--jq "[.[] | select(.isCrossRepository == false and .headRepository.nameWithOwner == \"$GH_REPO\")][0].url // empty")"
3033
if [[ -n "$pr" ]]; then
3134
echo "Promotion PR already open: $pr"
3235
exit 0

‎.github/workflows/publish-console-cli.yml‎

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ run-name: "${{ github.event_name == 'workflow_dispatch' && format('Console CLI {
2525
# - The release service runs in environment release (dev and production), which holds no secrets: the service admits
2626
# it by OIDC. npm runs in environment npm (production only, no secrets), which every package's trusted publishing
2727
# names, so no dev run can mint a token npm accepts.
28-
# .github/ is the security boundary for releases: changes to it need review by someone who may release.
28+
# .github/ is the security boundary for releases. Changes to it need a code owner's review, which repository admins
29+
# may skip when merging a pull request. Required Checks and the merge methods apply to everyone, and only admins and
30+
# maintainers may merge into production.
2931

3032
on:
3133
push:
@@ -119,10 +121,14 @@ jobs:
119121
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
120122
with:
121123
node-version-file: .node-version
124+
package-manager-cache: false
125+
# No step here restores a cache: dev is the default branch, so a production run could restore one a dev run
126+
# wrote, and the build would run what it holds.
122127
- &setup-bun
123128
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
124129
with:
125130
bun-version: "1.4.3"
131+
no-cache: true
126132
- run: bun install --frozen-lockfile
127133
# Stamped into the checkout only, never committed.
128134
- &stamp
@@ -274,8 +280,9 @@ jobs:
274280
name: signed-macos
275281
path: ${{ runner.temp }}/signed
276282

277-
# Authenticode through Azure Artifact Signing for the two Windows binaries, signed in by OIDC (the federated
278-
# credential names repo:anomalyco/opencode-console-cli:environment:signing). A check run repacks them unsigned.
283+
# Authenticode through Azure Artifact Signing for the two Windows binaries, signed in by OIDC. This repository uses
284+
# immutable OIDC subjects, so the federated credential names
285+
# repo:anomalyco@66570915/opencode-console-cli@1452060248:environment:signing. A check run repacks them unsigned.
279286
sign-windows:
280287
name: Sign the Windows binaries
281288
needs: [plan, build-binaries]
@@ -352,6 +359,8 @@ jobs:
352359
timestamp-digest: SHA256
353360
description: OpenCode Console CLI
354361
description-url: https://opencode.ai/console
362+
# Its PowerShell module and build tools would otherwise come from a cache any branch's run can write.
363+
cache-dependencies: false
355364
exclude-environment-credential: true
356365
exclude-workload-identity-credential: true
357366
exclude-managed-identity-credential: true
@@ -424,16 +433,42 @@ jobs:
424433

425434
# ---- Publish: only .github/release/ is checked out; artifacts are data --------------------------------------------
426435

436+
# Stable only, before anything leaves GitHub: claims refs/tags/v<version> at this run's commit (reserve-tag.sh), so a
437+
# tag someone made at another commit stops the release before it publishes rather than after. A rerun at the same
438+
# commit finds its own tag.
439+
reserve-tag:
440+
name: Reserve the release tag
441+
needs: [plan, build, build-binaries, sign-macos, sign-windows, sign-linux]
442+
if: >-
443+
${{ !cancelled() && needs.plan.outputs.mode == 'stable' && needs.build.result == 'success'
444+
&& needs.build-binaries.result == 'success' && needs.sign-macos.result == 'success'
445+
&& needs.sign-windows.result == 'success' && needs.sign-linux.result == 'success' }}
446+
runs-on: ubuntu-24.04
447+
timeout-minutes: 5
448+
environment: release
449+
permissions:
450+
contents: write # creates the tag's ref
451+
steps:
452+
- *checkout-release
453+
- name: Reserve
454+
run: bash .github/release/reserve-tag.sh
455+
env:
456+
GH_TOKEN: ${{ github.token }}
457+
GH_REPO: ${{ github.repository }}
458+
CONSOLE_VERSION: ${{ needs.plan.outputs.version }}
459+
SHA: ${{ github.sha }}
460+
427461
# The job's OIDC token is the only credential; the service checks it against its registry entry for opencode-console
428462
# (this repository and workflow, environment release, dev for channel dev and production for stable and beta).
429463
publish-release-service:
430464
name: Publish to the release service
431-
needs: [plan, build, build-binaries, sign-macos, sign-windows, sign-linux]
432-
# Dev skips signing; every other run needs all three signed.
465+
needs: [plan, build, build-binaries, sign-macos, sign-windows, sign-linux, reserve-tag]
466+
# Dev skips signing; every other run needs all three signed, and stable its tag reserved.
433467
if: >-
434468
${{ !cancelled() && needs.build.result == 'success' && needs.build-binaries.result == 'success'
435469
&& (needs.plan.outputs.mode == 'dev' || (needs.sign-macos.result == 'success'
436-
&& needs.sign-windows.result == 'success' && needs.sign-linux.result == 'success')) }}
470+
&& needs.sign-windows.result == 'success' && needs.sign-linux.result == 'success'))
471+
&& (needs.plan.outputs.mode != 'stable' || needs.reserve-tag.result == 'success') }}
437472
runs-on: ubuntu-24.04
438473
timeout-minutes: 20
439474
environment: ${{ needs.plan.outputs.dry_run != 'true' && 'release' || '' }}
@@ -485,6 +520,7 @@ jobs:
485520
with:
486521
node-version: "24"
487522
check-latest: true
523+
package-manager-cache: false
488524
- *download-all
489525
- name: Publish
490526
run: |
@@ -518,7 +554,8 @@ jobs:
518554
CONSOLE_VERSION: ${{ needs.plan.outputs.version }}
519555
CONSOLE_CHANNEL: ${{ needs.plan.outputs.channel }}
520556
run: |
521-
curl -fsSL --retry 5 --retry-all-errors https://anoma.ly/release/client.sh -o "$RUNNER_TEMP/client.sh"
557+
. .github/release/lib.sh
558+
release_client "$RUNNER_TEMP/client.sh"
522559
bash "$RUNNER_TEMP/client.sh" distribution --app opencode-console --artifact cli --channel "$CONSOLE_CHANNEL" \
523560
--entry "$(jq -nc --arg version "$CONSOLE_VERSION" '{kind: "npm", version: $version}')"
524561
- name: Tag and GitHub release
@@ -528,12 +565,19 @@ jobs:
528565
GH_REPO: ${{ github.repository }}
529566
CONSOLE_VERSION: ${{ needs.plan.outputs.version }}
530567
SHA: ${{ github.sha }}
568+
# The tag reserve-tag made at this commit; --verify-tag refuses to make one. A rerun after the release was made
569+
# uploads its files again in place.
531570
run: |
532571
. .github/release/lib.sh
533-
files=()
572+
files=("$RUNNER_TEMP/in/build/opencode-console-$CONSOLE_VERSION.tgz")
534573
for target in $TARGETS; do
535574
files+=("$(target_file "$target" "$RUNNER_TEMP/in/binaries" "$RUNNER_TEMP/in/signed-macos" "$RUNNER_TEMP/in/signed-windows" "$RUNNER_TEMP/in/signed-linux")")
536575
done
537-
gh release create "v$CONSOLE_VERSION" --target "$SHA" --title "OpenCode Console CLI $CONSOLE_VERSION" \
538-
--notes "@opencode/console $CONSOLE_VERSION: https://anoma.ly/release/opencode-console/cli/stable.json" \
539-
"$RUNNER_TEMP/in/build/opencode-console-$CONSOLE_VERSION.tgz" "${files[@]}"
576+
SHA=$SHA bash .github/release/reserve-tag.sh
577+
if gh release view "v$CONSOLE_VERSION" > /dev/null 2>&1; then
578+
gh release upload "v$CONSOLE_VERSION" --clobber "${files[@]}"
579+
else
580+
gh release create "v$CONSOLE_VERSION" --verify-tag --title "OpenCode Console CLI $CONSOLE_VERSION" \
581+
--notes "@opencode/console $CONSOLE_VERSION: https://anoma.ly/release/opencode-console/cli/stable.json" \
582+
"${files[@]}"
583+
fi

‎AGENTS.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ every push to it publishes a dev build. Beta and stable releases are dispatched
1818
`src/hermes*.ts`: each agent's setup, hook or in-process plugin.
1919
- `src/credentials.ts`, `src/store.ts`: the sign-in and the CLI's data directory.
2020
- `src/install.ts`, `src/update/`: where setup installs the CLI, self-update, signature checks and rollback.
21-
- `install`, `install.ps1`: the standalone installers served at `https://opencode.ai/console/install[.ps1]`.
21+
- `install`, `install.ps1`: the standalone installers, from `production` on `raw.githubusercontent.com` until Console
22+
serves them at `https://opencode.ai/console/install[.ps1]` (RELEASING.md, "The installers").
2223
- `.github/release/`: the release scripts. Jobs holding secrets run only these.
2324
- `test/support/`: the stand-in Console and release service every test runs against.
2425

@@ -59,4 +60,6 @@ every push to it publishes a dev build. Beta and stable releases are dispatched
5960

6061
Pull requests squash-merge into `dev`, and every push to `dev` publishes a dev build. `dev` reaches `production`
6162
through the promotion pull request, merged with a merge commit. Stable and beta are dispatched on `production`; see
62-
`RELEASING.md`. Changes under `.github/` need a code owner's review.
63+
`RELEASING.md`. Changes under `.github/` need a code owner's review, which repository admins may skip when merging a
64+
pull request; required `Checks` and the merge methods apply to everyone, and only admins and maintainers may merge
65+
into `production`.

0 commit comments

Comments
 (0)