diff --git a/.agents/skills/nemoclaw-maintainer-cut-release-tag/SKILL.md b/.agents/skills/nemoclaw-maintainer-cut-release-tag/SKILL.md index 0edd4c6a466..c91262804c7 100644 --- a/.agents/skills/nemoclaw-maintainer-cut-release-tag/SKILL.md +++ b/.agents/skills/nemoclaw-maintainer-cut-release-tag/SKILL.md @@ -11,7 +11,7 @@ user_invocable: true Use the release scripts for normal release operations. Do not run raw `git tag`, `git push`, `gh api`, or version-bump commands by hand for the normal release flow. -The release is one annotated semver tag on an already-merged `origin/main` commit. The GitHub workflow moves `latest`; release admins promote `lkg` manually after validation. After the tag and `latest` are verified, automatically move remaining open issues/PRs from the released version label to the next patch label, draft release notes, then verify the maintainer-published Announcement before final handoff. +The release is one signed annotated semver tag on an already-merged `origin/main` commit. The GitHub workflow requires that tag to be GitHub-Verified, points `latest` at the exact verified tag object, carries remaining open issues/PRs to the next patch label, and deletes the released label while holding the shared release-label coordination queue; release admins promote `lkg` manually after validation. After the workflow is verified, draft release notes, then verify the maintainer-published Announcement before final handoff. ## LKG Production Image Dispatch @@ -34,6 +34,8 @@ The downstream scheduled reconciliation remains available if the event-driven di - Push only the semver tag (`vX.Y.Z`) from the agent-controlled step. - Never push `latest` or `lkg` from this skill. - Never move, delete, or force-push an existing remote semver tag unless the maintainer explicitly starts protected-tag remediation. +- Delete the released version label only after open work moves forward and a final query finds no open stragglers. Never rename or reuse a released label. +- Keep label retirement inside the `release-latest-tag` workflow so it cannot overlap the post-merge labeler. Do not run the retirement script directly. - Draft release notes locally. Do not create the GitHub Discussion; the maintainer does that. - Do not mark the announcement step complete until the maintainer provides a valid Discussion URL and the published Announcement is verified. - Follow the shared [Git and GitHub Access Hard Stop](../_shared/git-github-hard-stop.md) for SSH, authentication, remote access, authorization, or permission failures. @@ -48,7 +50,7 @@ Release Progress: - [ ] Step 2: Show plan, E2E evidence, and confirmation phrase - [ ] Step 3: Cut the semver tag from the confirmed plan - [ ] Step 4: Wait for workflow-managed latest -- [ ] Step 5: Bump remaining open issues/PRs +- [ ] Step 5: Carry open work forward and retire the released label - [ ] Step 6: Generate release-note data and draft Markdown - [ ] Step 7: Wait for maintainer-published Announcement - [ ] Step 8: Verify Announcement and hand off sharing @@ -96,7 +98,7 @@ Read the generated `plan.json` and show the maintainer: - plan hash, - forbidden operations, - confirmation phrase, -- open issue/PR housekeeping plan for the release label. +- open issue/PR housekeeping plan for the release label, including deletion of the released label after carry-forward succeeds. Unless Step 1 records an explicit waiver, verify that the plan's next tag matches the H2 version heading in the dated changelog entry at the candidate SHA. When the entry is waived, show the recorded waiver reason in the plan presentation and confirmation handoff instead. @@ -128,7 +130,7 @@ Run the cut script with the plan and the maintainer's phrase: npm run release:cut -- --plan --confirm "CONFIRM RELEASE vX.Y.Z " ``` -The script verifies a clean worktree, unchanged `origin/main`, tag availability, target reachability, and remote peeled tag state. It writes: +The script verifies a clean worktree, unchanged `origin/main`, tag availability, target reachability, and remote peeled tag state, then creates and pushes the signed annotated tag using the configured signing key. It writes: ```text /cut-result.json @@ -144,7 +146,7 @@ Run: npm run release:wait-latest -- --plan ``` -The script waits until `vX.Y.Z^{}` and `latest^{}` both peel to the planned commit and verifies `lkg` did not change from the plan. It writes: +The script waits until `vX.Y.Z` and `latest` reference the same tag object, verifies both peel to the planned commit, and verifies `lkg` did not change from the plan. It writes: ```text /latest-result.json @@ -152,26 +154,40 @@ The script waits until `vX.Y.Z^{}` and `latest^{}` both peel to the planned comm If it fails, report the failed workflow/status. Do not manually move `latest`. -### Step 5: Bump Remaining Open Issues/PRs +### Step 5: Verify Carry-Forward and Label Retirement -Move every remaining open issue or PR carrying the released version to the next patch label: +The `release-latest-tag` workflow continues after moving `latest`: it moves every remaining open issue or PR carrying the released version to the next patch label, verifies none remain, and deletes the released label. The workflow and post-merge labeler share one queued concurrency group, so assignment cannot overlap the verification-and-delete window. + +Find the workflow run started by Step 3 and wait for it to finish: ```bash -node --experimental-strip-types --no-warnings .agents/skills/nemoclaw-maintainer-day/scripts/bump-stragglers.ts +RELEASE_SHA="" +mapfile -t RELEASE_RUN_IDS < <( + gh run list --repo NVIDIA/NemoClaw --workflow release-latest-tag.yaml --limit 20 \ + --event push --commit "$RELEASE_SHA" --json databaseId --jq '.[].databaseId' +) +if (( ${#RELEASE_RUN_IDS[@]} != 1 )); then + echo "Expected exactly one release-latest-tag push run for $RELEASE_SHA" >&2 + exit 1 +fi +gh run watch "${RELEASE_RUN_IDS[0]}" --repo NVIDIA/NemoClaw --exit-status ``` -This is automatic post-tag housekeeping covered by the release plan and confirmation in Step 2. The script creates the next patch label when needed, removes the released-version label, and adds the next-version label to every open straggler. Do not run it before Step 4 verifies both the semver tag and workflow-managed `latest`. +This automatic post-tag housekeeping is covered by the release plan and confirmation in Step 2. Do not run `scripts/retire-release-label.mts` directly; doing so would bypass the coordination boundary. -Then verify the released version has no open stragglers: +Then verify the released version label no longer exists: ```bash -gh issue list --repo NVIDIA/NemoClaw --state open --label --limit 100 -gh pr list --repo NVIDIA/NemoClaw --state open --label --limit 100 +gh label list --repo NVIDIA/NemoClaw --search --json name \ + --jq '.[] | select(.name == "")' ``` +The command must return no output. Never rename the released label into a future version; a future target must be a separately created label with its own GitHub identity. + Summarize: -- open issues/PRs bumped to ``; +- open issues/PRs moved to ``; +- released label deleted; - any items that need manual maintainer attention. ### Step 6: Generate Release-Note Data and Draft Markdown @@ -235,7 +251,7 @@ If the Announcement is valid, return its URL with the release artifacts and mark - `latest` workflow fails or times out: report the workflow/status; do not move `latest` manually. - `latest` workflow rejects a rollback: keep `latest` unchanged, inspect the plan target commit, and regenerate the plan for the current `origin/main` tip if appropriate. - `lkg` changed: stop and escalate to a release admin. -- Post-tag housekeeping fails: report the error and list items still carrying the released label. After the failure is fixed, rerun the same bump command; already-moved items no longer match the source label. +- Post-tag housekeeping fails: report the workflow error and list items still carrying the released label. After the failure is fixed, rerun `release-latest-tag.yaml` with `` through `workflow_dispatch`; the promotion and retirement steps are idempotent, already-moved items no longer match the source label, and an already-deleted released label is treated as success. Do not run the retirement script outside the workflow. - Announcement is not published yet: keep Step 7 in progress and return the draft path and suggested title; the tag and housekeeping remain complete. - Announcement title, category, body, or links are wrong: ask the maintainer to edit the existing Discussion, then verify the same URL again. Do not create a replacement. After three failed verification attempts for the same Discussion, stop and escalate to a release admin. - Announcement cannot be inspected: report the read failure and ask the maintainer to confirm access or provide a public URL; do not mark Step 8 complete. diff --git a/.agents/skills/nemoclaw-maintainer-day/PR-REVIEW-PRIORITIES.md b/.agents/skills/nemoclaw-maintainer-day/PR-REVIEW-PRIORITIES.md index 9dd31f3bf12..bfa2ffed5dd 100644 --- a/.agents/skills/nemoclaw-maintainer-day/PR-REVIEW-PRIORITIES.md +++ b/.agents/skills/nemoclaw-maintainer-day/PR-REVIEW-PRIORITIES.md @@ -38,10 +38,10 @@ The team follows a daily ship cycle. All maintainer skills operate within this r 1. **Morning** (`/nemoclaw-maintainer-morning`) — triage the backlog, pick items for the day, label them with the target version (e.g., `v0.0.8`). 2. **During the day** (`/nemoclaw-maintainer-day`) — land PRs using the maintainer loop. Version labels make progress visible on dashboards. -3. **Evening** (`/nemoclaw-maintainer-evening`) — check what shipped, merge or explicitly waive the pre-tag release-note docs PR containing `docs/changelog/YYYY-MM-DD.mdx` for the target version, identify open stragglers, generate a QA-focused summary, freeze the candidate SHA, collect the E2E evidence or itemized maintainer exceptions required before confirmation, cut the tag, automatically bump stragglers to the next patch, and prepare the Announcement for posting. +3. **Evening** (`/nemoclaw-maintainer-evening`) — check what shipped, merge or explicitly waive the pre-tag release-note docs PR containing `docs/changelog/YYYY-MM-DD.mdx` for the target version, identify open stragglers, generate a QA-focused summary, freeze the candidate SHA, collect the E2E evidence or itemized maintainer exceptions required before confirmation, cut the tag, automatically carry stragglers to the next patch, delete the released label, and prepare the Announcement for posting. 4. **Overnight** — QA team (different timezone) performs additional validation of the tag. Any issues they file enter the next morning's triage like any other issue. -Version labels activate release work; they are not readiness claims. If an open item misses the tag, its label moves to the next patch during post-tag housekeeping. +Version labels activate release work; they are not readiness claims. If an open item misses the tag, its label moves to the next patch during post-tag housekeeping. After no open item remains, housekeeping deletes the released label; it never renames or reuses it. ## Explicitly not priorities diff --git a/.agents/skills/nemoclaw-maintainer-day/scripts/bump-stragglers.ts b/.agents/skills/nemoclaw-maintainer-day/scripts/bump-stragglers.ts deleted file mode 100644 index 2a8f008bae4..00000000000 --- a/.agents/skills/nemoclaw-maintainer-day/scripts/bump-stragglers.ts +++ /dev/null @@ -1,167 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -/** - * Bump open items from one version label to another. - * - * Creates the target label if needed, then swaps labels on all open - * PRs and issues carrying the source version. - * - * Usage: node --experimental-strip-types --no-warnings .agents/skills/nemoclaw-maintainer-day/scripts/bump-stragglers.ts [--repo OWNER/REPO] - */ - -import { execFileSync } from "node:child_process"; - -import { parseStringArg } from "./shared.ts"; - -interface BumpedItem { - number: number; - title: string; - type: "pr" | "issue"; -} - -interface BumpOutput { - from: string; - to: string; - bumped: BumpedItem[]; -} - -function main(): void { - const args = process.argv.slice(2); - const from = args[0]; - const to = args[1]; - if (!from || !to) { - console.error("Usage: bump-stragglers.ts [--repo OWNER/REPO]"); - process.exit(1); - } - - const repo = parseStringArg(args, "--repo", "NVIDIA/NemoClaw"); - - ensureReleaseLabel(repo, to); - - const bumped: BumpedItem[] = []; - - // Bump open PRs - const prs = ghJsonArray<{ number: number; title: string }>([ - "pr", - "list", - "--repo", - repo, - "--label", - from, - "--state", - "open", - "--json", - "number,title", - "--limit", - "100", - ]); - for (const pr of prs) { - gh([ - "pr", - "edit", - String(pr.number), - "--repo", - repo, - "--remove-label", - from, - "--add-label", - to, - ]); - bumped.push({ number: pr.number, title: pr.title, type: "pr" }); - } - - // Bump open issues - const issues = ghJsonArray<{ number: number; title: string }>([ - "issue", - "list", - "--repo", - repo, - "--label", - from, - "--state", - "open", - "--json", - "number,title", - "--limit", - "100", - ]); - for (const issue of issues) { - gh([ - "issue", - "edit", - String(issue.number), - "--repo", - repo, - "--remove-label", - from, - "--add-label", - to, - ]); - bumped.push({ number: issue.number, title: issue.title, type: "issue" }); - } - - const output: BumpOutput = { from, to, bumped }; - console.log(JSON.stringify(output, null, 2)); -} - -function ensureReleaseLabel(repo: string, label: string): void { - const labels = ghJsonArray<{ name: string }>( - ["label", "list", "--repo", repo, "--search", label, "--json", "name", "--limit", "100"], - { emptyOutputIsEmptyArray: true }, - ); - if (labels.some((entry) => entry.name === label)) return; - - gh([ - "label", - "create", - label, - "--repo", - repo, - "--description", - "Release target", - "--color", - "1d76db", - ]); -} - -function ghJsonArray( - args: string[], - { emptyOutputIsEmptyArray = false }: { emptyOutputIsEmptyArray?: boolean } = {}, -): T[] { - const output = gh(args); - if (output === "" && emptyOutputIsEmptyArray) return []; - try { - const parsed = JSON.parse(output) as unknown; - if (!Array.isArray(parsed)) { - throw new Error(`expected JSON array, got ${typeof parsed}`); - } - return parsed as T[]; - } catch (error) { - const reason = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to parse gh JSON output for gh ${args.join(" ")}: ${reason}`); - } -} - -function gh(args: string[]): string { - try { - return execFileSync("gh", args, { - encoding: "utf-8", - maxBuffer: 10 * 1024 * 1024, - stdio: ["ignore", "pipe", "pipe"], - }).trim(); - } catch (error) { - const errorObject = typeof error === "object" && error !== null ? error : null; - const stdout = readStringProperty(errorObject, "stdout")?.trim(); - const stderr = readStringProperty(errorObject, "stderr")?.trim(); - throw new Error([`gh ${args.join(" ")} failed`, stdout, stderr].filter(Boolean).join("\n")); - } -} - -function readStringProperty(value: object | null, key: string): string | undefined { - if (!value || Array.isArray(value)) return undefined; - const property = Reflect.get(value, key); - return typeof property === "string" ? property : undefined; -} - -main(); diff --git a/.agents/skills/nemoclaw-maintainer-evening/SKILL.md b/.agents/skills/nemoclaw-maintainer-evening/SKILL.md index ed5e968769d..ad115240370 100644 --- a/.agents/skills/nemoclaw-maintainer-evening/SKILL.md +++ b/.agents/skills/nemoclaw-maintainer-evening/SKILL.md @@ -6,7 +6,7 @@ user_invocable: true # NemoClaw Maintainer Evening -Wrap up the day: check progress, identify stragglers, summarize for QA, cut the tag, automatically bump stragglers to the next patch, and prepare release notes for posting. +Wrap up the day: check progress, identify stragglers, summarize for QA, cut the tag, automatically carry stragglers to the next patch, retire the released label, and prepare release notes for posting. See [PR-REVIEW-PRIORITIES.md](../nemoclaw-maintainer-day/PR-REVIEW-PRIORITIES.md) for the daily cadence. @@ -28,7 +28,7 @@ gh issue list --repo NVIDIA/NemoClaw --state open --label --limit 100 --json number,title,url,labels ``` -List open labeled PRs and issues as the post-tag housekeeping plan. Tell the maintainer that, after the tag and workflow-managed `latest` are verified, `cut-release-tag` will automatically move all of them to the next patch label. +List open labeled PRs and issues as the post-tag housekeeping plan. Tell the maintainer that, after the tag and workflow-managed `latest` are verified, `cut-release-tag` will automatically move all of them to the next patch label and delete the released label. If an item should leave the daily release flow instead of moving forward, remove it from the released-version label before asking for the release confirmation phrase. @@ -50,7 +50,7 @@ If a docs PR or any other intended PR merges after `release:plan`, regenerate th ## Step 4: Cut the Tag and Publish Release Notes -Load `cut-release-tag`. The version is already known — default to patch bump, but still show the commit, changelog, post-tag bump plan, and release notes draft for confirmation. After the release plan freezes the candidate SHA, review the pre-tag E2E evidence ledger derived from `.github/workflows/e2e.yaml` at that commit. Do not ask for the release confirmation phrase until every test has green evidence or an explicit itemized maintainer exception. NemoClaw releases are tag-based: tag the confirmed release commit with `vX.Y.Z`, let the workflow move `latest`, automatically bump remaining open issues/PRs to the next patch label, and prepare the release notes announcement for the maintainer to post. +Load `cut-release-tag`. The version is already known — default to patch bump, but still show the commit, changelog, post-tag carry-forward and label-retirement plan, and release notes draft for confirmation. After the release plan freezes the candidate SHA, review the pre-tag E2E evidence ledger derived from `.github/workflows/e2e.yaml` at that commit. Do not ask for the release confirmation phrase until every test has green evidence or an explicit itemized maintainer exception. NemoClaw releases are tag-based: tag the confirmed release commit with `vX.Y.Z`, let the workflow move `latest`, automatically carry remaining open issues/PRs to the next patch label, delete the released label, and prepare the release notes announcement for the maintainer to post. ## Step 5: Confirm and Share @@ -60,7 +60,8 @@ After the tag is cut and release notes are drafted or posted by the maintainer, - **Pre-tag E2E evidence**: 12/13 tests green for the candidate SHA; 1 itemized maintainer exception - **Release notes draft**: `../nemoclaw-release-v0.0.8/release-note-draft.md` - **Shipped**: 4 items (#1234, #1235, #1236, #1237) -- **Bumped to v0.0.9**: 1 item (#1238 — still needs CI fix) +- **Moved to v0.0.9**: 1 item (#1238 — still needs CI fix) +- **Retired label**: `v0.0.8` - **QA focus areas**: installer changes, new onboard preset This summary can be shared in the team's handoff channel. @@ -68,7 +69,7 @@ This summary can be shared in the team's handoff channel. ## Step 6: Update State ```bash -node --experimental-strip-types --no-warnings .agents/skills/nemoclaw-maintainer-day/scripts/state.ts history "tag-cut" "" "shipped N items, bumped M" +node --experimental-strip-types --no-warnings .agents/skills/nemoclaw-maintainer-day/scripts/state.ts history "tag-cut" "" "shipped N items, carried M forward" ``` ## Notes @@ -77,3 +78,4 @@ node --experimental-strip-types --no-warnings .agents/skills/nemoclaw-maintainer - If nothing was labeled or nothing shipped, ask whether to skip the tag today. - A PR version label activates release work; it is not a readiness claim. - If an open item misses the tag, post-tag housekeeping moves its target to the next patch version. +- After carry-forward succeeds, post-tag housekeeping deletes the released label; never rename or reuse it. diff --git a/.agents/skills/nemoclaw-maintainer-policies/SKILL.md b/.agents/skills/nemoclaw-maintainer-policies/SKILL.md index 280f37a0f38..ece39861638 100644 --- a/.agents/skills/nemoclaw-maintainer-policies/SKILL.md +++ b/.agents/skills/nemoclaw-maintainer-policies/SKILL.md @@ -13,7 +13,7 @@ This is a policy-only skill package. `SKILL.md` is only the manifest and index. ## References -- **Broad overview:** When a new engineer asks how NemoClaw uses GitHub or how the maintainer workflow works, load [references/workflow-policy.md](references/workflow-policy.md), then [references/project-workflow.md](references/project-workflow.md), then [references/daily-flow.md](references/daily-flow.md). Use [references/release-train.md](references/release-train.md) only when release labels, cutoff, carry-forward, release history, or label pruning are involved. +- **Broad overview:** When a new engineer asks how NemoClaw uses GitHub or how the maintainer workflow works, load [references/workflow-policy.md](references/workflow-policy.md), then [references/project-workflow.md](references/project-workflow.md), then [references/daily-flow.md](references/daily-flow.md). Use [references/release-train.md](references/release-train.md) only when release labels, cutoff, carry-forward, release history, or label retirement are involved. - **Agent implementation:** When building or updating an agent or app that applies or recommends workflow metadata, load [references/workflow-policy.md](references/workflow-policy.md), [references/triage-instructions.md](references/triage-instructions.md), [references/label-taxonomy.json](references/label-taxonomy.json), and [references/examples.md](references/examples.md), in that order. - **Load [references/workflow-policy.md](references/workflow-policy.md)** when answering source-of-truth, authorization, Issue Type, label boundary, or agent-owned label questions. - **Load [references/triage-instructions.md](references/triage-instructions.md)** when answering how to evaluate issues or PRs, when to ask for information, how to use `needs:*`, how to set confidence, or what suggestion payload shape to emit. @@ -21,7 +21,7 @@ This is a policy-only skill package. `SKILL.md` is only the manifest and index. - **Load [references/label-taxonomy.json](references/label-taxonomy.json)** when validating machine-readable Issue Type, Project field, label, signal, compatibility, or write-policy values. - **Load [references/project-workflow.md](references/project-workflow.md)** when answering Project Status, Project fields, duplicate, blocked, backlog, review, QA, issue-template, or lifecycle workflow questions. - **Load [references/daily-flow.md](references/daily-flow.md)** when answering daily slate, priority lane, standup, assignment, execution, QA handoff, or daily operating-loop questions. -- **Load [references/release-train.md](references/release-train.md)** when answering daily version-label, release inclusion, carry-forward, cutoff, release history, or label-pruning questions. +- **Load [references/release-train.md](references/release-train.md)** when answering daily version-label, release inclusion, carry-forward, cutoff, release history, or label-retirement questions. - **Load [references/examples.md](references/examples.md)** when examples or anti-examples are needed for triage, PR review, daily release activation, competing PRs, stale/rebase cases, or agent-owned labels. ## Answering Workflow Questions diff --git a/.agents/skills/nemoclaw-maintainer-policies/references/daily-flow.md b/.agents/skills/nemoclaw-maintainer-policies/references/daily-flow.md index b0fd3de8fc3..117f289b21a 100644 --- a/.agents/skills/nemoclaw-maintainer-policies/references/daily-flow.md +++ b/.agents/skills/nemoclaw-maintainer-policies/references/daily-flow.md @@ -70,4 +70,5 @@ Agents may recommend labels, assignments, Project field changes, comments, merge - Issue daily version labels are tracking or coordination signals only. - Before tag confirmation, freeze the candidate SHA and review every E2E test declared by `.github/workflows/e2e.yaml` at that commit. Each test needs green evidence for that SHA or an explicit itemized maintainer exception. - Open PRs and issues that miss a tagged release carry forward by automatically moving from the released version label to the next patch label after the tag and `latest` are verified. +- After carry-forward leaves no open item on the released label, delete that repository label. Never rename or reuse it. - Durable release history belongs in releases, release notes, or manifests, not in long-lived labels. diff --git a/.agents/skills/nemoclaw-maintainer-policies/references/examples.md b/.agents/skills/nemoclaw-maintainer-policies/references/examples.md index d7fe2dfad6d..561d6776a3d 100644 --- a/.agents/skills/nemoclaw-maintainer-policies/references/examples.md +++ b/.agents/skills/nemoclaw-maintainer-policies/references/examples.md @@ -471,6 +471,8 @@ Anti-examples: - Do not treat `v0.0.8` as a readiness claim. - Do not move stragglers before the semver tag and workflow-managed `latest` are verified. - Do not leave an open straggler on the released label after authorized post-tag housekeeping succeeds. +- Do not keep a released label after open stragglers move forward. +- Do not rename or reuse a released label for a future version. - Do not bulk-edit daily version labels outside the named release automation or another explicit authorization context. ## Agent-Owned Label Examples diff --git a/.agents/skills/nemoclaw-maintainer-policies/references/label-taxonomy.json b/.agents/skills/nemoclaw-maintainer-policies/references/label-taxonomy.json index 96fadd902bb..57fb65e1834 100644 --- a/.agents/skills/nemoclaw-maintainer-policies/references/label-taxonomy.json +++ b/.agents/skills/nemoclaw-maintainer-policies/references/label-taxonomy.json @@ -609,9 +609,9 @@ "release": { "item_kinds": ["issue", "pull_request"], "pattern": "^v\\d+\\.\\d+\\.\\d+$", - "positive_signals": ["maintainer selected PR for daily work", "open PR activated for day queue", "authorized post-merge assignment to a containing release or the next patch release", "issue tracked as daily release attention signal", "issue flagged as needing PR work for daily release"], - "negative_signals": ["readiness claim", "unverified automatic assignment outside an authorized workflow", "issue treated as release inclusion"], - "application_policy": "Authorized post-merge automation may add the earliest containing release label, or the next patch label when no release contains a PR merged to main. It must preserve existing version labels." + "positive_signals": ["maintainer selected PR for daily work", "open PR activated for day queue", "authorized post-merge assignment to the next untagged patch release", "issue tracked as daily release attention signal", "issue flagged as needing PR work for daily release"], + "negative_signals": ["readiness claim", "unverified automatic assignment outside an authorized workflow", "issue treated as release inclusion", "historical release attribution after a tag exists", "renamed or reused released label"], + "application_policy": "Authorized post-merge automation may add the next patch label only when a PR merge is ahead of the latest release tag. After the tag and workflow-managed latest are verified, authorized release housekeeping must carry open items forward and delete the released label." }, "agent_owned": { "item_kinds": ["issue", "pull_request"], @@ -632,7 +632,7 @@ "human_review_required_when_outside_authorization_context": true, "agent_owned_label_writes_allowed_when_authorized": true, "release_labels_on_issues_allowed": true, - "post_merge_release_labeling_allowed": true, + "post_merge_untagged_release_labeling_allowed": true, "release_label_is_readiness_claim": false } } diff --git a/.agents/skills/nemoclaw-maintainer-policies/references/label-taxonomy.md b/.agents/skills/nemoclaw-maintainer-policies/references/label-taxonomy.md index 548868e7057..c19603583fa 100644 --- a/.agents/skills/nemoclaw-maintainer-policies/references/label-taxonomy.md +++ b/.agents/skills/nemoclaw-maintainer-policies/references/label-taxonomy.md @@ -191,7 +191,7 @@ Do not combine: ### Release Train -Daily `v0.0.x` labels activate open PRs for daily release work. After a PR merges to `main`, authorized post-merge automation adds its earliest containing release label, or the next patch label after the highest strict-ancestor release when no tag contains it yet. This gives every landed PR release attribution, is additive, and does not remove earlier version labels. Issues may use a daily label as a tracking or attention signal, but issue labels do not determine release inclusion. See `release-train.md`. +Daily `v0.0.x` labels activate open PRs for daily release work. After a PR merges to `main`, authorized post-merge automation adds the next patch label only while the merge is ahead of the latest release tag. After that tag and workflow-managed `latest` are verified, release housekeeping moves open stragglers and deletes the released label. Tags and commit ancestry own durable release membership. Issues may use a daily label as a tracking or attention signal, but issue labels do not determine release inclusion. Released labels must never be renamed or reused. See `release-train.md`. ### Agent-Owned diff --git a/.agents/skills/nemoclaw-maintainer-policies/references/project-workflow.md b/.agents/skills/nemoclaw-maintainer-policies/references/project-workflow.md index dd40614d963..d29be51f9bc 100644 --- a/.agents/skills/nemoclaw-maintainer-policies/references/project-workflow.md +++ b/.agents/skills/nemoclaw-maintainer-policies/references/project-workflow.md @@ -66,10 +66,11 @@ Standup converts the recommendation into assignments. Every recommended item sho Daily `v0.0.x` labels have different meanings by item kind and PR state: - On open PRs, the label activates the PR for daily release work. Open labeled PRs that merge by cutoff are candidates for that release. -- After a PR merges to `main`, authorized automation adds the earliest containing release label, or the next patch label when no release tag contains the merge yet. This is historical release attribution, not evidence that the PR was activated or ready before merge. +- After a PR merges to `main`, authorized automation adds the next patch label only when the merge is ahead of the latest release tag. This keeps the untagged release queue complete before cutoff. +- After the release tag and workflow-managed `latest` are verified, the released label is deleted. The tag comparison range owns durable release membership. - On issues, the label is an attention, regression-tracking, or "needs PR for this daily release" signal. It does not include the issue in the release by itself. -Open labeled PRs and issues that miss a tagged release are automatically moved to the next patch label during post-tag housekeeping. Merged PR labels remain as additive release attribution. Remove a version label without replacement only when an open item is deferred, superseded, closed, or no longer part of the daily release cycle. +Open labeled PRs and issues that miss a tagged release are automatically moved to the next patch label during post-tag housekeeping. After no open item remains, housekeeping deletes the released label from the repository. Remove a version label without replacement only when an open item is deferred, superseded, closed, or no longer part of the daily release cycle. Never rename or reuse a released label. ## Issue Templates diff --git a/.agents/skills/nemoclaw-maintainer-policies/references/release-train.md b/.agents/skills/nemoclaw-maintainer-policies/references/release-train.md index ab6bbe02282..20bfad0fa4d 100644 --- a/.agents/skills/nemoclaw-maintainer-policies/references/release-train.md +++ b/.agents/skills/nemoclaw-maintainer-policies/references/release-train.md @@ -9,16 +9,16 @@ Daily release labels coordinate release work. They do not classify issues and th - PRs own the release-inclusion meaning of daily version labels. - Engineers and agents may add the current `v0.0.x` label to open PRs to activate them for day work. -- After a PR merges to `main`, the trusted post-merge workflow records it automatically. If a release tag already contains the merge, the workflow uses the earliest containing release; otherwise it finds the highest strict-ancestor release tag and adds its next patch label. -- Post-merge assignment is additive and idempotent. It creates the next release label with the canonical metadata when needed and never removes an existing version label. -- A scheduled and manually dispatchable reconciliation pass repairs missed or failed merge events across the current train and completed releases tagged within the seven-day retention window. +- After a PR merges to `main`, the trusted post-merge workflow adds the next patch label only when the merge is ahead of the latest release tag. A merge already contained in a release tag receives no release label. +- A scheduled and manually dispatchable reconciliation pass repairs missed or failed merge events only across the untagged interval from the latest release tag to `main`. +- Post-merge assignment and tag-triggered label retirement share one queued GitHub Actions concurrency group. Authorized automation cannot add a released label during the retirement verification-and-delete window. - Issues may also carry daily version labels when they need a PR, fix, or regression follow-up for the daily tag. - Applying a daily version label is not a readiness claim. - Release includes PRs that both carry the daily version label and are merged by cutoff. - Issue version labels are tracking signals only; an issue label does not include work in the release without a merged labeled PR. - Open PRs and issues that miss a tagged release carry forward automatically by moving from the released version label to the next patch label. -- An open PR or issue leaves the daily release cycle only when its version label is removed without a replacement. Merged PR labels record release attribution and remain subject to the history and pruning rules below. -- Version labels are pruned after seven days only after durable release history is preserved and no open PR still carries or depends on the old label. +- After the semver tag and workflow-managed `latest` are verified, post-tag housekeeping moves open stragglers and deletes the released version label. Tags and commit ancestry are the only durable release-membership record. +- Released version labels must be deleted, never renamed or reused for a later release. ## Release-Prep Docs @@ -42,7 +42,7 @@ At cutoff: 6. Generate the release plan to freeze the candidate commit. 7. Review the candidate commit's pre-tag E2E evidence. 8. Cut the release tag only with explicit maintainer confirmation. -9. After the tag and workflow-managed `latest` are verified, automatically move every open straggler to the next patch label. +9. After the tag and workflow-managed `latest` are verified, automatically move every open straggler to the next patch label, verify none remain, and delete the released version label. ## Pre-Tag E2E Evidence @@ -61,9 +61,9 @@ Every test must have either green evidence or an itemized maintainer exception b ## Carry Forward -Open PRs and issues that miss the cutoff remain active carry-forward work, but their target changes after the release succeeds. Post-tag housekeeping creates the next patch label if needed, removes the released-version label from every open straggler, and adds the next patch label. +Open PRs and issues that miss the cutoff remain active carry-forward work, but their target changes after the release succeeds. Post-tag housekeeping creates the next patch label if needed, removes the released-version label from every open straggler, adds the next patch label, verifies no open item remains on the released label, and deletes the released label. -Run the automatic bump only after both the semver tag and workflow-managed `latest` resolve to the confirmed release commit. The release confirmation must include the housekeeping plan, so the post-tag label writes remain inside the authorized release operation. +The `release-latest-tag` workflow runs automatic carry-forward after moving `latest`. It shares the release-label coordination queue with post-merge assignment and must complete before housekeeping is considered successful. The release confirmation must include the housekeeping plan, so the post-tag label writes remain inside the authorized release operation. Do not run the retirement script directly or manually add a label whose semver tag already exists. Maintainers may: @@ -71,13 +71,14 @@ Maintainers may: - Remove a version label without replacement when an item is deferred, superseded, closed, or no longer part of the daily cycle. - Rerun post-tag housekeeping after a partial failure; already-moved items no longer match the released source label, so the operation is safely resumable. -## Pruning +## Label Retirement -Old version labels may be deleted only when all conditions are true: +Release labels are temporary planning state. Retire one only when all conditions are true: -1. The label is older than seven days. -2. Durable release history has been preserved in tags, release notes, Agent Feed artifacts, or equivalent reports. -3. No open PR or issue still carries or depends on the old label after post-tag housekeeping, and the label is outside the post-merge reconciliation window. -4. The current authorization context explicitly allows label pruning. +1. The semver tag and workflow-managed `latest` both resolve to the confirmed release commit. +2. Every open PR and issue has moved to the next patch label or explicitly left the daily release cycle. +3. A final query finds no open item carrying the released label. +4. The release confirmation explicitly authorizes deletion of that released label. +5. Retirement runs inside the shared release-label coordination queue. -Pruning is a cleanup operation, not part of ordinary daily triage. +Delete the repository label after those checks. Deletion removes it from merged and closed items without preserving a second, mutable release-membership signal. Never rename a released label into a future version, and never recreate a label whose semver tag already exists. diff --git a/.agents/skills/nemoclaw-skills-guide/SKILL.md b/.agents/skills/nemoclaw-skills-guide/SKILL.md index 2dd39e9132b..fba92b18931 100644 --- a/.agents/skills/nemoclaw-skills-guide/SKILL.md +++ b/.agents/skills/nemoclaw-skills-guide/SKILL.md @@ -53,8 +53,8 @@ documentation updates, and onboarding new messaging channels. | `nemoclaw-maintainer-policies` | Read-only maintainer workflow policy reference for Issue Type, labels, Project fields, daily release labels, the pre-tag changelog gate, triage, duplicates, blocked items, and workflow decisions. | | `nemoclaw-maintainer-cross-issue-sweep` | Scan open issues for adjacent fixes or contradiction risks when reviewing a PR. | | `nemoclaw-maintainer-day` | Daytime loop: pick the highest-value version-targeted item and execute the right workflow (merge gate, salvage, security sweep, test gaps, hotspot cooling, or sequencing). Designed for `/loop`. | -| `nemoclaw-maintainer-evening` | End-of-day handoff: require the pre-tag dated changelog PR, check version progress, identify stragglers, generate a QA handoff summary, cut the release tag, bump stragglers, and hand off the Announcement. | -| `nemoclaw-maintainer-cut-release-tag` | Verify the dated changelog entry, cut an annotated semver tag on a maintainer-confirmed `origin/main` commit, wait for workflow-managed `latest`, and bump remaining open items; `lkg` stays manual. | +| `nemoclaw-maintainer-evening` | End-of-day handoff: require the pre-tag dated changelog PR, check version progress, identify stragglers, generate a QA handoff summary, cut the release tag, carry stragglers forward, retire the released label, and hand off the Announcement. | +| `nemoclaw-maintainer-cut-release-tag` | Verify the dated changelog entry, cut an annotated semver tag on a maintainer-confirmed `origin/main` commit, wait for workflow-managed `latest`, carry remaining open items forward, and delete the released label; `lkg` stays manual. | | `nemoclaw-maintainer-release-notes` | Draft the post-tag Announcement from live tag/compare data, with the three-paragraph narrative, categorized change list, and external-only contributor thanks. | | `nemoclaw-maintainer-find-review-pr` | Find open security PRs with Urgent or High Project Priority, link each to its issue, detect duplicates, and present a review summary. | | `nemoclaw-maintainer-pr-comparator` | Compare competing PRs for the same issue and recommend which one to merge. | diff --git a/.github/workflows/label-merged-pr-release-target.yaml b/.github/workflows/label-merged-pr-release-target.yaml index 0720b9f95eb..2cf07d22c49 100644 --- a/.github/workflows/label-merged-pr-release-target.yaml +++ b/.github/workflows/label-merged-pr-release-target.yaml @@ -22,6 +22,12 @@ permissions: # targets a pull request; issues:write alone returns 403. pull-requests: write +# Serialize assignment with tag-triggered label retirement. queue:max keeps +# every merge event while the release workflow owns the same coordination lock. +concurrency: + group: release-target-label-operations + queue: max + jobs: label-release-target: if: ${{ github.event_name != 'pull_request_target' || github.event.pull_request.merged == true }} @@ -40,7 +46,6 @@ jobs: const RELEASE_LABEL_DESCRIPTION = 'Release target'; const RELEASE_TAG_PATTERN = /^v(\d+)\.(\d+)\.(\d+)$/; const SHA_PATTERN = /^[0-9a-f]{40}$/i; - const RECONCILIATION_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; const { owner, repo } = context.repo; const ensuredLabels = new Set(); @@ -136,10 +141,6 @@ jobs: throw new Error(`Release tag ${release.name} does not peel to a commit`); } release.commit = validateSha(releaseCommit, `commit for release tag ${release.name}`); - release.taggedAt = Date.parse(annotatedTag.data.tagger?.date ?? ''); - if (!Number.isFinite(release.taggedAt)) { - throw new Error(`Release tag ${release.name} has an invalid tagger date`); - } return release.commit; } @@ -158,34 +159,14 @@ jobs: } async function resolveTargetForMerge(mergeSha, releaseTags) { - let containingRelease; - for (const release of releaseTags) { - const releaseCommit = await peelReleaseTag(release); - const relation = await compareRelation(releaseCommit, mergeSha); - - if (relation === 'behind' || relation === 'identical') { - containingRelease = release; - continue; - } - if (containingRelease) { - return { - label: containingRelease.name, - boundary: `containing release ${containingRelease.name}`, - }; - } - return { - label: nextPatchLabel(release), - boundary: `release predecessor ${release.name}`, - }; - } - - if (containingRelease) { - return { - label: containingRelease.name, - boundary: `containing release ${containingRelease.name}`, - }; - } - throw new Error(`No release tag is linearly related to merge ${mergeSha}`); + const latestRelease = releaseTags[0]; + const latestCommit = await peelReleaseTag(latestRelease); + const relation = await compareRelation(latestCommit, mergeSha); + if (relation === 'behind' || relation === 'identical') return null; + return { + label: nextPatchLabel(latestRelease), + boundary: `release predecessor ${latestRelease.name}`, + }; } function releaseLabels(pullRequest) { @@ -307,91 +288,44 @@ jobs: return [...pullRequestsByNumber.values()]; } - async function applyInterval(interval, processed) { - const pullRequests = await collectIntervalPullRequests(interval); - for (const pullRequest of pullRequests) { - const key = `${interval.label}:${pullRequest.number}`; - if (processed.has(key)) continue; - processed.add(key); - await applyTarget(pullRequest, interval.label, interval.boundary); - } - } - async function refreshLatestRelease(expectedName, expectedCommit) { const releaseTags = await loadReleaseTags(); const latest = releaseTags[0]; const latestCommit = await peelReleaseTag(latest); return { changed: latest.name !== expectedName || latestCommit !== expectedCommit, - latest, - latestCommit, - releaseTags, }; } async function reconcileReleaseTargets(releaseTags, restartCount = 0) { const latestRelease = releaseTags[0]; const latestCommit = await peelReleaseTag(latestRelease); - const processed = new Set(); - const reconciliationCutoff = Date.now() - RECONCILIATION_WINDOW_MS; - - for (let index = 0; index < releaseTags.length - 1; index += 1) { - const newer = releaseTags[index]; - const older = releaseTags[index + 1]; - await peelReleaseTag(newer); - if (newer.taggedAt < reconciliationCutoff) break; - await applyInterval( - { - base: await peelReleaseTag(older), - head: await peelReleaseTag(newer), - label: newer.name, - boundary: `containing release ${newer.name}`, - }, - processed, - ); - } - - const refreshed = await refreshLatestRelease(latestRelease.name, latestCommit); - if (refreshed.changed) { - if (restartCount >= 2) { - throw new Error('Newest release tag kept changing during reconciliation'); - } - core.warning('Newest release tag changed; restarting reconciliation'); - return reconcileReleaseTargets(refreshed.releaseTags, restartCount + 1); - } - const main = await github.rest.repos.getBranch({ owner, repo, branch: 'main' }); const mainCommit = validateSha(main.data.commit.sha, 'main commit SHA'); const currentInterval = { - base: refreshed.latestCommit, + base: latestCommit, head: mainCommit, - label: nextPatchLabel(refreshed.latest), - boundary: `release predecessor ${refreshed.latest.name}`, + label: nextPatchLabel(latestRelease), + boundary: `release predecessor ${latestRelease.name}`, }; const currentPullRequests = await collectIntervalPullRequests(currentInterval); - const verified = await refreshLatestRelease( - refreshed.latest.name, - refreshed.latestCommit, - ); + const verified = await refreshLatestRelease(latestRelease.name, latestCommit); if (verified.changed) { if (restartCount >= 2) { throw new Error('Newest release tag kept changing during reconciliation'); } core.warning('Newest release tag changed; restarting reconciliation'); - return reconcileReleaseTargets(verified.releaseTags, restartCount + 1); + return reconcileReleaseTargets(await loadReleaseTags(), restartCount + 1); } for (const pullRequest of currentPullRequests) { - const key = `${currentInterval.label}:${pullRequest.number}`; - if (processed.has(key)) continue; - processed.add(key); await applyTarget( pullRequest, currentInterval.label, currentInterval.boundary, ); } - core.info(`Reconciled ${processed.size} merged PR release target(s)`); + core.info(`Reconciled ${currentPullRequests.length} merged PR release target(s)`); } if (context.eventName === 'pull_request_target') { @@ -400,7 +334,13 @@ jobs: ); const releaseTags = await loadReleaseTags(); const target = await resolveTargetForMerge(mergeSha, releaseTags); - await applyTarget(pullRequest, target.label, target.boundary); + if (target) { + await applyTarget(pullRequest, target.label, target.boundary); + } else { + core.info( + `PR #${pullRequest.number} is already contained in ${releaseTags[0].name}; no release target label added`, + ); + } } else { const releaseTags = await loadReleaseTags(); await reconcileReleaseTargets(releaseTags); diff --git a/.github/workflows/release-latest-tag.yaml b/.github/workflows/release-latest-tag.yaml index 501930da84d..1207f59cd17 100644 --- a/.github/workflows/release-latest-tag.yaml +++ b/.github/workflows/release-latest-tag.yaml @@ -16,10 +16,14 @@ on: permissions: contents: write + issues: write + pull-requests: write +# Serialize tag-triggered retirement with every release-label assignment. +# queue:max keeps merge events instead of replacing a pending run. concurrency: - group: release-latest-tag - cancel-in-progress: false + group: release-target-label-operations + queue: max jobs: update-latest: @@ -86,3 +90,16 @@ jobs: 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 + + - name: Set up Node for release-label retirement + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.0.0 + with: + node-version: 22.19.0 + + - name: Retire the released target label + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + run: >- + node --experimental-strip-types --no-warnings scripts/retire-release-label.mts + "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" diff --git a/scripts/release-plan.mts b/scripts/release-plan.mts index 58ba4e7f987..4e62a3cfb19 100644 --- a/scripts/release-plan.mts +++ b/scripts/release-plan.mts @@ -193,6 +193,7 @@ function main(): void { const previousTag = semverTags[0]; const nextTag = bumpTag(previousTag, options.bump); + const followingTag = bumpTag(nextTag, "patch"); if (readRemoteTag(nextTag)) { throw new Error(`Remote tag already exists: ${nextTag}`); } @@ -223,6 +224,8 @@ function main(): void { `create signed annotated ${nextTag} tag at ${originMainCommit}`, `push ${nextTag}`, "wait for release-latest-tag workflow to move latest", + `have release-latest-tag workflow carry open ${nextTag} items forward to ${followingTag}`, + `have release-latest-tag workflow delete released ${nextTag} label after carry-forward succeeds`, "draft release notes from live compare data", ], forbiddenOperations: [ diff --git a/scripts/retire-release-label.mts b/scripts/retire-release-label.mts new file mode 100644 index 00000000000..a52865ccaa1 --- /dev/null +++ b/scripts/retire-release-label.mts @@ -0,0 +1,223 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Retire a released version label after carrying open work forward. + * + * Creates the next target label if needed, moves all open PRs and issues, + * verifies the released label has no open items, then deletes it. + * Run only inside the release-latest-tag workflow, which serializes this + * operation with every authorized release-label assignment. + * + * Usage: node --experimental-strip-types --no-warnings scripts/retire-release-label.mts [--repo OWNER/REPO] + */ + +import { execFileSync } from "node:child_process"; + +interface MovedItem { + number: number; + title: string; + type: "pr" | "issue"; +} + +interface RetireOutput { + from: string; + to: string; + moved: MovedItem[]; + retired: boolean; +} + +function main(): void { + const args = process.argv.slice(2); + const from = args[0]; + if (!from) { + console.error("Usage: retire-release-label.mts [--repo OWNER/REPO]"); + process.exit(1); + } + validateVersion(from, "released version"); + const to = nextPatch(from); + + const repo = parseRepository(args); + + const moved: MovedItem[] = []; + if (!releaseLabelExists(repo, from)) { + const output: RetireOutput = { from, to, moved, retired: true }; + console.log(JSON.stringify(output, null, 2)); + return; + } + if (remoteReleaseTagExists(repo, to)) { + throw new Error(`Refusing to use release target ${to}; the remote tag already exists`); + } + ensureReleaseLabel(repo, to); + + // Move open PRs + const prs = listOpenItems(repo, "pr", from); + for (const pr of prs) { + gh([ + "pr", + "edit", + String(pr.number), + "--repo", + repo, + "--remove-label", + from, + "--add-label", + to, + ]); + moved.push({ number: pr.number, title: pr.title, type: "pr" }); + } + + // Move open issues + const issues = listOpenItems(repo, "issue", from); + for (const issue of issues) { + gh([ + "issue", + "edit", + String(issue.number), + "--repo", + repo, + "--remove-label", + from, + "--add-label", + to, + ]); + moved.push({ number: issue.number, title: issue.title, type: "issue" }); + } + + const remaining = [ + ...listOpenItems(repo, "pr", from).map((item) => `PR #${item.number}`), + ...listOpenItems(repo, "issue", from).map((item) => `issue #${item.number}`), + ]; + if (remaining.length > 0) { + throw new Error( + `Refusing to delete ${from}; open items still carry it: ${remaining.join(", ")}`, + ); + } + + gh(["label", "delete", from, "--repo", repo, "--yes"]); + if (releaseLabelExists(repo, from)) { + throw new Error(`Released label ${from} still exists after deletion`); + } + + const output: RetireOutput = { from, to, moved, retired: true }; + console.log(JSON.stringify(output, null, 2)); +} + +function validateVersion(value: string, description: string): void { + if (!/^v\d+\.\d+\.\d+$/.test(value)) { + throw new Error(`Invalid ${description}: ${value}`); + } +} + +function nextPatch(version: string): string { + const parts = version.slice(1).split(".").map(Number); + if (!parts.every((part) => Number.isSafeInteger(part))) { + throw new Error(`Release version exceeds the supported numeric range: ${version}`); + } + const [major, minor, patch] = parts; + if (patch === Number.MAX_SAFE_INTEGER) { + throw new Error(`Cannot increment release version ${version} safely`); + } + return `v${major}.${minor}.${patch + 1}`; +} + +function parseRepository(args: string[]): string { + const index = args.indexOf("--repo"); + if (index === -1) return "NVIDIA/NemoClaw"; + const repo = args[index + 1]; + if (!repo || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) { + throw new Error(`Invalid --repo value: ${repo ?? ""}`); + } + return repo; +} + +function listOpenItems( + repo: string, + kind: "pr" | "issue", + label: string, +): Array<{ number: number; title: string }> { + return ghJsonArray<{ number: number; title: string }>([ + kind, + "list", + "--repo", + repo, + "--label", + label, + "--state", + "open", + "--json", + "number,title", + "--limit", + "1000", + ]); +} + +function ensureReleaseLabel(repo: string, label: string): void { + if (releaseLabelExists(repo, label)) return; + + gh([ + "label", + "create", + label, + "--repo", + repo, + "--description", + "Release target", + "--color", + "1d76db", + ]); +} + +function releaseLabelExists(repo: string, label: string): boolean { + const labels = ghJsonArray<{ name: string }>( + ["label", "list", "--repo", repo, "--search", label, "--json", "name", "--limit", "100"], + { emptyOutputIsEmptyArray: true }, + ); + return labels.some((entry) => entry.name === label); +} + +function remoteReleaseTagExists(repo: string, tag: string): boolean { + const refs = ghJsonArray<{ ref: string }>(["api", `repos/${repo}/git/matching-refs/tags/${tag}`]); + return refs.some((entry) => entry.ref === `refs/tags/${tag}`); +} + +function ghJsonArray( + args: string[], + { emptyOutputIsEmptyArray = false }: { emptyOutputIsEmptyArray?: boolean } = {}, +): T[] { + const output = gh(args); + if (output === "" && emptyOutputIsEmptyArray) return []; + try { + const parsed = JSON.parse(output) as unknown; + if (!Array.isArray(parsed)) { + throw new Error(`expected JSON array, got ${typeof parsed}`); + } + return parsed as T[]; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to parse gh JSON output for gh ${args.join(" ")}: ${reason}`); + } +} + +function gh(args: string[]): string { + try { + return execFileSync("gh", args, { + encoding: "utf-8", + maxBuffer: 10 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + } catch (error) { + const errorObject = typeof error === "object" && error !== null ? error : null; + const stdout = readStringProperty(errorObject, "stdout")?.trim(); + const stderr = readStringProperty(errorObject, "stderr")?.trim(); + throw new Error([`gh ${args.join(" ")} failed`, stdout, stderr].filter(Boolean).join("\n")); + } +} + +function readStringProperty(value: object | null, key: string): string | undefined { + if (!value || Array.isArray(value)) return undefined; + const property = Reflect.get(value, key); + return typeof property === "string" ? property : undefined; +} + +main(); diff --git a/test/bump-stragglers.test.ts b/test/bump-stragglers.test.ts deleted file mode 100644 index 40122bed3c4..00000000000 --- a/test/bump-stragglers.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { spawnSync } from "node:child_process"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { describe, expect, it } from "vitest"; - -function runBumpStragglers(fakeGh: string) { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "bump-stragglers-")); - const bin = path.join(tmp, "bin"); - fs.mkdirSync(bin); - const ghPath = path.join(bin, "gh"); - fs.writeFileSync(ghPath, fakeGh); - fs.chmodSync(ghPath, 0o755); - try { - return spawnSync( - process.execPath, - [ - "--experimental-strip-types", - "--no-warnings", - ".agents/skills/nemoclaw-maintainer-day/scripts/bump-stragglers.ts", - "v1.2.3", - "v1.2.4", - ], - { - cwd: process.cwd(), - encoding: "utf-8", - env: { ...process.env, PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}` }, - }, - ); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } -} - -describe("bump-stragglers release housekeeping", () => { - it("creates the next label and moves open PRs and issues", () => { - const result = runBumpStragglers(`#!/usr/bin/env bash -set -euo pipefail -case "$*" in - "label list"*) printf '[]' ;; - "label create v1.2.4"*) ;; - "pr list"*) printf '[{"number":42,"title":"needs more work"}]' ;; - "pr edit 42"*) ;; - "issue list"*) printf '[{"number":84,"title":"still open"}]' ;; - "issue edit 84"*) ;; - *) echo "unexpected gh args: $*" >&2; exit 9 ;; -esac -`); - - expect(result.status).toBe(0); - expect(JSON.parse(result.stdout)).toEqual({ - from: "v1.2.3", - to: "v1.2.4", - bumped: [ - { number: 42, title: "needs more work", type: "pr" }, - { number: 84, title: "still open", type: "issue" }, - ], - }); - }); - - it("creates the target label when a successful lookup returns no output", () => { - const result = runBumpStragglers(`#!/usr/bin/env bash -set -euo pipefail -case "$*" in - "label list"*) ;; - "label create v1.2.4"*) : > "$0.created" ;; - "pr list"*) test -f "$0.created"; printf '[]' ;; - "issue list"*) printf '[]' ;; - *) echo "unexpected gh args: $*" >&2; exit 9 ;; -esac -`); - - expect(result.status).toBe(0); - expect(JSON.parse(result.stdout)).toEqual({ from: "v1.2.3", to: "v1.2.4", bumped: [] }); - }); - - it("fails visibly when gh label lookup fails", () => { - const result = runBumpStragglers(`#!/usr/bin/env bash -set -euo pipefail -case "$*" in - "label list"*) echo 'auth failed' >&2; exit 4 ;; - *) echo "unexpected gh args: $*" >&2; exit 9 ;; -esac -`); - - expect(result.status).not.toBe(0); - expect(result.stderr).toContain("auth failed"); - expect(result.stdout).toBe(""); - }); - - it("fails visibly when gh returns non-array JSON", () => { - const result = runBumpStragglers(`#!/usr/bin/env bash -set -euo pipefail -case "$*" in - "label list"*) printf '{}' ;; - *) echo "unexpected gh args: $*" >&2; exit 9 ;; -esac -`); - - expect(result.status).not.toBe(0); - expect(result.stderr).toContain("expected JSON array"); - expect(result.stdout).toBe(""); - }); - - it("does not create the target label when it already exists", () => { - const result = runBumpStragglers(`#!/usr/bin/env bash -set -euo pipefail -case "$*" in - "label list"*) printf '[{"name":"v1.2.4"}]' ;; - "label create"*) echo 'label create should not run' >&2; exit 9 ;; - "pr list"*) printf '[]' ;; - "issue list"*) printf '[]' ;; - *) echo "unexpected gh args: $*" >&2; exit 9 ;; -esac -`); - - expect(result.status).toBe(0); - expect(JSON.parse(result.stdout)).toEqual({ from: "v1.2.3", to: "v1.2.4", bumped: [] }); - }); - - it("fails visibly when gh returns invalid JSON", () => { - const result = runBumpStragglers(`#!/usr/bin/env bash -set -euo pipefail -case "$*" in - "label list"*) printf '[{"name":"v1.2.4"}]' ;; - "pr list"*) printf 'not-json' ;; - *) echo "unexpected gh args: $*" >&2; exit 9 ;; -esac -`); - - expect(result.status).not.toBe(0); - expect(result.stderr).toContain("Failed to parse gh JSON output"); - expect(result.stdout).toBe(""); - }); - - it("fails visibly when a successful PR lookup returns no output", () => { - const result = runBumpStragglers(`#!/usr/bin/env bash -set -euo pipefail -case "$*" in - "label list"*) printf '[{"name":"v1.2.4"}]' ;; - "pr list"*) ;; - *) echo "unexpected gh args: $*" >&2; exit 9 ;; -esac -`); - - expect(result.status).not.toBe(0); - expect(result.stderr).toContain("Failed to parse gh JSON output for gh pr list"); - expect(result.stdout).toBe(""); - }); - - it("fails visibly when a successful issue lookup returns no output", () => { - const result = runBumpStragglers(`#!/usr/bin/env bash -set -euo pipefail -case "$*" in - "label list"*) printf '[{"name":"v1.2.4"}]' ;; - "pr list"*) printf '[]' ;; - "issue list"*) ;; - *) echo "unexpected gh args: $*" >&2; exit 9 ;; -esac -`); - - expect(result.status).not.toBe(0); - expect(result.stderr).toContain("Failed to parse gh JSON output for gh issue list"); - expect(result.stdout).toBe(""); - }); - - it("fails visibly when a GitHub PR edit command fails", () => { - const result = runBumpStragglers(`#!/usr/bin/env bash -set -euo pipefail -case "$*" in - "label list"*) printf '[{"name":"v1.2.4"}]' ;; - "pr list"*) printf '[{"number":42,"title":"needs more work"}]' ;; - "pr edit"*) echo 'auth failed' >&2; exit 7 ;; - *) echo "unexpected gh args: $*" >&2; exit 9 ;; -esac -`); - - expect(result.status).not.toBe(0); - expect(result.stderr).toContain("auth failed"); - expect(result.stdout).toBe(""); - }); - - it("fails visibly when a GitHub issue edit command fails", () => { - const result = runBumpStragglers(`#!/usr/bin/env bash -set -euo pipefail -case "$*" in - "label list"*) printf '[{"name":"v1.2.4"}]' ;; - "pr list"*) printf '[]' ;; - "issue list"*) printf '[{"number":84,"title":"still open"}]' ;; - "issue edit"*) echo 'permission denied' >&2; exit 7 ;; - *) echo "unexpected gh args: $*" >&2; exit 9 ;; -esac -`); - - expect(result.status).not.toBe(0); - expect(result.stderr).toContain("permission denied"); - expect(result.stdout).toBe(""); - }); -}); diff --git a/test/label-merged-pr-release-target-workflow.test.ts b/test/label-merged-pr-release-target-workflow.test.ts index fcfadb250f5..04061cecd72 100644 --- a/test/label-merged-pr-release-target-workflow.test.ts +++ b/test/label-merged-pr-release-target-workflow.test.ts @@ -12,6 +12,7 @@ const AsyncFunction = Object.getPrototypeOf(async () => undefined).constructor a ) => (...args: unknown[]) => Promise; type AutoLabelWorkflow = { + concurrency?: { group?: string; queue?: string }; on?: { pull_request_target?: { branches?: string[]; @@ -24,24 +25,35 @@ type AutoLabelWorkflow = { jobs: Record; }; +type ReleaseLatestWorkflow = { + concurrency?: { group?: string; queue?: string }; + permissions?: Record; + jobs: Record; +}; + type ComparisonStatus = "ahead" | "behind" | "diverged" | "identical"; type TagFixture = { name: string; refType?: "commit" | "tag"; peeledType?: "commit" | "tag"; - taggedAt?: string; status?: ComparisonStatus; aheadBy?: number; behindBy?: number; }; const WORKFLOW_PATH = ".github/workflows/label-merged-pr-release-target.yaml"; +const RELEASE_WORKFLOW_PATH = ".github/workflows/release-latest-tag.yaml"; const MERGE_SHA = "f".repeat(40); const workflow = readYaml(WORKFLOW_PATH); const job = workflow.jobs["label-release-target"]; const actionStep = job.steps?.find((step) => step.name === "Apply release target to merged PRs"); const script = actionStep?.with?.script; +const releaseWorkflow = readYaml(RELEASE_WORKFLOW_PATH); +const releaseJob = releaseWorkflow.jobs["update-latest"]; +const retirementStep = releaseJob.steps?.find( + (step) => step.name === "Retire the released target label", +); function sha(index: number): string { return index.toString(16).padStart(40, "0"); @@ -81,7 +93,6 @@ function createHarness(tags: TagFixture[], pullRequestLabels: string[] = []) { sha: fixture.commitSha, type: fixture.peeledType ?? "commit", }, - tagger: { date: fixture.taggedAt ?? new Date().toISOString() }, }, }; }); @@ -169,8 +180,12 @@ async function runScript(harness: ReturnType): Promise { - // source-shape-contract: security -- Pull-request-target labeling must never execute or checkout untrusted contributor code + // source-shape-contract: security -- Privileged label writes must stay metadata-only and serialize retirement with assignment it("keeps fork-safe labeling inside the trusted metadata boundary", () => { + const coordination = { + group: "release-target-label-operations", + queue: "max", + }; expect(workflow.on?.pull_request_target).toEqual({ branches: ["main"], types: ["closed"], @@ -181,6 +196,13 @@ describe("merged PR release target workflow", () => { issues: "write", "pull-requests": "write", }); + expect(workflow.concurrency).toEqual(coordination); + expect(releaseWorkflow.concurrency).toEqual(coordination); + expect(releaseWorkflow.permissions).toEqual({ + contents: "write", + issues: "write", + "pull-requests": "write", + }); expect(job.if).toBe( "${{ github.event_name != 'pull_request_target' || github.event.pull_request.merged == true }}", ); @@ -188,6 +210,20 @@ describe("merged PR release target workflow", () => { expect(job.steps).toHaveLength(1); expect(job.steps?.some((step) => step.uses?.startsWith("actions/checkout@"))).toBe(false); expect(job.steps?.some((step) => typeof step.run === "string")).toBe(false); + expect(script).not.toContain("containing release"); + expect(script).not.toContain("RECONCILIATION_WINDOW_MS"); + expect(retirementStep?.env).toMatchObject({ + GH_TOKEN: "${{ github.token }}", + }); + expect(retirementStep?.run).toContain("scripts/retire-release-label.mts"); + const latestIndex = releaseJob.steps?.findIndex( + (step) => step.name === "Move latest to the verified release tag object", + ); + const retirementIndex = releaseJob.steps?.findIndex( + (step) => step.name === "Retire the released target label", + ); + expect(latestIndex).toBeGreaterThanOrEqual(0); + expect(retirementIndex).toBeGreaterThan(latestIndex ?? -1); }); it.each([ @@ -251,7 +287,7 @@ describe("merged PR release target workflow", () => { }); }); - it("assigns a PR at a tag boundary to that release", async () => { + it("does not label a PR already captured at the latest tag boundary", async () => { const harness = createHarness([ { name: "v0.0.10", status: "identical" }, { name: "v0.0.9", status: "ahead" }, @@ -259,24 +295,14 @@ describe("merged PR release target workflow", () => { await runScript(harness); - expect(harness.compareCommitsWithBasehead).toHaveBeenCalledTimes(2); - expect(harness.addLabels).toHaveBeenCalledWith( - expect.objectContaining({ labels: ["v0.0.10"] }), + expect(harness.compareCommitsWithBasehead).toHaveBeenCalledTimes(1); + expect(harness.addLabels).not.toHaveBeenCalled(); + expect(harness.info).toHaveBeenCalledWith( + "PR #123 is already contained in v0.0.10; no release target label added", ); }); - it("assigns a non-patch release tag that contains the merge", async () => { - const harness = createHarness([ - { name: "v1.0.0", status: "identical" }, - { name: "v0.9.9", status: "ahead" }, - ]); - - await runScript(harness); - - expect(harness.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["v1.0.0"] })); - }); - - it("uses ancestry when a newer release tag appears after the merge", async () => { + it("does not recreate a label when the latest release contains the merge", async () => { const harness = createHarness([ { name: "v0.0.11", status: "behind" }, { name: "v0.0.10", status: "ahead" }, @@ -284,9 +310,10 @@ describe("merged PR release target workflow", () => { await runScript(harness); - expect(harness.compareCommitsWithBasehead).toHaveBeenCalledTimes(2); - expect(harness.addLabels).toHaveBeenCalledWith( - expect.objectContaining({ labels: ["v0.0.11"] }), + expect(harness.compareCommitsWithBasehead).toHaveBeenCalledTimes(1); + expect(harness.addLabels).not.toHaveBeenCalled(); + expect(harness.info).toHaveBeenCalledWith( + "PR #123 is already contained in v0.0.11; no release target label added", ); }); @@ -365,11 +392,10 @@ describe("merged PR release target workflow", () => { expect(harness.addLabels).not.toHaveBeenCalled(); }); - it("repairs missed labels across the current and latest completed releases", async () => { + it("repairs missed labels only in the current untagged interval", async () => { const harness = createHarness([{ name: "v0.0.10" }, { name: "v0.0.9" }]); const mainCommit = "e".repeat(40); - const completedReleaseCommit = "d".repeat(40); - const [latest, previous] = harness.fixtures; + const [latest] = harness.fixtures; harness.context.eventName = "schedule"; harness.getBranch.mockResolvedValueOnce({ data: { commit: { sha: mainCommit } } }); harness.compareCommitsWithBasehead.mockImplementation( @@ -385,16 +411,6 @@ describe("merged PR release target workflow", () => { commits: [{ sha: MERGE_SHA }], }, }; - case `${previous.commitSha}...${latest.commitSha}`: - return { - data: { - status: "ahead", - ahead_by: 1, - behind_by: 0, - total_commits: 1, - commits: [{ sha: completedReleaseCommit }], - }, - }; default: throw new Error(`Unexpected reconciliation comparison: ${basehead}`); } @@ -402,42 +418,33 @@ describe("merged PR release target workflow", () => { ); harness.listPullRequestsAssociatedWithCommit.mockImplementation( async ({ commit_sha: commitSha }: { commit_sha: string }) => ({ - data: [ + data: commitSha === MERGE_SHA - ? { - base: { ref: "main" }, - labels: [], - merge_commit_sha: MERGE_SHA, - merged_at: "2026-07-04T00:00:00Z", - number: 123, - } - : { - base: { ref: "main" }, - labels: [{ name: "v0.0.10" }], - merge_commit_sha: completedReleaseCommit, - merged_at: "2026-07-03T00:00:00Z", - number: 122, - }, - ], + ? [ + { + base: { ref: "main" }, + labels: [], + merge_commit_sha: MERGE_SHA, + merged_at: "2026-07-04T00:00:00Z", + number: 123, + }, + ] + : [], }), ); await runScript(harness); - expect(harness.listPullRequestsAssociatedWithCommit).toHaveBeenCalledTimes(2); + expect(harness.listPullRequestsAssociatedWithCommit).toHaveBeenCalledTimes(1); expect(harness.addLabels).toHaveBeenCalledTimes(1); expect(harness.addLabels).toHaveBeenCalledWith( expect.objectContaining({ issue_number: 123, labels: ["v0.0.11"] }), ); - expect(harness.info).toHaveBeenCalledWith("Reconciled 2 merged PR release target(s)"); + expect(harness.info).toHaveBeenCalledWith("Reconciled 1 merged PR release target(s)"); }); - it("does not recreate completed release labels outside the retention window", async () => { - const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(); - const harness = createHarness([ - { name: "v0.0.10", taggedAt: eightDaysAgo }, - { name: "v0.0.9", taggedAt: eightDaysAgo }, - ]); + it("never scans completed release intervals", async () => { + const harness = createHarness([{ name: "v0.0.10" }, { name: "v0.0.9" }]); const mainCommit = "e".repeat(40); const [latest] = harness.fixtures; harness.context.eventName = "schedule"; @@ -447,7 +454,7 @@ describe("merged PR release target workflow", () => { assert.equal( basehead, `${latest.commitSha}...${mainCommit}`, - `Unexpected expired release comparison: ${basehead}`, + `Unexpected release comparison: ${basehead}`, ); return { data: { @@ -464,7 +471,7 @@ describe("merged PR release target workflow", () => { await runScript(harness); expect(harness.compareCommitsWithBasehead).toHaveBeenCalledTimes(1); - expect(harness.getRef).toHaveBeenCalledTimes(3); + expect(harness.getRef).toHaveBeenCalledTimes(2); expect(harness.listPullRequestsAssociatedWithCommit).not.toHaveBeenCalled(); expect(harness.addLabels).not.toHaveBeenCalled(); }); @@ -493,8 +500,7 @@ describe("merged PR release target workflow", () => { commits: [{ sha: MERGE_SHA }], }, }; - case `${v10.commitSha}...${v11.commitSha}`: - case `${v09.commitSha}...${v10.commitSha}`: + case `${v10.commitSha}...${mainCommit}`: return { data: { status: "ahead", @@ -537,27 +543,25 @@ describe("merged PR release target workflow", () => { }); it("stops after two reconciliation restarts when release tags keep changing", async () => { - const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(); const harness = createHarness( - ["v0.0.13", "v0.0.12", "v0.0.11", "v0.0.10", "v0.0.9"].map((name) => ({ - name, - taggedAt: eightDaysAgo, - })), + ["v0.0.13", "v0.0.12", "v0.0.11", "v0.0.10", "v0.0.9"].map((name) => ({ name })), ); const [v13, v12, v11, v10, v09] = harness.fixtures; harness.context.eventName = "schedule"; harness.listTags .mockResolvedValueOnce({ data: [v10, v09] }) .mockResolvedValueOnce({ data: [v11, v10, v09] }) + .mockResolvedValueOnce({ data: [v11, v10, v09] }) + .mockResolvedValueOnce({ data: [v12, v11, v10, v09] }) .mockResolvedValueOnce({ data: [v12, v11, v10, v09] }) .mockResolvedValueOnce({ data: [v13, v12, v11, v10, v09] }); await expect(runScript(harness)).rejects.toThrow( "Newest release tag kept changing during reconciliation", ); - expect(harness.listTags).toHaveBeenCalledTimes(4); + expect(harness.listTags).toHaveBeenCalledTimes(6); expect(harness.warning).toHaveBeenCalledTimes(2); - expect(harness.getBranch).not.toHaveBeenCalled(); + expect(harness.getBranch).toHaveBeenCalledTimes(3); expect(harness.addLabels).not.toHaveBeenCalled(); }); }); diff --git a/test/maintainer-skills-policy.test.ts b/test/maintainer-skills-policy.test.ts index 85af47496bc..47b97b5b774 100644 --- a/test/maintainer-skills-policy.test.ts +++ b/test/maintainer-skills-policy.test.ts @@ -66,27 +66,33 @@ describe("maintainer skills follow canonical workflow policy", () => { expect(morning).toContain("review, CI, file, and risky-area data"); }); - it("moves post-tag stragglers to the next patch version", () => { + it("moves post-tag stragglers and retires the released label", () => { const evening = read(".agents/skills/nemoclaw-maintainer-evening/SKILL.md"); const release = read(".agents/skills/nemoclaw-maintainer-cut-release-tag/SKILL.md"); const morning = read(".agents/skills/nemoclaw-maintainer-morning/SKILL.md"); const priorities = read(".agents/skills/nemoclaw-maintainer-day/PR-REVIEW-PRIORITIES.md"); const policy = read(".agents/skills/nemoclaw-maintainer-policies/references/release-train.md"); - expect(evening).toContain("automatically bump stragglers to the next patch"); - expect(release).toContain("scripts/bump-stragglers.ts"); - expect(release).toContain("Do not run it before Step 4"); + expect(evening).toContain("automatically carry stragglers to the next patch"); + expect(evening).toContain("retire the released label"); + expect(release).toContain("release-latest-tag"); + expect(release).toContain("signed annotated semver tag"); + expect(release).toContain("GitHub-Verified"); + expect(release).toContain("same tag object"); + expect(release).toContain("Do not run the retirement script directly"); + expect(release).toContain('--event push --commit "$RELEASE_SHA"'); + expect(release).toContain("Expected exactly one release-latest-tag push run"); expect(morning).toContain("post-tag housekeeping was interrupted"); - expect(priorities).toContain("automatically bump stragglers to the next patch"); + expect(priorities).toContain("automatically carry stragglers to the next patch"); + expect(priorities).toContain("delete the released label"); expect(policy).toContain("automatically move every open straggler to the next patch label"); - expect( - fs.existsSync( - path.join(root, ".agents/skills/nemoclaw-maintainer-day/scripts/bump-stragglers.ts"), - ), - ).toBe(true); + expect(policy).toContain("delete the released version label"); + expect(policy).toContain("never renamed or reused"); + expect(policy).toContain("shared release-label coordination queue"); + expect(fs.existsSync(path.join(root, "scripts/retire-release-label.mts"))).toBe(true); }); - it("records every merged main PR against its ancestry-derived release target", () => { + it("keeps release labels temporary and limits post-merge assignment to untagged work", () => { const policy = read(".agents/skills/nemoclaw-maintainer-policies/references/release-train.md"); const projectWorkflow = read( ".agents/skills/nemoclaw-maintainer-policies/references/project-workflow.md", @@ -97,23 +103,25 @@ describe("maintainer skills follow canonical workflow policy", () => { label_families: { release: { application_policy: string; positive_signals: string[] }; }; - quality_rules: { post_merge_release_labeling_allowed: boolean }; + quality_rules: { post_merge_untagged_release_labeling_allowed: boolean }; }; expect(policy).toContain("After a PR merges to `main`"); - expect(policy).toContain("earliest containing release"); - expect(policy).toContain("completed releases tagged within the seven-day retention window"); - expect(policy).toContain("never removes an existing version label"); + expect(policy).toContain("ahead of the latest release tag"); + expect(policy).toContain("only across the untagged interval"); + expect(policy).toContain("Tags and commit ancestry are the only durable"); + expect(policy).not.toContain("earliest containing release"); + expect(policy).not.toContain("seven-day retention window"); expect(projectWorkflow).toContain("On open PRs"); expect(projectWorkflow).toContain("After a PR merges to `main`"); - expect(projectWorkflow).toContain("historical release attribution"); + expect(projectWorkflow).toContain("tag comparison range owns durable release membership"); expect(taxonomy.label_families.release.positive_signals).toContain( - "authorized post-merge assignment to a containing release or the next patch release", + "authorized post-merge assignment to the next untagged patch release", ); expect(taxonomy.label_families.release.application_policy).toContain( - "preserve existing version labels", + "carry open items forward and delete the released label", ); - expect(taxonomy.quality_rules.post_merge_release_labeling_allowed).toBe(true); + expect(taxonomy.quality_rules.post_merge_untagged_release_labeling_allowed).toBe(true); }); it("requires E2E evidence for the release candidate commit or itemized maintainer exceptions", () => { diff --git a/test/release-latest-tag.test.ts b/test/release-latest-tag.test.ts index 37304e11f10..85b8ac7e46e 100644 --- a/test/release-latest-tag.test.ts +++ b/test/release-latest-tag.test.ts @@ -221,6 +221,13 @@ function createPlan( expect(plan.nextTag).toBe("v0.0.2"); expect(plan.originMainCommit).toBe(releaseCommit); expect(plan.operations).toContain(`create signed annotated v0.0.2 tag at ${releaseCommit}`); + const carryForward = "have release-latest-tag workflow carry open v0.0.2 items forward to v0.0.3"; + const deleteReleased = + "have release-latest-tag workflow delete released v0.0.2 label after carry-forward succeeds"; + const carryForwardIndex = plan.operations.indexOf(carryForward); + const deleteReleasedIndex = plan.operations.indexOf(deleteReleased); + expect(carryForwardIndex).toBeGreaterThanOrEqual(0); + expect(deleteReleasedIndex).toBeGreaterThan(carryForwardIndex); expect(plan.confirmationPhrase).toBe(`CONFIRM RELEASE v0.0.2 ${releaseCommit}`); return { plan, result }; } diff --git a/test/retire-release-label.test.ts b/test/retire-release-label.test.ts new file mode 100644 index 00000000000..99442388392 --- /dev/null +++ b/test/retire-release-label.test.ts @@ -0,0 +1,198 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +function runRetireReleaseLabel(fakeGh: string, args = ["v1.2.3"]) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "retire-release-label-")); + const bin = path.join(tmp, "bin"); + fs.mkdirSync(bin); + const ghPath = path.join(bin, "gh"); + fs.writeFileSync(ghPath, fakeGh); + fs.chmodSync(ghPath, 0o755); + try { + return spawnSync( + process.execPath, + ["--experimental-strip-types", "--no-warnings", "scripts/retire-release-label.mts", ...args], + { + cwd: process.cwd(), + encoding: "utf-8", + env: { ...process.env, PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}` }, + }, + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + +describe("released label retirement", () => { + it("moves open work before deleting the released label", () => { + const result = runRetireReleaseLabel(`#!/usr/bin/env bash +set -euo pipefail +case "$*" in + "api repos/NVIDIA/NemoClaw/git/matching-refs/tags/v1.2.4") + printf '[{"ref":"refs/tags/v1.2.40"}]' ;; + "label list"*"--search v1.2.4"*) printf '[]' ;; + "label create v1.2.4"*) ;; + "label list"*"--search v1.2.3"*) + if test -f "$0.deleted"; then printf '[]'; else printf '[{"name":"v1.2.3"}]'; fi ;; + "pr list"*) + if test -f "$0.pr-moved"; then printf '[]'; else printf '[{"number":42,"title":"needs more work"}]'; fi ;; + "pr edit 42"*) : > "$0.pr-moved" ;; + "issue list"*) + if test -f "$0.issue-moved"; then printf '[]'; else printf '[{"number":84,"title":"still open"}]'; fi ;; + "issue edit 84"*) : > "$0.issue-moved" ;; + "label delete v1.2.3"*) + test -f "$0.pr-moved" + test -f "$0.issue-moved" + : > "$0.deleted" ;; + *) echo "unexpected gh args: $*" >&2; exit 9 ;; +esac +`); + + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + from: "v1.2.3", + to: "v1.2.4", + moved: [ + { number: 42, title: "needs more work", type: "pr" }, + { number: 84, title: "still open", type: "issue" }, + ], + retired: true, + }); + }); + + it("treats an already-absent released label as retired", () => { + const result = runRetireReleaseLabel(`#!/usr/bin/env bash +set -euo pipefail +case "$*" in + "label list"*"--search v1.2.3"*) printf '[]' ;; + "label create"*) echo 'label create must not run' >&2; exit 9 ;; + *) echo "unexpected gh args: $*" >&2; exit 9 ;; +esac +`); + + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + from: "v1.2.3", + to: "v1.2.4", + moved: [], + retired: true, + }); + }); + + it("refuses deletion while an open item still carries the released label", () => { + const result = runRetireReleaseLabel(`#!/usr/bin/env bash +set -euo pipefail +case "$*" in + "api repos/NVIDIA/NemoClaw/git/matching-refs/tags/v1.2.4") printf '[]' ;; + "label list"*"--search v1.2.4"*) printf '[{"name":"v1.2.4"}]' ;; + "label list"*"--search v1.2.3"*) printf '[{"name":"v1.2.3"}]' ;; + "pr list"*) printf '[{"number":42,"title":"still labeled"}]' ;; + "pr edit 42"*) ;; + "issue list"*) printf '[]' ;; + "label delete"*) echo 'delete must not run' >&2; exit 9 ;; + *) echo "unexpected gh args: $*" >&2; exit 9 ;; +esac +`); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("Refusing to delete v1.2.3"); + expect(result.stderr).toContain("PR #42"); + expect(result.stdout).toBe(""); + }); + + it("fails visibly when deleting the released label fails", () => { + const result = runRetireReleaseLabel(`#!/usr/bin/env bash +set -euo pipefail +case "$*" in + "api repos/NVIDIA/NemoClaw/git/matching-refs/tags/v1.2.4") printf '[]' ;; + "label list"*"--search v1.2.4"*) printf '[{"name":"v1.2.4"}]' ;; + "label list"*"--search v1.2.3"*) printf '[{"name":"v1.2.3"}]' ;; + "pr list"*|"issue list"*) printf '[]' ;; + "label delete v1.2.3"*) echo 'permission denied' >&2; exit 7 ;; + *) echo "unexpected gh args: $*" >&2; exit 9 ;; +esac +`); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("permission denied"); + expect(result.stdout).toBe(""); + }); + + it("fails when GitHub still returns the label after deletion", () => { + const result = runRetireReleaseLabel(`#!/usr/bin/env bash +set -euo pipefail +case "$*" in + "api repos/NVIDIA/NemoClaw/git/matching-refs/tags/v1.2.4") printf '[]' ;; + "label list"*"--search v1.2.4"*) printf '[{"name":"v1.2.4"}]' ;; + "label list"*"--search v1.2.3"*) printf '[{"name":"v1.2.3"}]' ;; + "pr list"*|"issue list"*) printf '[]' ;; + "label delete v1.2.3"*) ;; + *) echo "unexpected gh args: $*" >&2; exit 9 ;; +esac +`); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("still exists after deletion"); + expect(result.stdout).toBe(""); + }); + + it.each([ + [["1.2.3"], "Invalid released version"], + [["v1.2.3", "--repo", "invalid"], "Invalid --repo value"], + [[], "Usage: retire-release-label.mts"], + ])("rejects invalid arguments", (args, error) => { + const result = runRetireReleaseLabel("#!/usr/bin/env bash\nexit 9\n", args); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain(error); + }); + + it("rejects an unincrementable version before calling GitHub", () => { + const result = runRetireReleaseLabel( + "#!/usr/bin/env bash\necho 'gh must not run' >&2\nexit 9\n", + [`v1.2.${Number.MAX_SAFE_INTEGER}`], + ); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("Cannot increment release version"); + expect(result.stderr).not.toContain("gh must not run"); + }); + + it("refuses to carry work to a version whose remote tag already exists", () => { + const result = runRetireReleaseLabel(`#!/usr/bin/env bash +set -euo pipefail +case "$*" in + "label list"*"--search v1.2.3"*) printf '[{"name":"v1.2.3"}]' ;; + "api repos/NVIDIA/NemoClaw/git/matching-refs/tags/v1.2.4") + printf '[{"ref":"refs/tags/v1.2.4"}]' ;; + "label list"*"--search v1.2.4"*|"label create"*) + echo 'target label lookup or creation must not run' >&2; exit 9 ;; + *) echo "unexpected gh args: $*" >&2; exit 9 ;; +esac +`); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("Refusing to use release target v1.2.4"); + expect(result.stderr).not.toContain("target label lookup or creation must not run"); + expect(result.stdout).toBe(""); + }); + + it("fails visibly when a label lookup fails", () => { + const result = runRetireReleaseLabel(`#!/usr/bin/env bash +set -euo pipefail +echo 'auth failed' >&2 +exit 4 +`); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("auth failed"); + expect(result.stdout).toBe(""); + }); +});