diff --git a/.github/scripts/group-release-notes.py b/.github/scripts/group-release-notes.py new file mode 100644 index 0000000..56d1511 --- /dev/null +++ b/.github/scripts/group-release-notes.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Group release-note commits by conventional-commit type. + +Reads `` lines on stdin (one commit each, as +produced by `git log --pretty=format:'%h%x09%s'`) and writes Markdown +release notes to stdout, bucketed by conventional-commit type (`feat`, +`fix`, `docs`, ...) instead of by author. + +The header is drawn from the `NEW_TAG` / `PREV_TAG` environment variables +so the output matches the previous shortlog-based notes. +""" + +import os +import re +import sys + +# Conventional-commit types in the order they should appear, mapped to +# their section headings. Types not listed here fall through to "Other". +SECTIONS = [ + ("feat", "🚀 Features"), + ("fix", "🐛 Fixes"), + ("perf", "⚡ Performance"), + ("refactor", "♻️ Refactoring"), + ("docs", "📚 Documentation"), + ("test", "✅ Tests"), + ("build", "📦 Build"), + ("ci", "👷 CI"), + ("chore", "🔧 Chores"), + ("revert", "⏪ Reverts"), +] +SECTION_TITLES = dict(SECTIONS) +OTHER_KEY = "other" + +# `type(optional scope)!: subject` — captures the type and the remaining +# subject. The optional `!` and scope are conventional-commit syntax. +COMMIT_RE = re.compile(r"^(?P[a-z]+)(?:\([^)]*\))?!?:\s*(?P.*)$") + + +def classify(subject): + """Return the `section_key` for a commit subject line. + + Only the type is parsed off, for bucketing; the subject itself is kept + verbatim (prefix included) by the caller so the rendered notes still + show the `feat:` / `fix:` convention. + """ + match = COMMIT_RE.match(subject) + if match and match.group("type") in SECTION_TITLES: + return match.group("type") + return OTHER_KEY + + +def main(): + buckets = {} + for line in sys.stdin: + line = line.rstrip("\n") + if not line: + continue + short_hash, _, subject = line.partition("\t") + key = classify(subject) + buckets.setdefault(key, []).append((short_hash, subject.strip())) + + new_tag = os.environ.get("NEW_TAG", "") + prev_tag = os.environ.get("PREV_TAG", "") + + out = [] + out.append(f"## {new_tag}") + out.append("") + out.append( + f"_Auto-drafted from commits in `{prev_tag}..{new_tag}`, grouped by " + "conventional-commit type. Edit on the release page after publish if " + "you want a polished writeup; the canonical release notes live on the " + "website._" + ) + out.append("") + + # Known types first, in declared order; then the "Other" catch-all. + for key, title in SECTIONS + [(OTHER_KEY, "🔖 Other")]: + commits = buckets.get(key) + if not commits: + continue + out.append(f"### {title}") + out.append("") + for short_hash, subject in commits: + out.append(f"- {subject} ({short_hash})") + out.append("") + + if not any(buckets.values()): + out.append("_No commits in range._") + out.append("") + + sys.stdout.write("\n".join(out).rstrip() + "\n") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48a80c8..28d0522 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,9 +5,22 @@ on: branches: [main] types: [opened, synchronize, reopened, ready_for_review] workflow_dispatch: + # Callable as a gate by release.yml, which passes the pinned release SHA as + # `ref` so the whole matrix runs against the exact commit being released + # (not the branch tip). When triggered by a PR, `ref` is empty and every + # checkout falls back to the triggering ref. + workflow_call: + inputs: + ref: + description: "Commit SHA / ref to check out. Empty = triggering ref." + required: false + type: string + default: "" concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + # Distinct group per release ref so a release-triggered run never cancels (or + # is cancelled by) a PR's CI. PR runs still coalesce per PR number. + group: ${{ github.workflow }}-${{ inputs.ref || github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: @@ -20,6 +33,8 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -48,6 +63,8 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -94,6 +111,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -135,6 +154,8 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c1d826..0466f78 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,50 +1,164 @@ name: Release +# Manual release of `strands-shell` (PyPI) + `@strands-agents/shell` (npm). +# +# Shape: scan → ci + build (wheels/sdist/node addons) → inspect → notes → +# approve → tag + release → publish. +# +# Publish is the last step. Everything before it — the full CI matrix, the +# multi-platform builds, and the artifact inspection — runs on a fork too; +# only the OIDC upload to PyPI/npm fails there, because the fork is not the +# registered trusted publisher. +# +# The reviewer approves against green checks AND a downloadable, version- +# verified artifact bundle. `dry_run` skips only approval + tag + publish; +# ci/build/inspect/notes still run so the artifact can be reviewed on the run +# page without shipping anything. +# +# This workflow is dispatch-only. It deliberately has no `push: tags` trigger: +# a pushed tag must not be able to reach publish while skipping version +# validation, CI, and the approval gate. + on: - push: - tags: - - "v*" workflow_dispatch: inputs: - ref: - description: "Tag to release (e.g. v0.1.0). Leave empty when triggered by push." + version: + description: 'Explicit version, e.g. 0.3.0 (no leading v, no prefix).' + required: true + type: string + sha: + description: 'Optional commit SHA to release (must be an ancestor of origin/main). Defaults to current origin/main.' required: false - -permissions: - contents: write - id-token: write # PyPI Trusted Publishing + default: '' + type: string + dry_run: + description: 'Skip approval + tag + publish. CI/build/inspect/notes still run so you can review the artifact on the run page.' + required: true + default: true + type: boolean + +concurrency: + group: release + cancel-in-progress: false jobs: - # ── Derive the release version from the tag (single source of truth) ───── - # Manifests carry a 0.0.0 placeholder; the tag is authoritative. This job - # parses + validates the version and exposes it; build/pack/publish jobs then - # stamp it into the manifests via .github/actions/stamp-version. - version: - name: Derive version from tag + # ── Resolve SHA and validate version (single gatekeeper) ───────────────── + # Validates the typed version, resolves a PINNED SHA (default origin/main, + # or a typed SHA that must be an ancestor of main), finds the previous tag, + # and rejects duplicate / non-monotonic / pre-existing tags. Every + # downstream job checks out this exact SHA — the release is pinned to a + # reviewed commit, not a moving branch. + scan-commits: + name: Resolve SHA and validate version runs-on: ubuntu-latest + permissions: + contents: read outputs: - version: ${{ steps.derive.outputs.version }} + release_sha: ${{ steps.scan.outputs.release_sha }} + prev_tag: ${{ steps.scan.outputs.prev_tag }} + new_tag: ${{ steps.scan.outputs.new_tag }} steps: - - name: Derive and validate version - id: derive + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + + - name: Resolve SHA, find previous tag, validate version + id: scan + env: + NEW_VERSION: ${{ inputs.version }} + SHA_INPUT: ${{ inputs.sha }} run: | set -euo pipefail - ref="${{ inputs.ref || github.ref_name }}" - # Strip refs/tags/ prefix if present, then leading "v". - tag="${ref#refs/tags/}" - tag="${tag#v}" - # Accept semver core plus optional pre-release/build (e.g. 0.2.0, - # 1.2.3-rc.1). Reject anything that isn't a release version. - if [[ ! "$tag" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]]; then - echo "::error::Tag '$ref' does not yield a valid version (got '$tag')." + + # 1. Validate the typed version (bare semver, no prefix). + if ! [[ "$NEW_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::version '$NEW_VERSION' is not bare semver (expected MAJOR.MINOR.PATCH, no 'v')." + exit 1 + fi + + # 2. Resolve the SHA. Default to origin/main; if a sha was typed, + # accept it only if it's an ancestor of origin/main. + if [ -n "$SHA_INPUT" ]; then + RELEASE_SHA=$(git rev-parse --verify "$SHA_INPUT^{commit}" 2>/dev/null) || { + echo "::error::sha '$SHA_INPUT' is not a valid commit." + exit 1 + } + if ! git merge-base --is-ancestor "$RELEASE_SHA" origin/main; then + echo "::error::sha $RELEASE_SHA is not an ancestor of origin/main — release only from main history." + exit 1 + fi + else + RELEASE_SHA=$(git rev-parse origin/main) + fi + + # 3. Resolve the previous tag. Tags are bare `v`. + TAG_PREFIX="v" + PREV_TAG=$(git tag --list "${TAG_PREFIX}*" --sort=-v:refname | head -n1) + if [ -z "$PREV_TAG" ]; then + echo "::error::No prior tag matching ${TAG_PREFIX}* — refusing to release without a baseline." + exit 1 + fi + PREV_VERSION="${PREV_TAG#"${TAG_PREFIX}"}" + NEW_TAG="${TAG_PREFIX}${NEW_VERSION}" + + # 4. Reject duplicate / non-monotonic / pre-existing tags. + if [ "$NEW_VERSION" = "$PREV_VERSION" ]; then + echo "::error::version $NEW_VERSION matches existing tag $PREV_TAG." + exit 1 + fi + HIGHER=$(printf '%s\n%s\n' "$PREV_VERSION" "$NEW_VERSION" | sort -V | tail -n1) + if [ "$HIGHER" != "$NEW_VERSION" ]; then + echo "::error::version $NEW_VERSION is not greater than previous $PREV_VERSION." + exit 1 + fi + if git rev-parse --verify "refs/tags/$NEW_TAG" >/dev/null 2>&1; then + echo "::error::tag $NEW_TAG already exists." exit 1 fi - echo "version=$tag" >> "$GITHUB_OUTPUT" - echo "Release version: $tag" + + # 5. Sanity-check: at least one commit since prev tag. + COUNT=$(git rev-list --count "$PREV_TAG..$RELEASE_SHA") + if [ "$COUNT" = "0" ]; then + echo "::error::no commits between $PREV_TAG and $RELEASE_SHA — nothing to release." + exit 1 + fi + + { + echo "release_sha=$RELEASE_SHA" + echo "prev_tag=$PREV_TAG" + echo "new_tag=$NEW_TAG" + } >> "$GITHUB_OUTPUT" + + { + echo "### Release scan" + echo "" + echo "| | |" + echo "|---|---|" + echo "| Previous tag | \`$PREV_TAG\` |" + echo "| Proposed tag | \`$NEW_TAG\` |" + echo "| Pinned SHA | \`$RELEASE_SHA\` |" + echo "| Commits since prev tag | **$COUNT** |" + } >> "$GITHUB_STEP_SUMMARY" + + # ── Full CI matrix at the pinned SHA ───────────────────────────────────── + # Runs the same Rust/Python/Node/audit matrix the merge gate runs, but + # against the exact commit being released rather than the branch tip. + ci: + name: CI + needs: scan-commits + uses: ./.github/workflows/ci.yml + permissions: + contents: read + with: + ref: ${{ needs.scan-commits.outputs.release_sha }} # ── Build native wheels for PyPI (one job per target) ──────────────────── + # Manifests carry a 0.0.0 placeholder; stamp-version writes inputs.version + # into them at build time (the version is validated by scan-commits). python-wheels: - needs: version + needs: scan-commits name: Python wheel (${{ matrix.target }}) strategy: fail-fast: false @@ -66,7 +180,7 @@ jobs: steps: - uses: actions/checkout@v6 with: - ref: ${{ inputs.ref || github.ref }} + ref: ${{ needs.scan-commits.outputs.release_sha }} - uses: actions/setup-python@v6 with: @@ -75,7 +189,7 @@ jobs: - name: Stamp version uses: ./.github/actions/stamp-version with: - version: ${{ needs.version.outputs.version }} + version: ${{ inputs.version }} - name: Build wheel uses: PyO3/maturin-action@v1 @@ -95,13 +209,13 @@ jobs: path: dist/*.whl python-sdist: - needs: version + needs: scan-commits name: Python sdist runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: - ref: ${{ inputs.ref || github.ref }} + ref: ${{ needs.scan-commits.outputs.release_sha }} - uses: actions/setup-python@v6 with: @@ -110,7 +224,7 @@ jobs: - name: Stamp version uses: ./.github/actions/stamp-version with: - version: ${{ needs.version.outputs.version }} + version: ${{ inputs.version }} - name: Build sdist uses: PyO3/maturin-action@v1 @@ -123,11 +237,17 @@ jobs: name: python-sdist path: dist/*.tar.gz - # ── Inspection gate: assemble every dist and verify before publishing ──── + # ── Inspect Python dists: verify coverage + version, re-upload bundle ──── + # Downloads every wheel + sdist, asserts each is stamped with the typed + # version, checks target coverage + metadata, then re-uploads the verified + # set as pypi-dist-bundle. python-publish pulls from the same bundle, so the + # bytes on the run page are the bytes uploaded to PyPI. python-inspect: name: Inspect Python dists - needs: [python-wheels, python-sdist] + needs: [scan-commits, python-wheels, python-sdist] runs-on: ubuntu-latest + permissions: + contents: read steps: # Only the Python dists — without the pattern, merge-multiple would also # pull the node-addon-*.node artifacts and break `twine check dist/*`. @@ -151,6 +271,44 @@ jobs: echo "::group::$f"; tar tzf "$f"; echo "::endgroup::" done + - name: Assert artifacts are stamped with the typed version + # Wheel + sdist filenames encode the version (PEP 427 / PEP 625) and + # publish cannot change them. Catch a version mismatch here, before + # anything is tagged or shipped. + env: + EXPECTED: ${{ inputs.version }} + run: | + python - <<'PYEOF' + import os + import re + import sys + from pathlib import Path + + expected = os.environ["EXPECTED"] + errors = [] + checked = 0 + for f in sorted(Path("dist").iterdir()): + name = f.name + if name.endswith(".whl"): + m = re.match(r"^[^-]+-([^-]+)-", name) + elif name.endswith(".tar.gz"): + m = re.match(r"^[^-]+-(.+)\.tar\.gz$", name) + else: + continue + checked += 1 + version = m.group(1) if m else None + if version != expected: + errors.append(f"{name}: version is {version!r}, expected {expected!r}") + if not checked: + print("::error::No wheel or sdist found in dist/", file=sys.stderr) + sys.exit(1) + if errors: + for e in errors: + print(f"::error::{e}") + sys.exit(1) + print(f"All {checked} artifact(s) stamped with version {expected}") + PYEOF + - name: Verify the dist set covers all platform targets # Maturin's --find-interpreter builds a wheel per discovered Python # version, so the exact count varies as runners update. Instead of an @@ -173,11 +331,16 @@ jobs: aarch64-unknown-linux-gnu) pattern="manylinux_*_aarch64" ;; x86_64-pc-windows-msvc) pattern="win_amd64" ;; esac + # shellcheck disable=SC2086 # $pattern is a glob; splitting is intended. if ! ls dist/*${pattern}*.whl >/dev/null 2>&1; then missing+=("$target") fi done - if [ ! -f dist/*.tar.gz ]; then + # `[ -f dist/*.tar.gz ]` would break on zero or multiple matches + # (SC2144); glob into an array and test that at least one exists. + shopt -s nullglob + sdists=(dist/*.tar.gz) + if [ ${#sdists[@]} -eq 0 ]; then missing+=("sdist") fi if [ ${#missing[@]} -gt 0 ]; then @@ -191,33 +354,17 @@ jobs: pip install twine twine check dist/* - - name: Upload combined dists for manual inspection + - name: Upload verified bundle for publish uses: actions/upload-artifact@v7 with: name: pypi-dist-bundle path: dist/* - - python-publish: - name: Publish to PyPI - needs: python-inspect - runs-on: ubuntu-latest - environment: - name: pypi - url: https://pypi.org/p/strands-shell - permissions: - id-token: write - steps: - # Publish exactly the bundle that python-inspect verified. - - uses: actions/download-artifact@v8 - with: - name: pypi-dist-bundle - path: dist - - - uses: pypa/gh-action-pypi-publish@release/v1 + if-no-files-found: error + retention-days: 30 # ── Build native node addons for npm (one job per target) ──────────────── node-builds: - needs: version + needs: scan-commits name: Node addon (${{ matrix.target }}) strategy: fail-fast: false @@ -237,7 +384,7 @@ jobs: steps: - uses: actions/checkout@v6 with: - ref: ${{ inputs.ref || github.ref }} + ref: ${{ needs.scan-commits.outputs.release_sha }} - uses: actions/setup-node@v6 with: @@ -250,7 +397,7 @@ jobs: - name: Stamp version uses: ./.github/actions/stamp-version with: - version: ${{ needs.version.outputs.version }} + version: ${{ inputs.version }} - name: Install cross-compile deps (linux aarch64) if: matrix.target == 'aarch64-unknown-linux-gnu' @@ -290,19 +437,20 @@ jobs: native.js native.d.ts - # ── Pack all 6 npm packages (fan-in from the per-platform builds) ───────── + # ── Pack + inspect all 6 npm packages (fan-in from per-platform builds) ── # Assembles the exact publishable set — the main @strands-agents/shell package - # plus the 5 per-platform packages — using the napi-rs v3 release flow, then - # packs each to a .tgz and uploads them. Download the `npm-packages` artifact - # to install/test the real tarballs locally before any publish happens. + # plus the 5 per-platform packages — using the napi-rs v3 release flow, packs + # each to a .tgz, verifies the packed set, then uploads npm-packages. The + # publish jobs pull from this same bundle, so the tarballs on the run page are + # the tarballs shipped to npm. node-pack: name: Pack npm packages - needs: [version, node-builds] + needs: [scan-commits, node-builds] runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: - ref: ${{ inputs.ref || github.ref }} + ref: ${{ needs.scan-commits.outputs.release_sha }} persist-credentials: false - uses: actions/setup-node@v6 @@ -312,7 +460,7 @@ jobs: - name: Stamp version uses: ./.github/actions/stamp-version with: - version: ${{ needs.version.outputs.version }} + version: ${{ inputs.version }} - name: npm install run: npm install @@ -337,7 +485,7 @@ jobs: # (--output-dir is the INPUT location of the .node files; --npm-dir the OUTPUT) # - pre-publish --skip-optional-publish wires the main package's # optionalDependencies to the platform packages WITHOUT publishing, - # so the packed set matches exactly what node-publish would ship. + # so the packed set matches exactly what the publish jobs would ship. run: | npx napi create-npm-dirs npx napi artifacts --output-dir ./artifacts --npm-dir ./npm @@ -359,6 +507,7 @@ jobs: run: | set -euo pipefail expected=6 + # shellcheck disable=SC2012 # controlled .tgz names; ls|wc is fine here. actual=$(ls dist-npm/*.tgz | wc -l | tr -d ' ') echo "Packed tarballs ($actual):"; ls -1 dist-npm/*.tgz if [ "$actual" -ne "$expected" ]; then @@ -366,6 +515,27 @@ jobs: exit 1 fi + - name: Assert every tarball is stamped with the typed version + # package.json's version is the source of the published version and, + # unlike wheels, isn't encoded in the tarball name — read it out of each + # packed tarball and confirm it matches the typed release version. + env: + EXPECTED: ${{ inputs.version }} + run: | + set -euo pipefail + errors=0 + for tgz in dist-npm/*.tgz; do + actual=$(tar -xOzf "$tgz" package/package.json | jq -r .version) + if [ "$actual" != "$EXPECTED" ]; then + echo "::error::$tgz: version is '$actual', expected '$EXPECTED'." + errors=$((errors + 1)) + fi + done + if [ "$errors" -gt 0 ]; then + exit 1 + fi + echo "All tarballs stamped with version $EXPECTED" + - name: List every tarball's contents run: | set -euo pipefail @@ -373,22 +543,198 @@ jobs: echo "::group::$tgz"; tar tzf "$tgz"; echo "::endgroup::" done - - name: Upload all npm packages for manual inspection + - name: Upload all npm packages for inspect/publish uses: actions/upload-artifact@v7 with: name: npm-packages path: dist-npm/*.tgz + if-no-files-found: error + retention-days: 30 + + # ── Draft notes grouped by commit type ─────────────────────────────────── + # Gated on CI + both build/inspect legs succeeding, so notes (and the + # approval that follows) only render once the release is known-buildable and + # the artifact is verified. + draft-notes: + name: Draft release notes (grouped by commit type) + needs: + - scan-commits + - ci + - python-inspect + - node-pack + if: | + always() && + needs.scan-commits.result == 'success' && + needs.ci.result == 'success' && + needs.python-inspect.result == 'success' && + needs.node-pack.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ needs.scan-commits.outputs.release_sha }} + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Render notes + env: + PREV_TAG: ${{ needs.scan-commits.outputs.prev_tag }} + NEW_REF: ${{ needs.scan-commits.outputs.release_sha }} + NEW_TAG: ${{ needs.scan-commits.outputs.new_tag }} + run: | + set -euo pipefail + # Feed commits to the grouper as `hashsubject` lines and let + # it bucket them by conventional-commit type (feat, fix, ...). + git log --no-merges --pretty=format:'%h%x09%s' "$PREV_TAG..$NEW_REF" \ + | NEW_TAG="$NEW_TAG" PREV_TAG="$PREV_TAG" \ + python .github/scripts/group-release-notes.py > release-notes.md + + - name: Render release summary + env: + NEW_TAG: ${{ needs.scan-commits.outputs.new_tag }} + RELEASE_SHA: ${{ needs.scan-commits.outputs.release_sha }} + DRY_RUN: ${{ inputs.dry_run }} + IS_FORK: ${{ github.event.repository.fork }} + run: | + set -euo pipefail + { + echo "## Release proposal" + echo "" + echo "| | |" + echo "|---|---|" + echo "| Package | \`strands-shell\` (PyPI) + \`@strands-agents/shell\` (npm) |" + echo "| Proposed tag | \`$NEW_TAG\` |" + echo "| Pinned SHA | \`$RELEASE_SHA\` |" + echo "| Dry run | \`$DRY_RUN\` |" + echo "| Running on fork | \`$IS_FORK\` |" + echo "" + echo "> Reviewers: the verified artifacts are uploaded as \`pypi-dist-bundle\` and \`npm-packages\` on this run's page. Download and install in a fresh venv / tmpdir to sanity-check before approving." + echo "" + echo "### Drafted notes" + echo "" + cat release-notes.md + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload release notes artifact + uses: actions/upload-artifact@v7 + with: + name: release-notes + path: release-notes.md + retention-days: 30 + + # ── Reviewer approval (skipped on dry runs) ────────────────────────────── + approve-release: + name: Wait for reviewer approvals + needs: [scan-commits, draft-notes] + if: inputs.dry_run != true + runs-on: ubuntu-latest + environment: + # Configure reviewers + main-only deployment branches in repo settings. + # On forks the environment has no reviewer config, so this just passes — + # the publish steps are the ones that fail there. + name: release-gate + permissions: {} + steps: + - name: Acknowledge approval + env: + NEW_TAG: ${{ needs.scan-commits.outputs.new_tag }} + RELEASE_SHA: ${{ needs.scan-commits.outputs.release_sha }} + run: | + echo "Approved to release $NEW_TAG at $RELEASE_SHA." + + # ── Tag + GitHub release ────────────────────────────────────────────────── + # Runs before publish so the tag is the source of truth. If publish fails, + # the tag stays — just re-run the publish jobs. Tags the PINNED SHA (the + # reviewed commit), not the branch tip. + create-gh-release: + name: Create GitHub release + needs: [scan-commits, draft-notes, approve-release] + if: inputs.dry_run != true + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ needs.scan-commits.outputs.release_sha }} + # This job creates the tag, so keep the GITHUB_TOKEN in git config + # for the push below. Other jobs use persist-credentials: false. + persist-credentials: true + + - name: Download release notes artifact + uses: actions/download-artifact@v8 + with: + name: release-notes + + - name: Tag the pinned SHA and create the release + env: + GH_TOKEN: ${{ github.token }} + NEW_TAG: ${{ needs.scan-commits.outputs.new_tag }} + RELEASE_SHA: ${{ needs.scan-commits.outputs.release_sha }} + run: | + set -euo pipefail + # Push the tag over git rather than letting `gh release create + # --target ` create it through the releases API. The API path + # returns "403 Resource not accessible by integration" for the + # GITHUB_TOKEN (see cli/cli#9514); a plain ref push with the same + # token succeeds. + # + # Both steps are guarded so re-running the job after a partial + # failure is safe: if a prior run pushed the tag but errored before + # the release was made, git push would otherwise refuse the existing + # tag. This keeps the "tag is source of truth, just re-run" contract. + if ! git ls-remote --exit-code --tags origin "refs/tags/$NEW_TAG" >/dev/null 2>&1; then + git tag "$NEW_TAG" "$RELEASE_SHA" + git push origin "refs/tags/$NEW_TAG" + fi + + # Tag now exists on the remote, so this only attaches notes to it. + if ! gh release view "$NEW_TAG" >/dev/null 2>&1; then + gh release create "$NEW_TAG" \ + --title "$NEW_TAG" \ + --notes-file release-notes.md + fi + + # ── Publish to PyPI ─────────────────────────────────────────────────────── + # Publishes exactly the bundle python-inspect verified. On a fork this fails + # at the OIDC step — by design. + python-publish: + name: Publish to PyPI + needs: [python-inspect, approve-release, create-gh-release] + if: inputs.dry_run != true + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/strands-shell + permissions: + id-token: write + contents: read + steps: + - uses: actions/download-artifact@v8 + with: + name: pypi-dist-bundle + path: dist + + - uses: pypa/gh-action-pypi-publish@release/v1 - # ── Publish the 5 per-platform packages (one independently retriable job each) - # Each leg publishes exactly one tarball that node-inspect packed + uploaded, - # so a flaky single platform can be re-run on its own via "Re-run failed jobs" + # ── Publish the 5 per-platform npm packages (one retriable job each) ────── + # Each leg publishes exactly one tarball that node-pack packed + uploaded, so + # a flaky single platform can be re-run on its own via "Re-run failed jobs" # without touching the others. The matrix is static — its entries must match - # package.json's napi.targets; node-inspect's count check fails the run before + # package.json's napi.targets; node-pack's count check fails the run before # this stage if they drift. fail-fast: false so one platform failing doesn't # cancel the rest. node-publish-platform: name: Publish ${{ matrix.pkg }} - needs: [version, node-pack] + needs: [node-pack, approve-release, create-gh-release] + if: inputs.dry_run != true runs-on: ubuntu-latest # OIDC trusted publishing (no NPM_TOKEN) — matches the strands-agents golden # path (see harness-sdk). Requires id-token: write and a registered trusted @@ -408,9 +754,9 @@ jobs: - strands-agents-shell-linux-arm64-gnu - strands-agents-shell-win32-x64-msvc env: - V: ${{ needs.version.outputs.version }} + V: ${{ inputs.version }} steps: - # No checkout/build — publish the exact tarball node-inspect uploaded. + # No checkout/build — publish the exact tarball node-pack uploaded. - uses: actions/setup-node@v6 with: node-version: "20" @@ -430,12 +776,13 @@ jobs: # ./ prefix: npm treats a bare "dir/file.tgz" as a git spec. run: npm publish "./dist-npm/${{ matrix.pkg }}-${V}.tgz" --access public - # ── Publish the main package last ──────────────────────────────────────── + # ── Publish the main package last ───────────────────────────────────────── # Gated on all platform packages succeeding, since the main package's # optionalDependencies reference them. node-publish-main: name: Publish @strands-agents/shell - needs: [version, node-publish-platform] + needs: [node-publish-platform] + if: inputs.dry_run != true runs-on: ubuntu-latest environment: name: npm @@ -444,7 +791,7 @@ jobs: id-token: write contents: read env: - V: ${{ needs.version.outputs.version }} + V: ${{ inputs.version }} steps: - uses: actions/setup-node@v6 with: