Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions .github/scripts/publish-release-to-s3.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# Publish one release's assets to releases/analytics-cli/prod/<version>/ in the trunk-releases
# bucket (served at https://trunk.io/releases/analytics-cli/prod/). Never moves channel.json:
# a published version is a prerelease until promote_release.yml points latest at it.
#
# Usage: publish-release-to-s3.sh [--skip-complete] <version> <asset-dir>
#
# Every *.tar.gz / *.zip in <asset-dir> is uploaded under its own filename. Objects are
# immutable: an asset already in the bucket is skipped if its recorded sha256 matches and is an
# error if it differs. manifest.json goes up last, so its presence means the version is
# complete. With --skip-complete (backfills), a version whose manifest already exists is a
# no-op; without it, that is an error.
set -euo pipefail

bucket="${BUCKET:-trunk-releases}"
prefix="${PREFIX:-releases/analytics-cli/prod}"

skip_complete=false
if [[ ${1-} == "--skip-complete" ]]; then
skip_complete=true
shift
fi
version="${1:?usage: publish-release-to-s3.sh [--skip-complete] <version> <asset-dir>}"
asset_dir="${2:?usage: publish-release-to-s3.sh [--skip-complete] <version> <asset-dir>}"

if [[ ! ${version} =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::'${version}' is not a semver version"
exit 1
fi

# Prints the object's recorded sha256, or nothing when the key does not exist. Any failure other
# than a 404 aborts: without s3:ListBucket a missing key reads as 403, which must not be taken
# for "absent".
recorded_sha256() {
local key="$1" output
if output="$(aws s3api head-object --bucket "${bucket}" --key "${key}" \
--query 'Metadata.sha256' --output text 2>&1)"; then
echo "${output}"
elif [[ ${output} != *"Not Found"* && ${output} != *"404"* ]]; then
echo "::error::head-object s3://${bucket}/${key} failed: ${output}" >&2
return 1
fi
}

version_prefix="${prefix}/${version}"
manifest_key="${version_prefix}/manifest.json"

manifest_sha="$(recorded_sha256 "${manifest_key}")"
if [[ -n ${manifest_sha} ]]; then
if [[ ${skip_complete} == true ]]; then
echo "${version} is already published (s3://${bucket}/${manifest_key}); nothing to do"
exit 0
fi
echo "::error::${version} is already published (s3://${bucket}/${manifest_key}); released versions are immutable"
exit 1
fi

shopt -s nullglob
assets=("${asset_dir}"/*.tar.gz "${asset_dir}"/*.zip)
if [[ ${#assets[@]} -eq 0 ]]; then
echo "::error::no *.tar.gz or *.zip assets in ${asset_dir}"
exit 1
fi

manifest="$(jq -n --arg v "${version}" '{version: $v, artifacts: {}}')"
for asset in "${assets[@]}"; do
name="$(basename "${asset}")"
key="${version_prefix}/${name}"
sha="$(sha256sum "${asset}" | cut -d' ' -f1)"
case "${name}" in
*.zip) content_type="application/zip" ;;
*) content_type="application/gzip" ;;
esac

existing="$(recorded_sha256 "${key}")"
if [[ -z ${existing} ]]; then
aws s3 cp "${asset}" "s3://${bucket}/${key}" --only-show-errors \
--content-type "${content_type}" \
--metadata "sha256=${sha},release-version=${version}"
echo "uploaded ${key}"
elif [[ ${existing} == "${sha}" ]]; then
echo "already uploaded ${key}"
else
echo "::error::s3://${bucket}/${key} already exists with sha256 ${existing}, not ${sha}; released artifacts are immutable"
exit 1
fi

manifest="$(jq --arg n "${name}" --arg s "${sha}" '.artifacts[$n] = {sha256: $s}' <<<"${manifest}")"
done

manifest_file="$(mktemp)"
jq . <<<"${manifest}" >"${manifest_file}"
aws s3 cp "${manifest_file}" "s3://${bucket}/${manifest_key}" --only-show-errors \
--content-type "application/json" \
--metadata "sha256=$(sha256sum "${manifest_file}" | cut -d' ' -f1),release-version=${version}"
rm -f "${manifest_file}"
echo "published ${version} to https://trunk.io/${version_prefix}/ (${#assets[@]} artifacts)"
30 changes: 30 additions & 0 deletions .github/scripts/set-latest-in-s3.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Point releases/analytics-cli/prod/channel.json at an already-published version. This is the
# only mutable object in the prefix; consumers read `latest` from it.
#
# Usage: set-latest-in-s3.sh <version>
set -euo pipefail

bucket="${BUCKET:-trunk-releases}"
prefix="${PREFIX:-releases/analytics-cli/prod}"
version="${1:?usage: set-latest-in-s3.sh <version>}"

if [[ ! ${version} =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::'${version}' is not a semver version"
exit 1
fi

manifest_key="${prefix}/${version}/manifest.json"
if ! output="$(aws s3api head-object --bucket "${bucket}" --key "${manifest_key}" 2>&1)"; then
if [[ ${output} == *"Not Found"* || ${output} == *"404"* ]]; then
echo "::error::no s3://${bucket}/${manifest_key}; ${version} was never published (or only partially). Publish it first (mirror_release_to_s3.yml)."
else
echo "::error::head-object s3://${bucket}/${manifest_key} failed: ${output}"
fi
exit 1
fi

jq -nc --arg v "${version}" '{latest: $v}' |
aws s3 cp - "s3://${bucket}/${prefix}/channel.json" --only-show-errors \

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noting the potential for accidentally overwriting other things here. Acceptable for now, just noting the one potential hole

--content-type "application/json" --cache-control "max-age=60"
echo "https://trunk.io/${prefix}/channel.json now points at ${version}"
33 changes: 33 additions & 0 deletions .github/scripts/verify-github-digests.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# GitHub records asset digests only since June 2025 (0.10.0-beta.0 on); older assets pass unchecked.
# Usage: verify-github-digests.sh <tag> <asset-dir>
set -euo pipefail

tag="${1:?usage: verify-github-digests.sh <tag> <asset-dir>}"
asset_dir="${2:?usage: verify-github-digests.sh <tag> <asset-dir>}"

declare -A digests
while IFS=$'\t' read -r name digest; do
digests["${name}"]="${digest}"
done < <(gh api "repos/{owner}/{repo}/releases/tags/${tag}" \
--jq '.assets[] | [.name, (.digest // "")] | @tsv')

verified=0
unverified=0
shopt -s nullglob
for asset in "${asset_dir}"/*.tar.gz "${asset_dir}"/*.zip; do
name="$(basename "${asset}")"
expected="${digests[${name}]-}"
if [[ -z ${expected} ]]; then
unverified=$((unverified + 1))
continue
fi
actual="sha256:$(sha256sum "${asset}" | cut -d' ' -f1)"
if [[ ${actual} != "${expected}" ]]; then
echo "::error::${tag}/${name}: downloaded ${actual}, GitHub recorded ${expected}"
exit 1
fi
verified=$((verified + 1))
done

echo "${tag}: ${verified} asset(s) match GitHub's digest, ${unverified} have no GitHub digest"
58 changes: 58 additions & 0 deletions .github/workflows/mirror_release_to_s3.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: Mirror Releases to S3
run-name: Mirror ${{ inputs.tags }} to S3
on:
workflow_dispatch:
inputs:
# trunk-ignore(checkov/CKV_GHA_7)
tags:
type: string
description: Space-separated release tags to copy from GitHub to trunk.io/releases/analytics-cli/prod/, or `all`
required: true
set-latest-to-github-latest:
type: boolean
description: Afterwards, point channel.json at GitHub's current latest release
default: false
concurrency: ${{ github.workflow }}

permissions:
contents: read

jobs:
mirror:
name: Mirror ${{ inputs.tags }}
runs-on: ubuntu-latest
timeout-minutes: 120
permissions:
contents: read
id-token: write
env:
TAGS: ${{ inputs.tags }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4

- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.TRUNK_RELEASES_PUBLISH_ROLE_ARN }}
role-skip-session-tagging: true
aws-region: us-west-2

# --skip-complete makes a re-run cheap: versions already published are skipped, and a
# version interrupted mid-upload is finished.
- name: Mirror
run: |
if [[ ${TAGS} == all ]]; then
TAGS="$(gh release list --limit 1000 --exclude-drafts --json tagName --jq '.[].tagName' | tr '\n' ' ')"
fi
for tag in ${TAGS}; do
dir="$(mktemp -d)"
gh release download "${tag}" --dir "${dir}" --pattern '*.tar.gz' --pattern '*.zip'
.github/scripts/verify-github-digests.sh "${tag}" "${dir}"
.github/scripts/publish-release-to-s3.sh --skip-complete "${tag}" "${dir}"
rm -rf "${dir}"
done

- name: Point channel.json at GitHub's latest release
if: inputs.set-latest-to-github-latest
run: .github/scripts/set-latest-in-s3.sh "$(gh release view --json tagName --jq .tagName)"
51 changes: 51 additions & 0 deletions .github/workflows/promote_release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: Promote Release
run-name: Promote ${{ inputs.version }} to latest
on:
workflow_dispatch:
inputs:
# trunk-ignore(checkov/CKV_GHA_7)
version:
type: string
description: Already-released version to make latest (on GitHub and at trunk.io/releases/analytics-cli/prod/channel.json)
required: true
concurrency: ${{ github.workflow }}

permissions:
contents: read

jobs:
promote:
name: Promote ${{ inputs.version }}
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
env:
VERSION: ${{ inputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4

- name: Check the GitHub release
run: |
if [[ ${VERSION} == *-* ]]; then
echo "::error::${VERSION} is a prerelease version; only a stable version can be latest"
exit 1
fi
gh release view "${VERSION}" --json tagName,isDraft --jq 'if .isDraft then error("release is a draft") else .tagName end'

- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.TRUNK_RELEASES_PUBLISH_ROLE_ARN }}
role-skip-session-tagging: true
aws-region: us-west-2

# S3 moves first: its preconditions (the role, a complete manifest) are the ones that fail in

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

precondition: I would like to eventually do a FT_VERSION=foo trunk flaky-tests version here as one final check before promoting

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The launcher can't pin a version yet (trunk flakytests only fetches GitHub's latest), so before promoting this runs the published launcher with just that download URL pinned to the version and asserts trunk flakytests --version reports it. There's a TODO to switch to FT_VERSION=… trunk flakytests --version once the launcher supports it. (f0ae2eb)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update: I moved this check to #1217, which is stacked on this PR, and reverted the URL-rewrite version here. #1217 runs TRUNK_ANALYTICS_CLI_VERSION=<version> trunk flakytests --version through both the bash launcher and the new CLI, and fails unless both report the version being promoted. The variable is being added to the launcher in trunk-io/trunk#34317 and to the new CLI in trunk-io/trunk2#6728, so #1217 stays a draft until both are released.

# practice, and when they do GitHub has not moved either. Both steps are idempotent, so if the
# GitHub edit fails afterwards, re-running this workflow converges the two.
- name: Point channel.json at ${{ inputs.version }}
run: .github/scripts/set-latest-in-s3.sh "${VERSION}"

- name: Mark ${{ inputs.version }} latest on GitHub
run: gh release edit "${VERSION}" --prerelease=false --latest
11 changes: 11 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,18 @@ on:
description: Version/tag to release
required: true

permissions:
contents: read

jobs:
release_cli:
name: Trigger CLI Release
uses: ./.github/workflows/release_cli.yml
# A called workflow's jobs can hold no more than its caller grants; publishing to S3 needs
# the OIDC token.
permissions:
contents: write
id-token: write
secrets: inherit
with:
release_tag: ${{ inputs.version }}
Expand All @@ -19,6 +27,9 @@ jobs:
name: Trigger Ruby Gem Release
needs: [release_cli]
uses: ./.github/workflows/release_ruby_gem.yml
permissions:
contents: read
id-token: write
secrets: inherit
with:
release_tag: ${{ inputs.version }}
15 changes: 15 additions & 0 deletions .github/workflows/release_cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,9 @@ jobs:
name: Tag and Release [ ${{ github.event.inputs.release_tag || inputs.release_tag }} ]
runs-on: ubuntu-latest
needs: [build_release, perform_smoke_test]
permissions:
contents: write
id-token: write
# only empty during testing
if: ${{ needs.perform_smoke_test.result == 'success' && (github.event.inputs.release_tag != '' || inputs.release_tag != '') }}
steps:
Expand Down Expand Up @@ -308,6 +311,18 @@ jobs:
--generate-notes ${{ github.event.inputs.release_tag || inputs.release_tag }} \
./build/*.tar.gz ./build/*.zip

- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.TRUNK_RELEASES_PUBLISH_ROLE_ARN }}
role-skip-session-tagging: true
aws-region: us-west-2

# Uploads to releases/analytics-cli/prod/<version>/ but leaves channel.json alone: like the
# GitHub release above, the version stays a prerelease until promote_release.yml.
- name: Publish release to S3
run: .github/scripts/publish-release-to-s3.sh "${{ github.event.inputs.release_tag || inputs.release_tag }}" ./build

- name: Create Sentry release
uses: getsentry/action-release@v1
env:
Expand Down
16 changes: 16 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,19 @@ The pattern is:
- Use `tracing` for observability and debugging (Sentry + optional verbose output)
- Use `superconsole` for user-facing communication
- Errors that block execution should use both: `tracing` for logging and `superconsole` for user display

## Releasing

Releases are published in two places: GitHub releases, and `https://trunk.io/releases/analytics-cli/prod/` (the `trunk-releases` S3 bucket). Trunk's own consumers (the `analytics-uploader` action and the `trunk` CLI's `flakytests` command) read the S3 copy. GitHub releases stay, because the frozen legacy `trunk` launcher and customers' own scripts download from them.

1. **Cut**: run the `Release` workflow with the version. It builds, smoke-tests, creates a GitHub **prerelease**, and uploads the same assets to `releases/analytics-cli/prod/<version>/`. A version is immutable once published.
2. **Promote**: run the `Promote Release` workflow with the version. It points `releases/analytics-cli/prod/channel.json` at it and marks it latest on GitHub. Do **not** mark a release latest in the GitHub UI, since that leaves `channel.json` behind.
3. **Backfill**: run `Mirror Releases to S3` to copy releases that predate the S3 lane (or that a failed run left incomplete). Pass space-separated tags or `all`. Re-running it is safe because complete versions are skipped. Each download is first checked against the sha256 digest GitHub recorded for it; releases from before June 2025 have no digest and are copied unchecked.

Layout under `releases/analytics-cli/prod/`:

- `channel.json`: `{"latest": "<version>"}`, the only mutable object (`Cache-Control: max-age=60`).
- `<version>/<asset>`: each GitHub release asset under the same filename, e.g. `trunk-analytics-cli-x86_64-unknown-linux.tar.gz`.
- `<version>/manifest.json`: `{"version": "<version>", "artifacts": {"<asset>": {"sha256": "<hex>"}}}`, uploaded last, so its presence means the version is complete.

One-time prerequisite: the publishing workflows assume the IAM role `trunk-analytics-cli-release-role` in AWS account 443897627245 over GitHub OIDC, through the repository variable `TRUNK_RELEASES_PUBLISH_ROLE_ARN`. The role is granted only the `releases/analytics-cli/*` prefix, plus `s3:ListBucket` on it so a missing key reads as 404 rather than 403. Because this is a public repository, its trust should be restricted to `repo:trunk-io/analytics-cli:ref:refs/heads/main`, not `repo:trunk-io/analytics-cli:*`. See trunk2's `docs/runbooks/trunk-releases-publish-roles.md` for how these roles are created.
Loading