diff --git a/.github/workflows/README.md b/.github/workflows/README.md index ab4b6550c..5290f8dac 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -5,8 +5,8 @@ Workflows are organized into two roles: ``` -Orchestrators (e.g. ci.yml, release.yml) -Jobs (e.g. build.yml, unit-test.yml, publish-npm.yml) +Orchestrators (ci.yml, release-prepare.yml, release-publish.yml) +Jobs (verify.yml) ``` **Orchestrators** respond to events and coordinate work. They define _when_ and @@ -19,32 +19,36 @@ care what triggered it. An orchestrator calls jobs via `workflow_call`. -### Current example +### Current examples ``` ci.yml - |-- check.yml (lint, format, typecheck, audit, secret scan) - |-- build.yml (bundle, package, compile, smoke test) - `-- unit-test.yml (tests on Linux, Windows, macOS) -``` + `-- verify.yml (one job per platform: static checks on Linux, then bundle, + package, compile, smoke test and unit tests) -### Future examples +release-prepare.yml (version bump, vended CDK pin, release PR) -``` -release.yml - ├── unit-test.yml - ├── build.yml - └── publish-npm.yml +release-publish.yml (npm publish and GitHub release when a release PR merges) + `-- verify.yml ``` -Jobs like `unit-test.yml` and `build.yml` appear in multiple orchestrators. This -is the point — write once, compose freely. +`verify.yml` appears in both orchestrators. This is the point — write once, +compose freely. ## Naming Convention -- **Orchestrators** are named for their purpose (e.g. `ci`, `release`). +- **Orchestrators** are named for their purpose (e.g. `ci`, `release-publish`). - **Jobs** are named as verbs or noun-verb pairs describing the work - (e.g. `build`, `unit-test`, `publish-npm`). + (e.g. `verify`). + +## Releasing + +Dispatch `release-prepare` with a bump and a channel, review the release PR it opens, merge it. +`release-publish` then publishes the merged package.json version. + +If publish fails after the merge, rerun the failed `release-publish` jobs. Both the npm publish +and the GitHub release steps skip work that already succeeded. Do not re-dispatch +`release-prepare`, package.json already holds the new version and it would bump again. ## Key Choices diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 9194929f1..000000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,51 +0,0 @@ -# Builds the CLI and compiles it into executables for each platform for a smoke test. -name: build -on: - workflow_call: - inputs: - ref: - required: true - type: string - -env: - AGENTCORE_TELEMETRY_DISABLED: "1" - -jobs: - build: - name: Build (${{ matrix.name }}) - runs-on: ${{ fromJSON(matrix.runner) }} - permissions: - contents: read - strategy: - fail-fast: false - matrix: - include: - - name: Linux - runner: '["codebuild-agentcore-e2e-${{ github.run_id }}-${{ github.run_attempt }}"]' - target: linux-x64 - binary: ./dist/bin/agentcore-linux-x64 - - name: Windows - runner: '["codebuild-agentcore-e2e-${{ github.run_id }}-${{ github.run_attempt }}", "image:windows-1.0"]' - target: windows-x64 - binary: ./dist/bin/agentcore-windows-x64.exe - # CodeBuild does not support macOS. - - name: macOS - runner: '["macos-latest"]' - target: darwin-arm64 - binary: ./dist/bin/agentcore-darwin-arm64 - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ inputs.ref }} - persist-credentials: false - - uses: oven-sh/setup-bun@v2 - - run: bun install --frozen-lockfile - - - run: bun run build - - - run: bun pm pack - - - run: bun run compile:${{ matrix.target }} - - - name: Smoke test binary - run: ${{ matrix.binary }} --help diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml deleted file mode 100644 index 2609545ca..000000000 --- a/.github/workflows/check.yml +++ /dev/null @@ -1,31 +0,0 @@ -# Verifies lint, formatting, types, audit, and other static checks pass. -name: check -on: - workflow_call: - inputs: - ref: - required: true - type: string - -jobs: - check: - runs-on: codebuild-agentcore-e2e-${{ github.run_id }}-${{ github.run_attempt }} - permissions: - contents: read - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ inputs.ref }} - persist-credentials: false - - uses: oven-sh/setup-bun@v2 - - run: bun install --frozen-lockfile - - run: bun run lint:check - if: always() - - run: bun run format:check - if: always() - - run: bun run typecheck - if: always() - - run: bun audit - if: always() - - run: bun run secrets:check - if: always() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7363f1cfc..4660d09b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,20 +13,8 @@ concurrency: cancel-in-progress: true jobs: - check: - uses: ./.github/workflows/check.yml - permissions: - contents: read - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - build: - uses: ./.github/workflows/build.yml - permissions: - contents: read - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - unit-test: - uses: ./.github/workflows/unit-test.yml + verify: + uses: ./.github/workflows/verify.yml permissions: contents: read with: diff --git a/.github/workflows/pr-automation.yml b/.github/workflows/pr-automation.yml index 7950561e2..ee57bd362 100644 --- a/.github/workflows/pr-automation.yml +++ b/.github/workflows/pr-automation.yml @@ -171,7 +171,7 @@ jobs: id: tarball run: | bun pm pack --destination "$RUNNER_TEMP" - tarball="$(find "$RUNNER_TEMP" -maxdepth 1 -type f -name 'agentcore-*.tgz' -print -quit)" + tarball="$(find "$RUNNER_TEMP" -maxdepth 1 -type f -name 'aws-agentcore-*.tgz' -print -quit)" test -f "$tarball" echo "name=$(basename "$tarball")" >> "$GITHUB_OUTPUT" echo "path=$tarball" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml new file mode 100644 index 000000000..76a28f7fe --- /dev/null +++ b/.github/workflows/release-prepare.yml @@ -0,0 +1,76 @@ +# Opens the release PR: bumps package.json, refreshes the vended @aws/agentcore-cdk pin, and pushes +# release/v. Merging that PR is the release approval and triggers release-publish.yml. +name: release-prepare +on: + workflow_dispatch: + inputs: + bump: + description: Semver component to release. Ignored while an rc series is open, which continues or graduates instead. + required: true + type: choice + options: [major, minor, patch] + channel: + description: rc publishes under the rc dist-tag, stable under latest + required: true + type: choice + options: [rc, stable] + +jobs: + prepare: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + with: + fetch-tags: true + persist-credentials: false + - uses: oven-sh/setup-bun@v2 + - uses: actions/create-github-app-token@v3 + id: app-token + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Bump version + id: version + env: + BUMP: ${{ inputs.bump }} + CHANNEL: ${{ inputs.channel }} + run: | + set -euo pipefail + CURRENT=$(bun pm pkg get version | tr -d '"') + # 0.28.1 -> 1.0.0-rc.1 -> 1.0.0-rc.2 -> 1.0.0. Series boundaries set the version explicitly: + # Bun's prerelease counter starts at 0, and its "major" on an rc would jump to 2.0.0. + if [[ "$CURRENT" == *-rc.* ]]; then + NEXT=$([ "$CHANNEL" = rc ] && echo prerelease || echo "${CURRENT%%-*}") + else + NEXT=$(bun pm version "$BUMP" --no-git-tag-version) + NEXT=${NEXT#v} + [ "$CHANNEL" = rc ] && NEXT="$NEXT-rc.1" + fi + TAG=$(bun pm version "$NEXT" --preid rc --no-git-tag-version --allow-same-version) + if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + echo "::error::$TAG is already released" + exit 1 + fi + echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + + - run: bun scripts/sync-vended-cdk.ts + + - name: Open release PR + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + BRANCH="release/v$VERSION" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git commit -am "chore(release): v$VERSION" + git push --force "https://x-access-token:$GH_TOKEN@github.com/$GITHUB_REPOSITORY.git" "HEAD:refs/heads/$BRANCH" + + if [ -z "$(gh pr list --base "$GITHUB_REF_NAME" --head "$BRANCH" --json number --jq '.[0].number')" ]; then + gh pr create --base "$GITHUB_REF_NAME" --head "$BRANCH" --title "chore(release): v$VERSION" \ + --body "Merging publishes \`@aws/agentcore@$VERSION\` to npm and creates the \`v$VERSION\` GitHub release with generated notes." + fi diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml new file mode 100644 index 000000000..a7ed01d05 --- /dev/null +++ b/.github/workflows/release-publish.yml @@ -0,0 +1,94 @@ +# Publishes @aws/agentcore to npm and creates the GitHub release when a release PR from +# release-prepare.yml merges. The version is whatever the merged package.json says. +name: release-publish +on: + pull_request: + types: [closed] + # TODO: switch to main once the refactor lands there. + branches: [refactor] + +jobs: + verify: + if: github.event.pull_request.merged && startsWith(github.head_ref, 'release/v') + uses: ./.github/workflows/verify.yml + permissions: + contents: read + with: + ref: ${{ github.event.pull_request.merge_commit_sha }} + + publish: + needs: verify + # npm provenance is only issued from GitHub-hosted runners. + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + env: + REF: ${{ github.event.pull_request.merge_commit_sha }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ env.REF }} + fetch-tags: true + persist-credentials: false + - uses: oven-sh/setup-bun@v2 + - uses: actions/setup-node@v6 + with: + node-version: 20.x + registry-url: https://registry.npmjs.org + - run: bun install --frozen-lockfile + + - name: Resolve release + id: release + run: | + set -euo pipefail + VERSION=$(bun pm pkg get version | tr -d '"') + # rc notes span from the previous rc, stable notes from the previous stable. Only this + # workflow's tags count, main's v1.0.0-preview.N tags live in the same repo. + DIST_TAG=latest + SEMVER_FLAGS=() + if [[ "$VERSION" == *-rc.* ]]; then + DIST_TAG=rc + SEMVER_FLAGS=(--include-prerelease) + fi + PREVIOUS=$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$' \ + | xargs bunx semver "${SEMVER_FLAGS[@]}" --range "<$VERSION" | tail -1) + { + echo "version=$VERSION" + echo "dist_tag=$DIST_TAG" + echo "previous_tag=v$PREVIOUS" + } >> "$GITHUB_OUTPUT" + + - run: bun run build + - id: pack + run: echo "tarball=$(bun pm pack --quiet --ignore-scripts)" >> "$GITHUB_OUTPUT" + - run: bun run compile + + - env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + VERSION: ${{ steps.release.outputs.version }} + DIST_TAG: ${{ steps.release.outputs.dist_tag }} + TARBALL: ${{ steps.pack.outputs.tarball }} + # A rerun after a failed release step must not republish, npm rejects an existing version. + run: | + if npm view "@aws/agentcore@$VERSION" version >/dev/null 2>&1; then + echo "@aws/agentcore@$VERSION is already on npm, skipping publish" + else + npm publish "$TARBALL" --provenance --access public --tag "$DIST_TAG" + fi + + - env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.release.outputs.version }} + PREVIOUS_TAG: ${{ steps.release.outputs.previous_tag }} + TARBALL: ${{ steps.pack.outputs.tarball }} + # A rerun after a failed asset upload re-uploads into the existing release instead of failing on the tag. + run: | + if gh release view "v$VERSION" >/dev/null 2>&1; then + gh release upload "v$VERSION" --clobber "$TARBALL" dist/bin/* + else + gh release create "v$VERSION" --target "$REF" --title "v$VERSION" \ + --generate-notes --notes-start-tag "$PREVIOUS_TAG" \ + ${{ steps.release.outputs.dist_tag == 'rc' && '--prerelease' || '--latest' }} \ + "$TARBALL" dist/bin/* + fi diff --git a/.github/workflows/unit-test.yml b/.github/workflows/verify.yml similarity index 56% rename from .github/workflows/unit-test.yml rename to .github/workflows/verify.yml index 40e81b6df..7f100e567 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/verify.yml @@ -1,5 +1,6 @@ -# Verifies unit tests pass on all platforms (Linux, Windows, macOS). -name: unit-test +# Verifies a commit on every platform in one job each: static checks (Linux only, they are +# platform-independent), bundle, package, compile, smoke test, unit tests. +name: verify on: workflow_call: inputs: @@ -14,8 +15,8 @@ env: AGENTCORE_TELEMETRY_DISABLED: "1" jobs: - test: - name: Test (${{ matrix.name }}) + verify: + name: ${{ matrix.name }} runs-on: ${{ fromJSON(matrix.runner) }} permissions: contents: read @@ -25,12 +26,18 @@ jobs: include: - name: Linux runner: '["codebuild-agentcore-e2e-${{ github.run_id }}-${{ github.run_attempt }}"]' + target: linux-x64 + binary: ./dist/bin/agentcore-linux-x64 - name: Windows runner: '["codebuild-agentcore-e2e-${{ github.run_id }}-${{ github.run_attempt }}", "image:windows-1.0"]' + target: windows-x64 + binary: ./dist/bin/agentcore-windows-x64.exe # CodeBuild does not support macOS. # https://docs.aws.amazon.com/codebuild/latest/userguide/action-runner-questions.html#action-runner-platform - name: macOS runner: '["macos-latest"]' + target: darwin-arm64 + binary: ./dist/bin/agentcore-darwin-arm64 steps: - uses: actions/checkout@v7 with: @@ -38,6 +45,25 @@ jobs: persist-credentials: false - uses: oven-sh/setup-bun@v2 - run: bun install --frozen-lockfile + + # Every static check reports even when an earlier one fails. Build and tests only run when all pass. + - run: bun run lint:check + if: runner.os == 'Linux' && !cancelled() + - run: bun run format:check + if: runner.os == 'Linux' && !cancelled() + - run: bun run typecheck + if: runner.os == 'Linux' && !cancelled() + - run: bun audit + if: runner.os == 'Linux' && !cancelled() + - run: bun run secrets:check + if: runner.os == 'Linux' && !cancelled() + + - run: bun run build + - run: bun pm pack --ignore-scripts + - run: bun run compile:${{ matrix.target }} + - name: Smoke test binary + run: ${{ matrix.binary }} --help + # TODO: investigate sharding as repo grows: https://bun.com/blog/release-notes/bun-v1.3.13#bun-test-shard-m-n-for-splitting-tests-across-ci-jobs - run: bun test --coverage --coverage-reporter=lcov diff --git a/README.md b/README.md index 41a92ba40..f240ff650 100644 --- a/README.md +++ b/README.md @@ -905,14 +905,14 @@ npm link Re-run `bun run build` after changes; the linked command picks it up. Remove with: ```bash -npm unlink -g agentcore +npm unlink -g @aws/agentcore ``` To test the exact published artifact instead: ```bash npm pack # builds via prepublishOnly, creates the .tgz -npm i -g ./agentcore-1.0.0.tgz +npm i -g ./aws-agentcore-0.28.1.tgz ``` # Build diff --git a/package.json b/package.json index 3a6fac2f4..bd51c5281 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,13 @@ { - "name": "agentcore", - "version": "1.0.0", + "name": "@aws/agentcore", + "version": "0.28.1", + "repository": { + "type": "git", + "url": "https://github.com/aws/agentcore-cli.git" + }, "module": "src/index.ts", "type": "module", + "packageManager": "bun@1.4.0", "bin": { "agentcore": "./dist/index.js" }, diff --git a/scripts/sync-vended-cdk.ts b/scripts/sync-vended-cdk.ts new file mode 100644 index 000000000..5be86b0d5 --- /dev/null +++ b/scripts/sync-vended-cdk.ts @@ -0,0 +1,27 @@ +#!/usr/bin/env bun + +/** + Pins the vended CDK template to the published @aws/agentcore-cdk at the given dist-tag (default latest). + Runs before bun install in release-prepare.yml, so it must not import anything from src/. +**/ + +import { $ } from "bun"; +import { join, resolve } from "node:path"; + +const CDK_PACKAGE = "@aws/agentcore-cdk"; +const TEMPLATE = Bun.file( + join(resolve(import.meta.dir, ".."), "src", "assets", "cdk", "package.json"), +); + +const tag = Bun.argv[2] ?? "latest"; +const target = (await $`bun pm view ${CDK_PACKAGE}@${tag} version`.text()).trim(); +const template = await TEMPLATE.json(); +const current = template.dependencies[CDK_PACKAGE]; + +if (current === target) { + console.log(`${CDK_PACKAGE} already pinned to ${target}`); +} else { + template.dependencies[CDK_PACKAGE] = target; + await Bun.write(TEMPLATE, JSON.stringify(template, null, 2) + "\n"); + console.log(`${CDK_PACKAGE}: ${current} → ${target}`); +} diff --git a/src/handlers/update/index.tsx b/src/handlers/update/index.tsx index d4b296003..f19a8667c 100644 --- a/src/handlers/update/index.tsx +++ b/src/handlers/update/index.tsx @@ -9,8 +9,8 @@ import { PACKAGE_VERSION } from "../../constants"; const PACKAGE_NAME = "@aws/agentcore"; const REGISTRY_URL = "https://registry.npmjs.org"; -function distTag(): string { - return PACKAGE_VERSION.includes("-") ? "preview" : "latest"; +export function distTag(version: string = PACKAGE_VERSION): string { + return semver.prerelease(version)?.[0]?.toString() ?? "latest"; } export function installArgv(): string[] { @@ -20,7 +20,7 @@ export function installArgv(): string[] { export async function fetchLatestVersion(): Promise { let response: Response; try { - response = await fetch(`${REGISTRY_URL}/${PACKAGE_NAME}/latest`); + response = await fetch(`${REGISTRY_URL}/${PACKAGE_NAME}/${distTag()}`); } catch (cause) { throw new NetworkingError( `Could not reach the npm registry: ${cause instanceof Error ? cause.message : String(cause)}`, diff --git a/src/handlers/update/update.test.ts b/src/handlers/update/update.test.ts index f5126e75a..5be838a57 100644 --- a/src/handlers/update/update.test.ts +++ b/src/handlers/update/update.test.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; -import { fetchLatestVersion, handleUpdate } from "./index"; +import { distTag, fetchLatestVersion, handleUpdate } from "./index"; import { NetworkingError } from "../../errors"; import type { ProcessRunner } from "../../io"; +import { PACKAGE_VERSION } from "../../constants"; // No golden/fixture tests here: the repo's *.fixture.test.tsx harness records and // replays AWS SDK responses through CoreClient, but `update` makes no AWS calls — @@ -9,6 +10,14 @@ import type { ProcessRunner } from "../../io"; // (runProcess). There is nothing for that harness to record, so a fetch spy plus // an injected fake runner is the right, hermetic way to cover this command. +test.each([ + ["0.28.1", "latest"], + ["1.0.0-rc.1", "rc"], + ["1.0.0-preview.29", "preview"], +])("distTag(%s) tracks the %s dist-tag", (version, tag) => { + expect(distTag(version)).toBe(tag); +}); + describe("fetchLatestVersion", () => { afterEach(() => { spyOn(globalThis, "fetch").mockRestore(); @@ -50,12 +59,12 @@ describe("handleUpdate", () => { }); test("up-to-date when versions match, without invoking the runner", async () => { - mockLatest("1.0.0"); + mockLatest(PACKAGE_VERSION); const runner: ProcessRunner = mock(async () => {}); expect(await handleUpdate(false, { runner })).toEqual({ status: "up-to-date", - currentVersion: "1.0.0", - latestVersion: "1.0.0", + currentVersion: PACKAGE_VERSION, + latestVersion: PACKAGE_VERSION, }); expect(runner).not.toHaveBeenCalled(); }); @@ -70,7 +79,7 @@ describe("handleUpdate", () => { const runner: ProcessRunner = mock(async () => {}); expect(await handleUpdate(true, { runner })).toEqual({ status: "update-available", - currentVersion: "1.0.0", + currentVersion: PACKAGE_VERSION, latestVersion: "2.0.0", }); expect(runner).not.toHaveBeenCalled(); diff --git a/src/telemetry/client.test.tsx b/src/telemetry/client.test.tsx index a2fca90bf..c7c2c559d 100644 --- a/src/telemetry/client.test.tsx +++ b/src/telemetry/client.test.tsx @@ -10,6 +10,7 @@ import type { MetricSink } from "./types"; import { FileSystemSink } from "./fileSystemSink"; import { DEFAULT_GLOBAL_CONFIG } from "../globalConfig"; import { PACKAGE_VERSION } from "../constants"; +import { resourceAttributesSchema } from "./shapes"; describe("DefaultTelemetryClient", () => { let tempDir: string; @@ -433,3 +434,27 @@ describe("OtelHistogramSink", () => { await rm(auditFilePath, { force: true }); }); }); + +describe("resourceAttributesSchema", () => { + const attributes = (version: string) => ({ + "service.name": "agentcore-cli", + "service.version": version, + "agentcore-cli.installation_id": "00000000-0000-0000-0000-000000000000", + "agentcore-cli.session_id": "00000000-0000-0000-0000-000000000000", + "os.type": "Darwin", + "os.version": "25.6.0", + "host.arch": "arm64", + "node.version": "v22.0.0", + }); + + test.each(["0.28.1", "1.0.0-rc.0", "1.0.0-preview.29"])( + "accepts service.version %s", + (version) => { + expect(resourceAttributesSchema.safeParse(attributes(version)).success).toBe(true); + }, + ); + + test.each(["v1.0.0", "1.0", "1.0.0-"])("rejects service.version %s", (version) => { + expect(resourceAttributesSchema.safeParse(attributes(version)).success).toBe(false); + }); +}); diff --git a/src/telemetry/shapes.tsx b/src/telemetry/shapes.tsx index 74f8c71e9..825161d81 100644 --- a/src/telemetry/shapes.tsx +++ b/src/telemetry/shapes.tsx @@ -2,7 +2,7 @@ import z from "zod"; import { ERROR_SOURCE } from "../errors"; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -const SEMVER_PATTERN = /^\d+\.\d+\.\d+$/; +const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/; const NODE_VERSION_PATTERN = /^v\d+\.\d+\.\d+$/; const MAX_ATTR_LENGTH = 64; @@ -12,7 +12,7 @@ const MAX_ATTR_LENGTH = 64; */ export const resourceAttributesSchema = z.object({ "service.name": z.literal("agentcore-cli"), - "service.version": z.string().regex(SEMVER_PATTERN), + "service.version": z.string().max(MAX_ATTR_LENGTH).regex(SEMVER_PATTERN), "agentcore-cli.installation_id": z.string().regex(UUID_PATTERN), "agentcore-cli.session_id": z.string().regex(UUID_PATTERN), "os.type": z.string().min(1).max(MAX_ATTR_LENGTH),