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
51 changes: 50 additions & 1 deletion .github/workflows/release-latest-tag.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,57 @@ jobs:
fetch-depth: 0
persist-credentials: false

- name: Move latest to the release tag commit
- name: Verify release tag signature
id: verify-release-tag
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
with:
script: |
const releaseTag = process.env.RELEASE_TAG ?? '';
if (!/^v\d+\.\d+\.\d+$/.test(releaseTag)) {
throw new Error(`Refusing to verify non-semver tag: ${releaseTag}`);
}

const { owner, repo } = context.repo;
const ref = await github.rest.git.getRef({ owner, repo, ref: `tags/${releaseTag}` });
if (ref.data.object.type !== 'tag') {
throw new Error(`Release tag ${releaseTag} must be annotated`);
}

const tagObjectSha = ref.data.object.sha;
let tagObject;
for (let attempt = 1; attempt <= 10; attempt += 1) {
({ data: tagObject } = await github.rest.git.getTag({
owner,
repo,
tag_sha: tagObjectSha,
}));
if (tagObject.verification?.verified === true) break;
if (attempt < 10) {
core.info(`Waiting for GitHub tag verification (${attempt}/10)`);
await new Promise((resolve) => setTimeout(resolve, 3000));
}
}

if (tagObject.tag !== releaseTag) {
throw new Error(`Tag object ${tagObjectSha} names ${tagObject.tag}, expected ${releaseTag}`);
}
if (tagObject.object.type !== 'commit') {
throw new Error(`Release tag ${releaseTag} must point directly to a commit`);
}
if (tagObject.verification?.verified !== true) {
throw new Error(
`Release tag ${releaseTag} is not GitHub-Verified (${tagObject.verification?.reason ?? 'unknown'})`,
);
}

core.setOutput('tag_object_sha', tagObjectSha);
core.info(`Verified signed release tag ${releaseTag} (${tagObjectSha})`);

- name: Move latest to the verified release tag object
env:
EXPECTED_RELEASE_TAG_OBJECT: ${{ steps.verify-release-tag.outputs.tag_object_sha }}
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
PUSH_REMOTE_URL: https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.git
run: scripts/release-latest-tag.sh
5 changes: 5 additions & 0 deletions ci/source-shape-test-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,11 @@
"test": "stages the public NVIDIA key for the Model Router's NVIDIA credential",
"category": "security"
},
{
"file": "test/release-latest-tag-workflow.test.ts",
"test": "binds latest promotion to the exact GitHub-verified tag object",
"category": "security"
},
{
"file": "test/release-lkg-brev-image.test.ts",
"test": "keeps LKG dispatch inside the trusted secret boundary (#6772)",
Expand Down
6 changes: 4 additions & 2 deletions scripts/release-cut-tag.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ while [[ $# -gt 0 ]]; do
cat <<'USAGE'
Usage: scripts/release-cut-tag.sh --plan PATH --confirm "CONFIRM RELEASE vX.Y.Z <sha>"

Creates and pushes only the annotated semver tag described by a release plan.
Creates and pushes only the signed annotated semver tag described by a release plan.
USAGE
exit 0
;;
Expand Down Expand Up @@ -82,7 +82,9 @@ if git ls-remote --exit-code --tags origin "$tag" >/dev/null; then
fail "Remote tag already exists: $tag"
fi

git tag -a "$tag" "$target" -m "$tag"
# Release tags are immutable once pushed. Sign the tag on the release
# operator's workstation so the private signing key never enters CI.
git tag -s "$tag" "$target" -m "$tag"
git push origin "refs/tags/$tag"

remote_peeled="$(git ls-remote --tags origin "refs/tags/$tag^{}" | awk '{print $1}')"
Expand Down
40 changes: 24 additions & 16 deletions scripts/release-latest-tag.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ set -euo pipefail

REMOTE_NAME="${REMOTE_NAME:-origin}"
RELEASE_TAG="${RELEASE_TAG:?RELEASE_TAG is required}"
EXPECTED_RELEASE_TAG_OBJECT="${EXPECTED_RELEASE_TAG_OBJECT:-}"
PUSH_LATEST="${PUSH_LATEST:-1}"
PUSH_REMOTE_URL="${PUSH_REMOTE_URL:-$REMOTE_NAME}"

Expand All @@ -14,22 +15,12 @@ fail() {
exit 1
}

ensure_tag_identity() {
if git var GIT_COMMITTER_IDENT >/dev/null 2>&1; then
return
fi

git config user.name "${RELEASE_TAGGER_NAME:-github-actions[bot]}"
git config user.email "${RELEASE_TAGGER_EMAIL:-41898282+github-actions[bot]@users.noreply.github.com}"

if ! git var GIT_COMMITTER_IDENT >/dev/null 2>&1; then
fail "Unable to configure a git committer identity for latest tag promotion"
fi
}

if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
fail "Refusing to promote non-semver tag: $RELEASE_TAG"
fi
if [[ ! "$EXPECTED_RELEASE_TAG_OBJECT" =~ ^[0-9a-f]{40}$ ]]; then
fail "EXPECTED_RELEASE_TAG_OBJECT must be the GitHub-verified tag object SHA"
fi

# Force-refresh remote main and tags so local stale tags cannot influence the
# release-order, reachability, or annotated-tag checks.
Expand All @@ -41,6 +32,11 @@ if [[ "$(git cat-file -t "refs/tags/$RELEASE_TAG" 2>/dev/null || true)" != "tag"
fail "Refusing to promote $RELEASE_TAG: release tags must be annotated"
fi

release_tag_object="$(git rev-parse "refs/tags/$RELEASE_TAG")"
if [[ "$release_tag_object" != "$EXPECTED_RELEASE_TAG_OBJECT" ]]; then
fail "Refusing to promote $RELEASE_TAG: local tag object $release_tag_object does not match GitHub-verified object $EXPECTED_RELEASE_TAG_OBJECT"
fi

release_commit="$(git rev-parse "${RELEASE_TAG}^{commit}")"
main_ref="refs/remotes/${REMOTE_NAME}/main"
main_commit="$(git rev-parse "$main_ref")"
Expand All @@ -67,6 +63,7 @@ if [[ "$RELEASE_TAG" != "$latest_remote_semver" ]]; then
fail "Refusing to promote $RELEASE_TAG: latest remote semver tag is $latest_remote_semver"
fi

latest_object="$(git rev-parse --verify --quiet "refs/tags/latest" || true)"
latest_commit="$(git rev-parse --verify --quiet "refs/tags/latest^{commit}" || true)"
if [[ -n "$latest_commit" ]] && ! git merge-base --is-ancestor "$latest_commit" "$release_commit"; then
fail "Refusing to move latest backward: current latest $latest_commit is not an ancestor of $RELEASE_TAG ($release_commit)"
Expand All @@ -87,20 +84,31 @@ if [[ -n "$previous_remote_semver" ]]; then
fi
fi

ensure_tag_identity
git tag -fa latest "$release_commit" -m "latest -> $RELEASE_TAG"
# Point latest at the already signed and GitHub-verified semver tag object.
# Do not mint a second unsigned annotated tag in CI.
git update-ref refs/tags/latest "$release_tag_object"

if [[ "$PUSH_LATEST" != "0" ]]; then
git push "$PUSH_REMOTE_URL" refs/tags/latest --force
git push \
--force-with-lease="refs/tags/latest:${latest_object}" \
"$PUSH_REMOTE_URL" \
refs/tags/latest

remote_latest_object="$(git ls-remote --tags "$REMOTE_NAME" refs/tags/latest | awk '{print $1}')"
if [[ "$remote_latest_object" != "$release_tag_object" ]]; then
fail "Remote latest object $remote_latest_object does not match release tag object $release_tag_object"
fi
fi

{
echo "## Release latest tag"
echo
echo "- Release tag: \`$RELEASE_TAG\`"
echo "- Release tag object: \`$release_tag_object\`"
echo "- Release commit: \`$release_commit\`"
echo "- Remote main: \`$main_commit\`"
echo "- Latest remote semver: \`$latest_remote_semver\`"
echo "- Previous latest object: \`${latest_object:-none}\`"
echo "- Previous latest commit: \`${latest_commit:-none}\`"
echo "- Previous semver tag: \`${previous_remote_semver:-none}\`"
echo "- Previous semver commit: \`${previous_semver_commit:-none}\`"
Expand Down
2 changes: 1 addition & 1 deletion scripts/release-plan.mts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ function main(): void {
planPath,
confirmationPhrase: `CONFIRM RELEASE ${nextTag} ${originMainCommit}`,
operations: [
`create annotated ${nextTag} tag at ${originMainCommit}`,
`create signed annotated ${nextTag} tag at ${originMainCommit}`,
`push ${nextTag}`,
"wait for release-latest-tag workflow to move latest",
"draft release notes from live compare data",
Expand Down
10 changes: 8 additions & 2 deletions scripts/release-wait-latest.sh
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,16 @@ remote_tag_commit_or_object() {
deadline=$((SECONDS + TIMEOUT_SECS))
latest_peeled=""
semver_peeled=""
latest_object=""
semver_object=""

while ((SECONDS <= deadline)); do
semver_object="$(git ls-remote --tags origin "refs/tags/$tag" | awk '{print $1}')"
semver_peeled="$(git ls-remote --tags origin "refs/tags/$tag^{}" | awk '{print $1}')"
latest_object="$(git ls-remote --tags origin refs/tags/latest | awk '{print $1}')"
latest_peeled="$(git ls-remote --tags origin 'refs/tags/latest^{}' | awk '{print $1}')"

if [[ "$semver_peeled" == "$target" && "$latest_peeled" == "$target" ]]; then
if [[ "$semver_peeled" == "$target" && "$latest_peeled" == "$target" && "$latest_object" == "$semver_object" ]]; then
break
fi

Expand All @@ -86,6 +90,8 @@ done

[[ "$semver_peeled" == "$target" ]] || fail "$tag peeled to $semver_peeled, expected $target"
[[ "$latest_peeled" == "$target" ]] || fail "latest peeled to $latest_peeled, expected $target"
[[ -n "$semver_object" ]] || fail "$tag tag object is missing"
[[ "$latest_object" == "$semver_object" ]] || fail "latest tag object $latest_object does not match $tag object $semver_object"

lkg_after="$(remote_tag_commit_or_object lkg)"
if [[ -n "$lkg_before" && "$lkg_after" != "$lkg_before" ]]; then
Expand All @@ -96,7 +102,7 @@ if [[ -z "$lkg_before" && -n "$lkg_after" ]]; then
fi

result_path="$(dirname "$PLAN_PATH")/latest-result.json"
node -e 'const fs=require("fs"); const result={schemaVersion:1,status:"ok",planPath:process.argv[1],planHash:process.argv[2],tag:process.argv[3],targetCommit:process.argv[4],semverPeeledCommit:process.argv[5],latestPeeledCommit:process.argv[6],lkgPeeledCommitBefore:process.argv[7] || null,lkgPeeledCommitAfter:process.argv[8] || null,createdAt:new Date().toISOString()}; fs.writeFileSync(process.argv[9], JSON.stringify(result, null, 2) + "\n");' "$PLAN_PATH" "$plan_hash" "$tag" "$target" "$semver_peeled" "$latest_peeled" "$lkg_before" "$lkg_after" "$result_path"
node -e 'const fs=require("fs"); const result={schemaVersion:1,status:"ok",planPath:process.argv[1],planHash:process.argv[2],tag:process.argv[3],targetCommit:process.argv[4],semverTagObject:process.argv[5],latestTagObject:process.argv[6],semverPeeledCommit:process.argv[7],latestPeeledCommit:process.argv[8],lkgPeeledCommitBefore:process.argv[9] || null,lkgPeeledCommitAfter:process.argv[10] || null,createdAt:new Date().toISOString()}; fs.writeFileSync(process.argv[11], JSON.stringify(result, null, 2) + "\n");' "$PLAN_PATH" "$plan_hash" "$tag" "$target" "$semver_object" "$latest_object" "$semver_peeled" "$latest_peeled" "$lkg_before" "$lkg_after" "$result_path"

printf 'release-wait-latest: latest and %s peel to %s\n' "$tag" "$target"
printf 'release-wait-latest: result written: %s\n' "$result_path"
137 changes: 137 additions & 0 deletions test/release-latest-tag-workflow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { afterEach, describe, expect, it, vi } from "vitest";

import { readYaml, type WorkflowJob } from "./helpers/e2e-workflow-contract";

const AsyncFunction = Object.getPrototypeOf(async () => undefined).constructor as new (
...parameters: string[]
) => (...args: unknown[]) => Promise<unknown>;

type ReleaseLatestWorkflow = {
jobs: Record<string, WorkflowJob>;
};

const WORKFLOW_PATH = ".github/workflows/release-latest-tag.yaml";
const RELEASE_TAG = "v0.0.86";
const TAG_OBJECT_SHA = "a".repeat(40);
const RELEASE_COMMIT = "b".repeat(40);
const workflow = readYaml<ReleaseLatestWorkflow>(WORKFLOW_PATH);
const job = workflow.jobs["update-latest"];
const verifyStep = job.steps?.find((step) => step.id === "verify-release-tag");
const moveStep = job.steps?.find(
(step) => step.name === "Move latest to the verified release tag object",
);
const verifyScript = verifyStep?.with?.script;

function createHarness(verification: { verified: boolean; reason: string }) {
const getRef = vi.fn().mockResolvedValue({
data: { object: { sha: TAG_OBJECT_SHA, type: "tag" } },
});
const getTag = vi.fn().mockResolvedValue({
data: {
object: { sha: RELEASE_COMMIT, type: "commit" },
tag: RELEASE_TAG,
verification,
},
});
const setOutput = vi.fn();
const info = vi.fn();

return {
core: { info, setOutput },
getRef,
getTag,
github: { rest: { git: { getRef, getTag } } },
context: { repo: { owner: "NVIDIA", repo: "NemoClaw" } },
info,
setOutput,
};
}

async function runVerify(harness: ReturnType<typeof createHarness>): Promise<void> {
expect(verifyScript).toEqual(expect.any(String));
await new AsyncFunction("github", "context", "core", verifyScript as string)(
harness.github,
harness.context,
harness.core,
);
}

afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
});

describe("release latest tag workflow", () => {
// source-shape-contract: security -- Exact verified-object output wiring prevents latest promotion from bypassing GitHub signature verification
it("binds latest promotion to the exact GitHub-verified tag object", () => {
expect(verifyStep?.uses).toBe("actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3");
expect(moveStep?.env?.EXPECTED_RELEASE_TAG_OBJECT).toBe(
"${{ steps.verify-release-tag.outputs.tag_object_sha }}",
);
});

it("accepts a GitHub-verified signed tag and emits its exact object SHA", async () => {
vi.stubEnv("RELEASE_TAG", RELEASE_TAG);
const harness = createHarness({ verified: true, reason: "valid" });

await runVerify(harness);

expect(harness.getRef).toHaveBeenCalledWith({
owner: "NVIDIA",
ref: `tags/${RELEASE_TAG}`,
repo: "NemoClaw",
});
expect(harness.getTag).toHaveBeenCalledWith({
owner: "NVIDIA",
repo: "NemoClaw",
tag_sha: TAG_OBJECT_SHA,
});
expect(harness.setOutput).toHaveBeenCalledWith("tag_object_sha", TAG_OBJECT_SHA);
});

it("waits for GitHub signature verification to propagate", async () => {
vi.useFakeTimers();
vi.stubEnv("RELEASE_TAG", RELEASE_TAG);
const harness = createHarness({ verified: true, reason: "valid" });
harness.getTag
.mockResolvedValueOnce({
data: {
object: { sha: RELEASE_COMMIT, type: "commit" },
tag: RELEASE_TAG,
verification: { verified: false, reason: "unsigned" },
},
})
.mockResolvedValue({
data: {
object: { sha: RELEASE_COMMIT, type: "commit" },
tag: RELEASE_TAG,
verification: { verified: true, reason: "valid" },
},
});

const verification = runVerify(harness);
await vi.runAllTimersAsync();
await verification;

expect(harness.getTag).toHaveBeenCalledTimes(2);
expect(harness.setOutput).toHaveBeenCalledWith("tag_object_sha", TAG_OBJECT_SHA);
});

it("rejects a tag that GitHub never verifies", async () => {
vi.useFakeTimers();
vi.stubEnv("RELEASE_TAG", RELEASE_TAG);
const harness = createHarness({ verified: false, reason: "unsigned" });

const verification = expect(runVerify(harness)).rejects.toThrow(
`Release tag ${RELEASE_TAG} is not GitHub-Verified (unsigned)`,
);
await vi.runAllTimersAsync();
await verification;

expect(harness.getTag).toHaveBeenCalledTimes(10);
expect(harness.setOutput).not.toHaveBeenCalled();
});
});
Loading