From 4777554c76260e1ed8cbe4069a4127849b5dce2e Mon Sep 17 00:00:00 2001 From: Dmitry Gimzelberg Date: Tue, 8 Sep 2026 18:00:54 -0400 Subject: [PATCH 1/2] feat(ci): automate TypeScript SDK release --- .github/CODEOWNERS | 7 +- .github/workflows/publish-typescript-sdk.yml | 247 ++++++++++++++++++- 2 files changed, 247 insertions(+), 7 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9881cd6..90c06fc 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,10 +1,15 @@ # Release and security-sensitive repository controls. /.github/CODEOWNERS @fuller @ximt /.github/workflows/** @fuller @ximt +/.github/workflows/publish-typescript-sdk.yml @gemini/principal-engineers /SECURITY.md @fuller @ximt -# Published TypeScript SDK source, packaging, and release metadata. +# Published TypeScript SDK source. /packages/sdk-typescript/** @fuller @ximt +# Published TypeScript SDK release metadata. +/packages/sdk-typescript/package.json @gemini/principal-engineers +/packages/sdk-typescript/package-lock.json @gemini/principal-engineers + # Published Go SDK source, module metadata, and release workflow. /packages/sdk-go/** @fuller @ximt diff --git a/.github/workflows/publish-typescript-sdk.yml b/.github/workflows/publish-typescript-sdk.yml index 16bcb3f..e3a4ae0 100644 --- a/.github/workflows/publish-typescript-sdk.yml +++ b/.github/workflows/publish-typescript-sdk.yml @@ -1,21 +1,222 @@ -name: Publish TypeScript SDK +name: Tag and Publish TypeScript SDK on: + # Run from the trusted base-branch workflow after merge; never execute PR + # code while handling the release credentials. + pull_request_target: + types: + - closed + branches: + - main + paths: + - packages/sdk-typescript/package.json + # Keep publishing on a tag ref so environment branch policies and npm + # provenance both describe the immutable release marker. push: tags: - "typescript-sdk-v*" + # GITHUB_TOKEN-created tags do not start a push workflow, so the tag job + # dispatches this workflow again with the release tag as its ref. + workflow_dispatch: permissions: contents: read + pull-requests: read concurrency: - group: publish-typescript-sdk + group: >- + ${{ github.event_name == 'pull_request_target' + && 'release-typescript-sdk-tag' + || format('release-typescript-sdk-publish-{0}', github.ref_name) }} cancel-in-progress: false jobs: + tag: + if: github.event_name == 'pull_request_target' && github.event.pull_request.merged == true + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + actions: write + pull-requests: read + steps: + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "22.14.0" + + - name: Read merged package metadata + id: release + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + run: | + set -euo pipefail + + # A package.json change is not enough to release; the version line + # must change, and all metadata comes from the exact merge commit. + test "$REPOSITORY" = "gemini/developer-platform" + [[ "$PR_NUMBER" =~ ^[0-9]+$ ]] + [[ "$MERGE_SHA" =~ ^[0-9a-f]{40}$ ]] + + changed_files="$RUNNER_TEMP/changed-files.json" + gh api \ + --paginate \ + --slurp \ + "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100" \ + > "$changed_files" + + jq -e ' + flatten | + any(.[]; + .filename == "packages/sdk-typescript/package.json" and + ((.patch // "") | test("(?m)^-\\s*\"version\"\\s*:")) and + ((.patch // "") | test("(?m)^\\+\\s*\"version\"\\s*:")) + ) + ' "$changed_files" > /dev/null + + package_json="$RUNNER_TEMP/package.json" + package_lock="$RUNNER_TEMP/package-lock.json" + gh api \ + "repos/${REPOSITORY}/contents/packages/sdk-typescript/package.json?ref=${MERGE_SHA}" \ + --jq '.content' | tr -d '\n' | base64 --decode > "$package_json" + gh api \ + "repos/${REPOSITORY}/contents/packages/sdk-typescript/package-lock.json?ref=${MERGE_SHA}" \ + --jq '.content' | tr -d '\n' | base64 --decode > "$package_lock" + + version="$(node - "$package_json" "$package_lock" <<'NODE' + const fs = require("node:fs"); + + const [packagePath, lockPath] = process.argv.slice(2); + const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8")); + const packageLock = JSON.parse(fs.readFileSync(lockPath, "utf8")); + const versionPattern = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; + + if (packageJson.name !== "@gemini-markets/sdk") { + throw new Error(`unexpected package name: ${packageJson.name}`); + } + if (typeof packageJson.version !== "string" || !versionPattern.test(packageJson.version)) { + throw new Error(`invalid stable package version: ${packageJson.version}`); + } + if (packageLock.name !== packageJson.name || packageLock.packages?.[""]?.version !== packageJson.version) { + throw new Error("package-lock.json does not match package.json"); + } + + process.stdout.write(packageJson.version); + NODE + )" + printf 'tag=typescript-sdk-v%s\n' "$version" >> "$GITHUB_OUTPUT" + + - name: Create release tag + env: + GH_TOKEN: ${{ github.token }} + API_URL: ${{ github.api_url }} + REPOSITORY: ${{ github.repository }} + TAG: ${{ steps.release.outputs.tag }} + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + run: | + set -euo pipefail + + node <<'NODE' + (async () => { + const apiUrl = process.env.API_URL; + const repository = process.env.REPOSITORY; + const tag = process.env.TAG; + const mergeSha = process.env.MERGE_SHA; + const token = process.env.GH_TOKEN; + const headers = { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "x-github-api-version": "2022-11-28", + }; + + if (!/^typescript-sdk-v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(tag)) { + throw new Error(`invalid stable release tag: ${tag}`); + } + if (!/^[0-9a-f]{40}$/.test(mergeSha)) { + throw new Error(`invalid release commit: ${mergeSha}`); + } + + const request = async (path, options = {}) => { + const response = await fetch(`${apiUrl}${path}`, { + ...options, + headers: { ...headers, ...(options.headers ?? {}) }, + }); + const body = await response.text(); + let data; + try { + data = body ? JSON.parse(body) : undefined; + } catch { + data = body; + } + if (!response.ok) { + throw new Error(`GitHub API ${response.status} for ${path}: ${JSON.stringify(data)}`); + } + return data; + }; + + const refPath = `/repos/${repository}/git/ref/tags/${encodeURIComponent(tag)}`; + const existingResponse = await fetch(`${apiUrl}${refPath}`, { headers }); + if (existingResponse.ok) { + const existingRef = await existingResponse.json(); + if (existingRef.object.type !== "tag") { + throw new Error(`release tag is not annotated: ${tag}`); + } + const existingCommit = (await request(`/repos/${repository}/git/tags/${existingRef.object.sha}`)).object.sha; + if (existingCommit !== mergeSha) { + throw new Error(`release tag already points to ${existingCommit}, expected ${mergeSha}`); + } + console.log(`release tag already points to ${mergeSha}: ${tag}`); + } else { + if (existingResponse.status !== 404) { + throw new Error(`GitHub API ${existingResponse.status} while checking ${tag}`); + } + + const tagObject = await request(`/repos/${repository}/git/tags`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + tag, + message: `Release TypeScript SDK ${tag}`, + object: mergeSha, + type: "commit", + tagger: { + name: "github-actions[bot]", + email: "41898282+github-actions[bot]@users.noreply.github.com", + date: new Date().toISOString(), + }, + }), + }); + await request(`/repos/${repository}/git/refs`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ref: `refs/tags/${tag}`, sha: tagObject.sha }), + }); + console.log(`created release tag ${tag} for ${mergeSha}`); + } + })().catch((error) => { + console.error(error); + process.exitCode = 1; + }); + NODE + + - name: Dispatch tag-based publish + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + run: gh workflow run publish-typescript-sdk.yml --repo "$REPOSITORY" --ref "$RELEASE_TAG" + build: + if: >- + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && + github.ref_type == 'tag' && + startsWith(github.ref_name, 'typescript-sdk-v') runs-on: ubuntu-24.04 timeout-minutes: 30 + permissions: + contents: read defaults: run: working-directory: packages/sdk-typescript @@ -33,9 +234,25 @@ jobs: cache-dependency-path: packages/sdk-typescript/package-lock.json - name: Verify release tag + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + RELEASE_COMMIT: ${{ github.sha }} + RELEASE_REF_TYPE: ${{ github.ref_type }} + REPOSITORY: ${{ github.repository }} run: | - tag_version="${GITHUB_REF_NAME#typescript-sdk-v}" + set -euo pipefail + test "$RELEASE_REF_TYPE" = tag + + tag_ref="repos/${REPOSITORY}/git/ref/tags/${RELEASE_TAG}" + test "$(gh api "$tag_ref" --jq '.object.type')" = tag + tag_object="$(gh api "$tag_ref" --jq '.object.sha')" + tagged_commit="$(gh api "repos/${REPOSITORY}/git/tags/${tag_object}" --jq '.object.sha')" + test "$tagged_commit" = "$RELEASE_COMMIT" + + tag_version="${RELEASE_TAG#typescript-sdk-v}" package_version="$(node -p "JSON.parse(require('fs').readFileSync('package.json', 'utf8')).version")" + test "$RELEASE_TAG" != "$tag_version" test "$tag_version" = "$package_version" - name: Verify release commit is on main @@ -64,6 +281,10 @@ jobs: retention-days: 7 preflight: + if: >- + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && + github.ref_type == 'tag' && + startsWith(github.ref_name, 'typescript-sdk-v') needs: build runs-on: ubuntu-24.04 timeout-minutes: 5 @@ -154,6 +375,10 @@ jobs: NODE publish: + if: >- + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && + github.ref_type == 'tag' && + startsWith(github.ref_name, 'typescript-sdk-v') needs: [build, preflight] runs-on: ubuntu-24.04 environment: npm @@ -162,11 +387,21 @@ jobs: id-token: write steps: - - name: Verify protected release environment + - name: Verify provenance ref env: - tag_ruleset_ready: ${{ vars.SDK_RELEASE_TAG_RULESET_READY }} + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + RELEASE_COMMIT: ${{ github.sha }} + RELEASE_REF: ${{ github.ref }} + REPOSITORY: ${{ github.repository }} run: | - test "$tag_ruleset_ready" = "enabled" + set -euo pipefail + test "$RELEASE_REF" = "refs/tags/$RELEASE_TAG" + tag_ref="repos/${REPOSITORY}/git/ref/tags/${RELEASE_TAG}" + test "$(gh api "$tag_ref" --jq '.object.type')" = tag + tag_object="$(gh api "$tag_ref" --jq '.object.sha')" + tagged_commit="$(gh api "repos/${REPOSITORY}/git/tags/${tag_object}" --jq '.object.sha')" + test "$tagged_commit" = "$RELEASE_COMMIT" - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: From 834eba5c533164c85528e285f5d9178ac7f3bec7 Mon Sep 17 00:00:00 2001 From: Dmitry Gimzelberg Date: Tue, 8 Sep 2026 20:13:13 -0400 Subject: [PATCH 2/2] fix(ci): preserve queued SDK release jobs --- .github/workflows/publish-typescript-sdk.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-typescript-sdk.yml b/.github/workflows/publish-typescript-sdk.yml index e3a4ae0..09e67bd 100644 --- a/.github/workflows/publish-typescript-sdk.yml +++ b/.github/workflows/publish-typescript-sdk.yml @@ -26,7 +26,7 @@ permissions: concurrency: group: >- ${{ github.event_name == 'pull_request_target' - && 'release-typescript-sdk-tag' + && format('release-typescript-sdk-tag-{0}', github.event.pull_request.number) || format('release-typescript-sdk-publish-{0}', github.ref_name) }} cancel-in-progress: false