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
7 changes: 6 additions & 1 deletion .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -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
Comment thread
ximt marked this conversation as resolved.
/packages/sdk-typescript/package-lock.json @gemini/principal-engineers

# Published Go SDK source, module metadata, and release workflow.
/packages/sdk-go/** @fuller @ximt
247 changes: 241 additions & 6 deletions .github/workflows/publish-typescript-sdk.yml
Original file line number Diff line number Diff line change
@@ -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'
&& format('release-typescript-sdk-tag-{0}', github.event.pull_request.number)
|| 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)) {
Comment thread
ximt marked this conversation as resolved.
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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
Loading