From 9d8ea08a70c70420522a237854f98d8429d8a39b Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Tue, 27 Oct 2026 15:33:02 -0700 Subject: [PATCH 1/4] fix(ci): stop skipped release notes from skipping publication Stable releases that reused the prerelease notes preview skipped the Write Release Notes job. Every job that depends on Publish Release Assets used an implicit success() check, which treats a skipped job anywhere upstream as a failure. As a result, v0.1.6 never published its snap, TypeScript SDK, Go SDK tag, wheels, or Fern docs. Prereleases hit the same problem: Publish Prerelease Snap was always skipped. Move notes production out of release-publish.yml. release-notes.yml now chooses between reusing the preview and generating notes inside its steps, so the job always succeeds when notes exist. Release runs it as a sibling of Build, and Publish requires it. Publish Release Assets depends only on assembly and the Helm chart again. Signed-off-by: Matthew Grossman --- .agents/skills/watch-github-actions/SKILL.md | 6 +-- .github/workflows/release-notes.yml | 36 ++++++++++++++ .github/workflows/release-prerelease.yml | 2 +- .github/workflows/release-publish.yml | 24 +--------- .github/workflows/release.yml | 50 ++++++++------------ CI.md | 8 ++-- 6 files changed, 65 insertions(+), 61 deletions(-) diff --git a/.agents/skills/watch-github-actions/SKILL.md b/.agents/skills/watch-github-actions/SKILL.md index f80ef25b12..f6db118f3d 100644 --- a/.agents/skills/watch-github-actions/SKILL.md +++ b/.agents/skills/watch-github-actions/SKILL.md @@ -190,7 +190,7 @@ qualification; stable publication is blocked, while published pre-release artifacts remain available. The Pre-release failure notification includes the run link. Both callers share `release-build.yml` and `release-publish.yml`. Qualification evidence remains in Actions; publishing a copy to OCI storage is best effort. -Snap Store uploads happen only for stable releases after qualification passes. +Pre-release uploads snaps to `latest/beta`; stable releases upload to `latest/stable`. For a release notes preview, dispatch `release.yml` with `notes-only=true` and `tag=`. The generator resolves the previous reachable stable tag; @@ -207,8 +207,8 @@ and qualification, and keeps the notes only in the `release-notes` artifact. This job is advisory: a notes failure emits a warning without failing the prerelease run or blocking promotion. Stable publication reuses the promoted prerelease's `release-notes` artifact, retagged, and generates notes only when -that preview is unavailable. The `Select Qualified Prerelease` summary says -which happened. +that preview is unavailable. The Release workflow's `Release Notes` job summary +says which happened, and Publish waits for that job. For `Codex Compatibility Review`, inspect its job summary and the `compatibility-review-run--attempt-` artifact. Confirm the diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml index d4f00b26ea..db3e143414 100644 --- a/.github/workflows/release-notes.yml +++ b/.github/workflows/release-notes.yml @@ -17,16 +17,27 @@ on: description: Keep notes failures from failing the calling workflow default: false type: boolean + preview-run-id: + description: Optional Pre-release run whose release-notes preview is reused instead of generating notes + default: "" + type: string + preview-tag: + description: Prerelease tag in the reused preview, replaced with release-tag + default: "" + type: string secrets: LLM_INFERENCE_API_KEY: description: NVIDIA Inference Hub API key required: true permissions: + actions: read contents: read pull-requests: read jobs: + # Callers depend on this job, so choose reuse or generation with step + # conditions; a skipped job would skip every job that depends on it. release-notes: name: Write Release Notes runs-on: ubuntu-latest @@ -36,7 +47,30 @@ jobs: ANTHROPIC_BASE_URL: ${{ vars.ANTHROPIC_BASE_URL }} ANTHROPIC_MODEL: ${{ vars.ANTHROPIC_MODEL }} steps: + # Without a preview (missing or expired), the notes are generated instead. + - name: Reuse the prerelease notes preview + id: preview + if: ${{ inputs.preview-run-id != '' }} + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RUN_ID: ${{ inputs.preview-run-id }} + PREVIEW_TAG: ${{ inputs.preview-tag }} + RELEASE_TAG: ${{ inputs.release-tag }} + run: | + set -euo pipefail + : "${PREVIEW_TAG:?preview-tag is required with preview-run-id}" + if gh run download "${RUN_ID}" -R "${GITHUB_REPOSITORY}" -n release-notes -D preview; then + sed "s/${PREVIEW_TAG//./\\.}\b/${RELEASE_TAG}/g" preview/release-notes.md > release-notes.md + rm -rf preview + echo "reused=true" >> "${GITHUB_OUTPUT}" + echo "Release notes are reused from the \`${PREVIEW_TAG}\` preview." >> "${GITHUB_STEP_SUMMARY}" + else + echo "Release notes will be regenerated; the \`${PREVIEW_TAG}\` preview is unavailable." >> "${GITHUB_STEP_SUMMARY}" + fi + - name: Check out release source + if: ${{ steps.preview.outputs.reused != 'true' }} uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 @@ -45,6 +79,7 @@ jobs: - name: Prepare release notes id: comparison + if: ${{ steps.preview.outputs.reused != 'true' }} shell: bash env: RELEASE_TAG: ${{ inputs.release-tag }} @@ -63,6 +98,7 @@ jobs: echo "url=${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/${BASE_TAG}...${RELEASE_TAG}" >> "$GITHUB_OUTPUT" - name: Write notes with Claude + if: ${{ steps.preview.outputs.reused != 'true' }} uses: anthropics/claude-code-action@374f254f5efaf968abfe15d399fd90972e8138b9 # v1 env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/release-prerelease.yml b/.github/workflows/release-prerelease.yml index 3d312a88e0..2d54d02b23 100644 --- a/.github/workflows/release-prerelease.yml +++ b/.github/workflows/release-prerelease.yml @@ -99,6 +99,7 @@ jobs: name: Preview Release Notes needs: [resolve, build] permissions: + actions: read contents: read pull-requests: read uses: ./.github/workflows/release-notes.yml @@ -114,7 +115,6 @@ jobs: permissions: actions: read contents: write - pull-requests: read packages: write id-token: write attestations: write diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 2dc221851a..04202bed05 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -19,11 +19,6 @@ on: required: false default: false type: boolean - release-notes-reused: - description: Whether the calling run already uploaded release-notes from the promoted prerelease's preview - required: false - default: false - type: boolean source-sha: description: Source commit of the built artifacts in the calling run required: true @@ -49,9 +44,6 @@ on: required: false GITLAB_CI_TRIGGER_URL: required: false - LLM_INFERENCE_API_KEY: - description: NVIDIA Inference Hub API key for stable release notes - required: false permissions: contents: read @@ -332,22 +324,9 @@ jobs: app-version: ${{ inputs.semver }} release-kind: public - release-notes: - name: Write Release Notes - if: ${{ !inputs.is-prerelease && !inputs.release-notes-reused }} - permissions: - contents: read - pull-requests: read - uses: ./.github/workflows/release-notes.yml - with: - release-tag: ${{ inputs.release-tag }} - secrets: - LLM_INFERENCE_API_KEY: ${{ secrets.LLM_INFERENCE_API_KEY }} - release: name: Publish Release Assets - needs: [assemble, release-helm, release-notes] - if: ${{ !cancelled() && needs.assemble.result == 'success' && needs.release-helm.result == 'success' && (inputs.is-prerelease || inputs.release-notes-reused || needs.release-notes.result == 'success') }} + needs: [assemble, release-helm] runs-on: linux-amd64-cpu8 timeout-minutes: 10 permissions: @@ -393,6 +372,7 @@ jobs: } } + # Stable callers upload release-notes in the same run before calling Publish. - name: Download release notes if: ${{ !inputs.is-prerelease }} uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bf929fac02..6e702fc963 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,6 +29,7 @@ jobs: name: Preview Release Notes if: ${{ inputs.notes-only }} permissions: + actions: read contents: read pull-requests: read uses: ./.github/workflows/release-notes.yml @@ -49,7 +50,8 @@ jobs: tag: ${{ steps.candidate.outputs.tag }} source-sha: ${{ steps.candidate.outputs.source_sha }} make-latest: ${{ steps.candidate.outputs.make_latest }} - notes-reused: ${{ steps.notes.outputs.reused }} + candidate-tag: ${{ steps.candidate.outputs.candidate_tag }} + qualification-run-id: ${{ steps.candidate.outputs.qualification_run_id }} steps: - name: Require a release branch env: @@ -92,32 +94,6 @@ jobs: retention-days: 90 if-no-files-found: error - # Without a preview (missing or expired), Publish generates the notes instead. - - name: Reuse the prerelease notes preview - id: notes - env: - GH_TOKEN: ${{ github.token }} - RUN_ID: ${{ steps.candidate.outputs.qualification_run_id }} - CANDIDATE_TAG: ${{ steps.candidate.outputs.candidate_tag }} - RELEASE_TAG: ${{ steps.candidate.outputs.tag }} - run: | - set -euo pipefail - if gh run download "${RUN_ID}" -R "${GITHUB_REPOSITORY}" -n release-notes -D notes; then - sed -i "s/${CANDIDATE_TAG//./\\.}\b/${RELEASE_TAG}/g" notes/release-notes.md - echo "reused=true" >> "${GITHUB_OUTPUT}" - echo "Release notes are reused from the \`${CANDIDATE_TAG}\` preview." >> "${GITHUB_STEP_SUMMARY}" - else - echo "Release notes will be regenerated; the \`${CANDIDATE_TAG}\` preview is unavailable." >> "${GITHUB_STEP_SUMMARY}" - fi - - - name: Upload release notes - if: ${{ steps.notes.outputs.reused == 'true' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: release-notes - path: notes/release-notes.md - if-no-files-found: error - - name: Create stable tag at the qualified commit env: GH_TOKEN: ${{ github.token }} @@ -147,13 +123,27 @@ jobs: secrets: CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }} + release-notes: + name: Release Notes + needs: promote + permissions: + actions: read + contents: read + pull-requests: read + uses: ./.github/workflows/release-notes.yml + with: + release-tag: ${{ needs.promote.outputs.tag }} + preview-run-id: ${{ needs.promote.outputs.qualification-run-id }} + preview-tag: ${{ needs.promote.outputs.candidate-tag }} + secrets: + LLM_INFERENCE_API_KEY: ${{ secrets.LLM_INFERENCE_API_KEY }} + publish: name: Publish - needs: [promote, build] + needs: [promote, build, release-notes] permissions: actions: read contents: write - pull-requests: read packages: write id-token: write attestations: write @@ -167,10 +157,8 @@ jobs: npm-version: ${{ needs.build.outputs.npm_version }} is-prerelease: false make-latest: ${{ needs.promote.outputs.make-latest == 'true' }} - release-notes-reused: ${{ needs.promote.outputs.notes-reused == 'true' }} secrets: SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} FERN_TOKEN: ${{ secrets.FERN_TOKEN }} GITLAB_CI_TRIGGER_TOKEN: ${{ secrets.GITLAB_CI_TRIGGER_TOKEN }} GITLAB_CI_TRIGGER_URL: ${{ secrets.GITLAB_CI_TRIGGER_URL }} - LLM_INFERENCE_API_KEY: ${{ secrets.LLM_INFERENCE_API_KEY }} diff --git a/CI.md b/CI.md index cf251e0bb9..c7ed827aad 100644 --- a/CI.md +++ b/CI.md @@ -382,9 +382,9 @@ reruns without overwriting earlier evidence. summarize PRs since the previous stable tag. Prereleases retain a `release-notes` artifact, with advisory failures. Stable releases publish the promoted prerelease's preview with the prerelease tag replaced by the stable tag. If that -preview is missing or expired, stable publication generates new notes and -requires generation to succeed. The promotion summary states whether notes were -reused or regenerated. Configure the `LLM_INFERENCE_API_KEY` Actions secret and +preview is missing or expired, the Release workflow's notes job generates new +notes and requires generation to succeed before publishing. That job's summary +states whether notes were reused or regenerated. Configure the `LLM_INFERENCE_API_KEY` Actions secret and the `ANTHROPIC_BASE_URL` and `ANTHROPIC_MODEL` Actions variables. To preview notes without building or publishing: @@ -715,7 +715,7 @@ These workflows run after merge to publish dev/tagged artifacts and verify them. | `.github/workflows/release-prerelease.yml` | Pre-release orchestrator, triggered by prerelease tag pushes or dispatched by Release Auto-Tag or manually. A tagless dispatch creates the next candidate on its branch; explicit tags select existing candidates. Builds, then publishes and qualifies in parallel. Qualification failure fails the run and sends the failure notification; published artifacts remain available. | | `.github/workflows/release-build.yml` | Shared version computation, binary and image builds, and package builds. Validates that the tag matches the caller's release kind. | | `.github/workflows/release-publish.yml` | Shared artifact assembly, OCI image tags and Helm chart publication, and package publication. Stable releases also publish to GitHub Releases, snap, npm, PyPI, and Fern. | -| `.github/workflows/release-notes.yml` | Shared prompt-driven release notes generation through Inference Hub, used by prerelease previews, notes-only previews, and stable publication when no preview is reused. | +| `.github/workflows/release-notes.yml` | Shared prompt-driven release notes generation through Inference Hub, used by prerelease previews, notes-only previews, and stable releases. Stable releases reuse the promoted prerelease's preview when available. | | `.github/workflows/release-qualification.yml` | Reusable qualification suites and result aggregation. Runs security scans, conformance and feature integration, Docker and VM E2E, protobuf compatibility, and advisory compatibility review. | | `.github/workflows/snap-package.yml` | Builds snap and component artifacts for Release Dev, Release, and Pre-release without Store credentials or publication. | | `.github/workflows/snap-publish.yml` | Uploads existing snap and component artifacts directly to a Store channel without rebuilding. Release Dev uses `latest/edge`, Pre-release uses `latest/beta`, and Release uses `latest/stable` for the stable rebuild. Uploads queue per channel and architecture, so tagged beta and stable jobs recheck repository tags after earlier publication finishes and before updating their floating channels. Manual dispatch uploads the `snap-linux-*` artifacts from `artifact-run-id`. | From f2d3184cc21ba9214d663bab3e115b9dbd2645ae Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Tue, 27 Oct 2026 16:52:05 -0700 Subject: [PATCH 2/4] fix(ci): make stable release publication rerunnable Re-dispatching Release for an existing stable tag rebuilt the artifacts, overwrote the published GHCR version tags, and then failed at the Helm duplicate check before reaching the package publishers. Each publisher now skips what is already published: - GHCR keeps existing version tags and still verifies their SBOMs. - Helm skips packaging and pushing when both charts exist, and still fails when only one exists. The Helm action is loaded from the workflow revision, so retrying an older tag uses the current duplicate handling. - The GitHub Release uploads only missing assets. - npm and the PyPI wheel trigger skip versions that already exist, whether called by Release or dispatched by hand. test:release-workflows checks that publish logic is loaded from the workflow revision and that no needed publish job can be skipped on its own. Signed-off-by: Matthew Grossman --- .agents/skills/release-captain/SKILL.md | 2 +- .agents/skills/release-openshell/SKILL.md | 4 +- .agents/skills/test-release-canary/SKILL.md | 2 +- .github/actions/release-helm-oci/action.yml | 22 +++- .github/workflows/branch-checks.yml | 3 + .github/workflows/release-publish.yml | 30 ++++- .github/workflows/sdk-typescript-publish.yml | 5 + .github/workflows/wheel-publish.yml | 4 + CI.md | 4 +- .../scripts/release_publish_workflow_test.py | 123 ++++++++++++++++++ tasks/test.toml | 6 + 11 files changed, 190 insertions(+), 15 deletions(-) create mode 100644 tasks/scripts/release_publish_workflow_test.py diff --git a/.agents/skills/release-captain/SKILL.md b/.agents/skills/release-captain/SKILL.md index 90ddc1286f..f1d83f818c 100644 --- a/.agents/skills/release-captain/SKILL.md +++ b/.agents/skills/release-captain/SKILL.md @@ -234,7 +234,7 @@ git worktree remove --force "$wt" | Transient infrastructure failure in a pre-release, including a registry push | After approval, `gh run rerun --failed`. `Release` won't select the run until all of it passes. | | Pre-release run ends in `startup_failure` | The workflow file is invalid at that commit. Fix it in a new `-pre.N` (Monday step 3). | | Code defect or `Cargo Deny` advisory | Rerunning won't help. Fix it in a new `-pre.N` (Monday step 3). | -| `Release` fails after selection | After approval, rerun only the failed jobs that haven't published. | +| `Release` fails after selection | After approval, rerun only the failed jobs that haven't published. If the run's artifacts expired or its jobs were skipped, dispatch `Release` again with `-f tag=vX.Y.Z`; it publishes only what is missing. | ## Related diff --git a/.agents/skills/release-openshell/SKILL.md b/.agents/skills/release-openshell/SKILL.md index f5f382e4ea..470d91467e 100644 --- a/.agents/skills/release-openshell/SKILL.md +++ b/.agents/skills/release-openshell/SKILL.md @@ -32,7 +32,9 @@ passed but publication failed, rerun the failed jobs before promoting the candid Release creates `vX.Y.Z` at that exact commit, then rebuilds and publishes all stable artifacts without rerunning qualification. Embedded versions change, so stable artifacts are not byte-identical to the qualified prerelease. An existing -stable tag may be retried only if it already points to the selected commit. +stable tag may be retried only if it already points to the selected commit. A retry +publishes only missing artifacts; published images, charts, release assets, npm, +and PyPI versions are kept, while the snap is uploaded again. Stable images are saved as OCI archives during the build and pushed under the stable version tags during publication. Their SBOMs are verified before updating `latest`; the qualified prerelease's commit-SHA image tags remain unchanged. diff --git a/.agents/skills/test-release-canary/SKILL.md b/.agents/skills/test-release-canary/SKILL.md index b707920b7b..7be722c3f8 100644 --- a/.agents/skills/test-release-canary/SKILL.md +++ b/.agents/skills/test-release-canary/SKILL.md @@ -95,7 +95,7 @@ Note `install.sh` is pulled from `raw.githubusercontent.com/NVIDIA/OpenShell/${h ## Testing artifacts from a specific SHA -`Release Dev` publishes two chart versions for every dev build (see `.github/actions/release-helm-oci/action.yml:89-102`): +`Release Dev` publishes two chart versions for every dev build (see the push steps in `.github/actions/release-helm-oci/action.yml`): - `oci://ghcr.io/nvidia/openshell/helm-chart:0.0.0-dev` — floating, overwritten on every main push. - `oci://ghcr.io/nvidia/openshell/helm-chart:0.0.0-dev.` — immutable, `appVersion` set to the same SHA so it pulls the matching `gateway`, `sandbox`, and `supervisor` images. diff --git a/.github/actions/release-helm-oci/action.yml b/.github/actions/release-helm-oci/action.yml index a296c9e4ba..0023bb92cf 100644 --- a/.github/actions/release-helm-oci/action.yml +++ b/.github/actions/release-helm-oci/action.yml @@ -3,8 +3,8 @@ name: Release Helm OCI description: > - Patch chart version/appVersion, refuse duplicate OCI versions on public - releases, package the gateway and workspace charts, and push them to GHCR OCI. + Patch chart version/appVersion, package the gateway and workspace charts, and + push them to GHCR OCI. Public releases never overwrite published versions. inputs: chart-version: @@ -63,22 +63,33 @@ runs: echo "workspace_chart_dir=${WORKSPACE_CHART_DIR}" >> "$GITHUB_OUTPUT" echo "chart_version=${CHART_VERSION}" >> "$GITHUB_OUTPUT" - - name: Refuse duplicate chart version + # Public chart versions are never overwritten. A rerun keeps both published + # charts; a partial publication still fails for manual repair. + - name: Check for published chart version + id: published if: inputs.release-kind == 'public' env: CHART_VERSION: ${{ steps.prep.outputs.chart_version }} shell: bash run: | set -euo pipefail + found=0 for chart in helm-chart openshell-workspace; do OCI_CHART="oci://ghcr.io/nvidia/openshell/${chart}" if helm show chart "${OCI_CHART}" --version "${CHART_VERSION}" >/dev/null 2>&1; then - echo "::error::Chart ${chart}:${CHART_VERSION} is already published. Use a new tag or delete the existing package first." - exit 1 + found=$((found + 1)) fi done + if [ "${found}" -eq 2 ]; then + echo "Charts ${CHART_VERSION} are already published; keeping them." + echo "published=true" >> "$GITHUB_OUTPUT" + elif [ "${found}" -ne 0 ]; then + echo "::error::Only one chart is published at ${CHART_VERSION}. Delete it or publish the other chart manually." + exit 1 + fi - name: Package Helm chart + if: steps.published.outputs.published != 'true' env: GATEWAY_CHART_DIR: ${{ steps.prep.outputs.gateway_chart_dir }} WORKSPACE_CHART_DIR: ${{ steps.prep.outputs.workspace_chart_dir }} @@ -91,6 +102,7 @@ runs: ls /tmp/helm-charts/*.tgz - name: Push Helm chart to GHCR OCI + if: steps.published.outputs.published != 'true' shell: bash run: | set -euo pipefail diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index 218d3d2657..ae9e5ac071 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -540,6 +540,9 @@ jobs: - name: Test release qualification summary run: mise run test:qualification-summary + + - name: Test release workflows + run: mise run test:release-workflows go: name: Go SDK needs: pr_metadata diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 04202bed05..86426b4383 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -256,11 +256,18 @@ jobs: set -euo pipefail for component in gateway sandbox supervisor; do image="ghcr.io/nvidia/openshell/${component}:${VERSION}" - # Archives are build artifacts from this run, not externally signed images. - nix shell --inputs-from . nixpkgs#skopeo -c \ - skopeo --insecure-policy copy --all --preserve-digests \ - --authfile "${HOME}/.docker/config.json" \ - "oci-archive:artifacts/images/${component}.tar" "docker://${image}" + # Never replace a published stable image; a rerun keeps the original. + if nix shell --inputs-from . nixpkgs#skopeo -c \ + skopeo inspect --raw --authfile "${HOME}/.docker/config.json" \ + "docker://${image}" >/dev/null 2>&1; then + echo "${image} is already published; keeping it." + else + # Archives are build artifacts from this run, not externally signed images. + nix shell --inputs-from . nixpkgs#skopeo -c \ + skopeo --insecure-policy copy --all --preserve-digests \ + --authfile "${HOME}/.docker/config.json" \ + "oci-archive:artifacts/images/${component}.tar" "docker://${image}" + fi tasks/scripts/verify-image-sbom.sh "${image}" --require-cargo done @@ -318,7 +325,16 @@ jobs: persist-credentials: false ref: ${{ inputs.source-sha }} - - uses: ./.github/actions/release-helm-oci + # Charts come from the release source; the publish logic comes from this + # workflow so retrying an older tag uses the current duplicate handling. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.workflow_sha }} + path: release-workflow + sparse-checkout: .github/actions/release-helm-oci + + - uses: ./release-workflow/.github/actions/release-helm-oci with: chart-version: ${{ inputs.semver }} app-version: ${{ inputs.semver }} @@ -396,6 +412,8 @@ jobs: make_latest: ${{ steps.latest.outputs.make-latest == 'true' && 'true' || 'false' }} tag_name: ${{ env.RELEASE_TAG }} body_path: release/release-notes.md + # A rerun uploads only missing assets and keeps published ones. + overwrite_files: false files: | release/openshell-x86_64-unknown-linux-musl.tar.gz release/openshell-aarch64-unknown-linux-musl.tar.gz diff --git a/.github/workflows/sdk-typescript-publish.yml b/.github/workflows/sdk-typescript-publish.yml index a10ff0d289..1f7904d48f 100644 --- a/.github/workflows/sdk-typescript-publish.yml +++ b/.github/workflows/sdk-typescript-publish.yml @@ -95,6 +95,11 @@ jobs: run: | set -euo pipefail npm pkg set version="${OPENSHELL_NPM_VERSION}" + package="$(npm pkg get name | tr -d '"')@${OPENSHELL_NPM_VERSION}" + if npm view "${package}" version >/dev/null 2>&1; then + echo "${package} is already published." + exit 0 + fi dist_tag=latest if [[ "${MAKE_LATEST}" != "true" ]]; then dist_tag="release-${OPENSHELL_NPM_VERSION%.*}" diff --git a/.github/workflows/wheel-publish.yml b/.github/workflows/wheel-publish.yml index 451c6ed46b..1456b70b4b 100644 --- a/.github/workflows/wheel-publish.yml +++ b/.github/workflows/wheel-publish.yml @@ -100,6 +100,10 @@ jobs: echo "No wheel filenames provided by build job" >&2 exit 1 fi + if curl --fail --silent --output /dev/null "https://pypi.org/pypi/openshell/${RELEASE_VERSION}/json"; then + echo "openshell ${RELEASE_VERSION} is already on PyPI." + exit 0 + fi response=$(curl -X POST \ --fail \ diff --git a/CI.md b/CI.md index c7ed827aad..312546a119 100644 --- a/CI.md +++ b/CI.md @@ -446,7 +446,9 @@ same run; the promotion record links to the attempt that produced that evidence. Release rebuilds all stable artifacts with the stable version and publishes without rerunning qualification. These artifacts have different embedded versions from the qualified prerelease. Existing stable tags are never moved; a retry must -resolve to the same qualified candidate. +resolve to the same qualified candidate. A retry rebuilds, then publishes only what +is missing: it keeps published GHCR version tags, Helm charts, GitHub Release assets, +npm versions, and PyPI versions. Snap uploads are not skipped. Stable image builds save multi-platform OCI archives as workflow artifacts without pushing to GHCR. Publication copies those archives, including their attestations, diff --git a/tasks/scripts/release_publish_workflow_test.py b/tasks/scripts/release_publish_workflow_test.py new file mode 100644 index 0000000000..400a50cf60 --- /dev/null +++ b/tasks/scripts/release_publish_workflow_test.py @@ -0,0 +1,123 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Static checks that stable publication can be retried for an existing tag. + +Run via `mise run test:release-workflows`. A retry dispatches the current +workflow against an older release commit, so publish logic must not be loaded +from that commit's checkout. +""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOW_SHA = "${{ github.workflow_sha }}" +SOURCE_SHA = "${{ inputs.source-sha }}" + +# Environment setup only; it does not decide what gets published. +SOURCE_ACTIONS = {"./.github/actions/setup-nix"} + + +def load(path: str) -> dict: + return yaml.load((ROOT / path).read_text(encoding="utf-8"), Loader=yaml.BaseLoader) + + +PUBLISH_WORKFLOWS = ( + ".github/workflows/release-publish.yml", + ".github/workflows/sdk-go-tag.yml", + ".github/workflows/sdk-typescript-publish.yml", + ".github/workflows/snap-publish.yml", + ".github/workflows/wheel-publish.yml", +) + + +def publish_jobs() -> dict: + return load(".github/workflows/release-publish.yml")["jobs"] + + +def all_publish_jobs() -> dict: + return { + f"{path}:{name}": job + for path in PUBLISH_WORKFLOWS + for name, job in load(path)["jobs"].items() + } + + +def test_publish_logic_is_loaded_from_the_workflow_revision() -> None: + for name, job in all_publish_jobs().items(): + for step in job.get("steps", []): + uses = step.get("uses", "") + if uses.startswith("./.github/actions/"): + assert uses in SOURCE_ACTIONS, ( + f"{name} loads {uses} from the release source checkout; " + "check it out from github.workflow_sha into release-workflow " + "so retries of older tags use the current publish logic" + ) + + +def test_release_workflow_checkouts_use_the_workflow_revision() -> None: + for name, job in all_publish_jobs().items(): + steps = job.get("steps", []) + loaded = { + step["uses"].removeprefix("./release-workflow/") + for step in steps + if step.get("uses", "").startswith("./release-workflow/") + } + if not loaded: + continue + checkouts = [ + step + for step in steps + if step.get("with", {}).get("path") == "release-workflow" + ] + assert len(checkouts) == 1, name + checkout = checkouts[0]["with"] + assert checkout["ref"] == WORKFLOW_SHA, name + sparse = set(checkout.get("sparse-checkout", "").split()) + assert loaded <= sparse, f"{name} needs {loaded - sparse} in sparse-checkout" + + +def test_helm_charts_come_from_the_release_source() -> None: + steps = publish_jobs()["release-helm"]["steps"] + source = [ + step + for step in steps + if step.get("with", {}).get("ref") == SOURCE_SHA + and "path" not in step.get("with", {}) + ] + assert len(source) == 1 + assert steps.index(source[0]) < next( + i + for i, step in enumerate(steps) + if step.get("uses") == "./release-workflow/.github/actions/release-helm-oci" + ) + + +def test_helm_action_keeps_published_public_charts() -> None: + steps = load(".github/actions/release-helm-oci/action.yml")["runs"]["steps"] + check = next(step for step in steps if step.get("id") == "published") + assert check["if"] == "inputs.release-kind == 'public'" + assert "Refuse duplicate" not in str(steps) + skipped = "steps.published.outputs.published != 'true'" + for name in ("Package Helm chart", "Push Helm chart to GHCR OCI"): + step = next(step for step in steps if step.get("name") == name) + assert step["if"] == skipped, name + + +def test_needed_jobs_skip_only_when_their_dependents_skip() -> None: + # A skipped job fails the implicit success() check of every job after it, + # so a needed job may only carry the same condition as each dependent. + jobs = publish_jobs() + for name, job in jobs.items(): + needs = job.get("needs", []) + for need in [needs] if isinstance(needs, str) else needs: + condition = jobs[need].get("if") + if condition is not None: + assert job.get("if") == condition, ( + f"{name} needs {need}, which can be skipped by {condition!r}; " + "decide inside its steps instead" + ) diff --git a/tasks/test.toml b/tasks/test.toml index 30e8982599..df14b2bc6d 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -18,6 +18,7 @@ depends = [ "test:gateway-config", "test:packaging-assets", "test:qualification-summary", + "test:release-workflows", "test:codex-security-release-range", "test:codex-security-diagnostics", "test:docs-website", @@ -76,6 +77,11 @@ run = "tasks/scripts/test-qualification-summary.sh" run_windows = "echo Skipping test:qualification-summary: the release workflow runs on Unix hosts." hide = true +["test:release-workflows"] +description = "Test that stable release publication can be retried" +run = "uv run --no-project --with pytest --with pyyaml pytest -o \"python_files=*_test.py\" tasks/scripts/release_publish_workflow_test.py" +hide = true + ["test:codex-security-release-range"] description = "Test Codex Security release-range resolution" run = "uv run --no-project --with pytest pytest -o \"python_files=*_test.py\" tasks/scripts/codex_security_range_test.py" From d8fc2fb607418010ab14663849fbbb736662a1f4 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Tue, 27 Oct 2026 21:05:12 -0700 Subject: [PATCH 3/4] fix(ci): fail release runs when an expected publisher does not succeed GitHub treats skipped jobs as passing, so v0.1.6 showed a green Release run although its publishers never ran. A final Check Publication job in release-publish.yml runs always and fails when any publisher expected for the release kind did not succeed, which also fires the existing Pre-release Slack alert. Release now sends the same Slack failure notice. Signed-off-by: Matthew Grossman --- .github/workflows/release-publish.yml | 46 ++++++++++++++++++ .github/workflows/release.yml | 47 +++++++++++++++++++ .../scripts/release_publish_workflow_test.py | 11 +++++ 3 files changed, 104 insertions(+) diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 86426b4383..87ebcb4337 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -589,3 +589,49 @@ jobs: publish: true secrets: FERN_TOKEN: ${{ secrets.FERN_TOKEN }} + + # GitHub treats skipped jobs as passing, so a publisher that never ran would + # leave the run green. Fail when any publisher expected for this release kind + # did not succeed, so callers' failure notifications fire. + check-publication: + name: Check Publication + needs: + - assemble + - tag-ghcr-release + - release-helm + - release + - publish-snap + - publish-prerelease-snap + - publish-sdk-typescript + - tag-sdk-go + - trigger-wheel-publish + - publish-fern-docs + if: always() + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - name: Require every expected publisher to succeed + env: + NEEDS: ${{ toJSON(needs) }} + IS_PRERELEASE: ${{ inputs.is-prerelease }} + MAKE_LATEST: ${{ inputs.make-latest }} + run: | + set -euo pipefail + expected=(assemble tag-ghcr-release release-helm release) + if [[ "${IS_PRERELEASE}" == "true" ]]; then + expected+=(publish-prerelease-snap) + else + expected+=(publish-sdk-typescript tag-sdk-go trigger-wheel-publish publish-fern-docs) + if [[ "${MAKE_LATEST}" == "true" ]]; then + expected+=(publish-snap) + fi + fi + failed=0 + for job in "${expected[@]}"; do + result=$(jq -r --arg job "${job}" '.[$job].result' <<<"${NEEDS}") + if [[ "${result}" != "success" ]]; then + echo "::error::${job} did not publish (result: ${result})" + failed=1 + fi + done + exit "${failed}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6e702fc963..15ab451116 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -162,3 +162,50 @@ jobs: FERN_TOKEN: ${{ secrets.FERN_TOKEN }} GITLAB_CI_TRIGGER_TOKEN: ${{ secrets.GITLAB_CI_TRIGGER_TOKEN }} GITLAB_CI_TRIGGER_URL: ${{ secrets.GITLAB_CI_TRIGGER_URL }} + + notify-release-failure: + name: Notify Release Failure + needs: [promote, build, release-notes, publish] + if: failure() + runs-on: ubuntu-latest + timeout-minutes: 2 + permissions: + actions: read + defaults: + run: + shell: bash + steps: + - name: Send Slack notification + continue-on-error: true + env: + RELEASE_TAG: ${{ needs.promote.outputs.tag || inputs.tag || github.ref_name }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_OPENSHELL_TRIAGE_WEBHOOK_URL }} + SLACK_MENTION: ${{ secrets.SLACK_OPENSHELL_TRIAGE_MENTION }} + GH_TOKEN: ${{ github.token }} + RUN_URL: ${{ format('{0}/{1}/actions/runs/{2}/attempts/{3}', github.server_url, github.repository, github.run_id, github.run_attempt) }} + run: | + set -euo pipefail + if [[ -z "${SLACK_WEBHOOK_URL}" ]]; then + echo "::notice::SLACK_OPENSHELL_TRIAGE_WEBHOOK_URL is unset; skipping Slack notification." + exit 0 + fi + + message=":x: OpenShell release ${RELEASE_TAG} failed (attempt ${GITHUB_RUN_ATTEMPT})." + if [[ -n "${SLACK_MENTION}" ]]; then + message+=" ${SLACK_MENTION}" + fi + + failed_jobs=$(gh api --paginate \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/attempts/${GITHUB_RUN_ATTEMPT}/jobs?per_page=100" \ + --jq '.jobs[] | select(.conclusion == "failure" or .conclusion == "timed_out") | "• <\(.html_url)|\(.name)>"') || { + echo "::warning::Could not list failed jobs; sending the notification without them." + failed_jobs="" + } + if [[ -n "${failed_jobs}" ]]; then + message+=$'\n\nFailed jobs\n'"${failed_jobs}" + fi + + jq -n --arg text "${message}" --arg run_url "${RUN_URL}" \ + '{text: ($text + "\n<" + $run_url + "|View failed release run>"), unfurl_links: false, unfurl_media: false}' | + curl --fail --silent --show-error --connect-timeout 5 --max-time 15 \ + --header 'Content-Type: application/json' --data-binary @- "${SLACK_WEBHOOK_URL}" diff --git a/tasks/scripts/release_publish_workflow_test.py b/tasks/scripts/release_publish_workflow_test.py index 400a50cf60..2f2d5b3a6a 100644 --- a/tasks/scripts/release_publish_workflow_test.py +++ b/tasks/scripts/release_publish_workflow_test.py @@ -113,6 +113,8 @@ def test_needed_jobs_skip_only_when_their_dependents_skip() -> None: # so a needed job may only carry the same condition as each dependent. jobs = publish_jobs() for name, job in jobs.items(): + if job.get("if") == "always()": + continue needs = job.get("needs", []) for need in [needs] if isinstance(needs, str) else needs: condition = jobs[need].get("if") @@ -121,3 +123,12 @@ def test_needed_jobs_skip_only_when_their_dependents_skip() -> None: f"{name} needs {need}, which can be skipped by {condition!r}; " "decide inside its steps instead" ) + + +def test_publication_check_needs_every_publish_job() -> None: + # The check fails the run when an expected publisher was skipped, so it + # must see every other job in the graph. + jobs = publish_jobs() + check = jobs["check-publication"] + assert check["if"] == "always()" + assert set(check["needs"]) == set(jobs) - {"check-publication"} From b410af751b19ffeb8b9dac2e96eb663389761d6e Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Tue, 27 Oct 2026 22:09:53 -0700 Subject: [PATCH 4/4] fix(ci): fail instead of overwriting when a registry lookup errors The GHCR and Helm existence checks treated any lookup error as not published, so a transient registry error could fall through to a push that overwrites a published tag. Only a confirmed missing tag now counts as unpublished; any other lookup error fails the job. Also state in CI.md that published artifacts are never overwritten, and remove the static release workflow test; check-publication catches skipped publishers at runtime. Signed-off-by: Matthew Grossman --- .github/actions/release-helm-oci/action.yml | 7 +- .github/workflows/branch-checks.yml | 3 - .github/workflows/release-publish.yml | 9 +- CI.md | 2 +- .../scripts/release_publish_workflow_test.py | 134 ------------------ tasks/test.toml | 6 - 6 files changed, 14 insertions(+), 147 deletions(-) delete mode 100644 tasks/scripts/release_publish_workflow_test.py diff --git a/.github/actions/release-helm-oci/action.yml b/.github/actions/release-helm-oci/action.yml index 0023bb92cf..aba7504129 100644 --- a/.github/actions/release-helm-oci/action.yml +++ b/.github/actions/release-helm-oci/action.yml @@ -76,8 +76,13 @@ runs: found=0 for chart in helm-chart openshell-workspace; do OCI_CHART="oci://ghcr.io/nvidia/openshell/${chart}" - if helm show chart "${OCI_CHART}" --version "${CHART_VERSION}" >/dev/null 2>&1; then + # Only "not found" means unpublished; any other lookup error fails + # rather than risk overwriting. + if lookup_error=$(helm show chart "${OCI_CHART}" --version "${CHART_VERSION}" 2>&1 >/dev/null); then found=$((found + 1)) + elif [[ "${lookup_error}" != *"ghcr.io/nvidia/openshell/${chart}:${CHART_VERSION}: not found"* ]]; then + echo "::error::Could not check whether ${chart}:${CHART_VERSION} is published: ${lookup_error}" + exit 1 fi done if [ "${found}" -eq 2 ]; then diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index ae9e5ac071..218d3d2657 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -540,9 +540,6 @@ jobs: - name: Test release qualification summary run: mise run test:qualification-summary - - - name: Test release workflows - run: mise run test:release-workflows go: name: Go SDK needs: pr_metadata diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 87ebcb4337..24190aab68 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -257,10 +257,15 @@ jobs: for component in gateway sandbox supervisor; do image="ghcr.io/nvidia/openshell/${component}:${VERSION}" # Never replace a published stable image; a rerun keeps the original. - if nix shell --inputs-from . nixpkgs#skopeo -c \ + # Only a missing manifest means unpublished; any other lookup error + # fails rather than risk overwriting. + if lookup_error=$(nix shell --inputs-from . nixpkgs#skopeo -c \ skopeo inspect --raw --authfile "${HOME}/.docker/config.json" \ - "docker://${image}" >/dev/null 2>&1; then + "docker://${image}" 2>&1 >/dev/null); then echo "${image} is already published; keeping it." + elif [[ "${lookup_error}" != *"manifest unknown"* ]]; then + echo "::error::Could not check whether ${image} is published: ${lookup_error}" + exit 1 else # Archives are build artifacts from this run, not externally signed images. nix shell --inputs-from . nixpkgs#skopeo -c \ diff --git a/CI.md b/CI.md index 312546a119..431795c959 100644 --- a/CI.md +++ b/CI.md @@ -712,7 +712,7 @@ These workflows run after merge to publish dev/tagged artifacts and verify them. | File | Role | |---|---| | `.github/workflows/release-dev.yml` | Publishes the rolling `dev` build on every push to `main`. Builds gateway, sandbox, and supervisor images and binaries, packages, wheels, and pushes the Helm chart as `oci://ghcr.io/nvidia/openshell/helm-chart:0.0.0-dev` (plus an immutable `0.0.0-dev.` pin). Also dispatchable manually. | -| `.github/workflows/release.yml` | Manually promote the latest qualified prerelease for a version and branch, create its stable tag, then rebuild and publish. Support releases preserve shared latest channels. Publishes the prerelease's notes preview, retagged, or generates notes when it is unavailable. The notes-only mode generates a release notes preview artifact. | +| `.github/workflows/release.yml` | Manually promote the latest qualified prerelease for a version and branch, create its stable tag, then rebuild and publish. Support releases preserve shared latest channels. Publishes the prerelease's notes preview, retagged, or generates notes when it is unavailable. The notes-only mode generates a release notes preview artifact. Published artifacts are never overwritten: a rerun fills in what is missing, and a bad published artifact is fixed by releasing a new patch version. | | `.github/workflows/release-auto-tag.yml` | Weekdays at 14:00 Europe/Zurich, tags the next `-pre.N` on `main` when commits landed since the latest one, then dispatches Pre-release. It skips when the train has no `-pre.1` or its version is already stable, so push each train's `-pre.1` tag by hand. | | `.github/workflows/release-prerelease.yml` | Pre-release orchestrator, triggered by prerelease tag pushes or dispatched by Release Auto-Tag or manually. A tagless dispatch creates the next candidate on its branch; explicit tags select existing candidates. Builds, then publishes and qualifies in parallel. Qualification failure fails the run and sends the failure notification; published artifacts remain available. | | `.github/workflows/release-build.yml` | Shared version computation, binary and image builds, and package builds. Validates that the tag matches the caller's release kind. | diff --git a/tasks/scripts/release_publish_workflow_test.py b/tasks/scripts/release_publish_workflow_test.py deleted file mode 100644 index 2f2d5b3a6a..0000000000 --- a/tasks/scripts/release_publish_workflow_test.py +++ /dev/null @@ -1,134 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Static checks that stable publication can be retried for an existing tag. - -Run via `mise run test:release-workflows`. A retry dispatches the current -workflow against an older release commit, so publish logic must not be loaded -from that commit's checkout. -""" - -from __future__ import annotations - -from pathlib import Path - -import yaml - -ROOT = Path(__file__).resolve().parents[2] -WORKFLOW_SHA = "${{ github.workflow_sha }}" -SOURCE_SHA = "${{ inputs.source-sha }}" - -# Environment setup only; it does not decide what gets published. -SOURCE_ACTIONS = {"./.github/actions/setup-nix"} - - -def load(path: str) -> dict: - return yaml.load((ROOT / path).read_text(encoding="utf-8"), Loader=yaml.BaseLoader) - - -PUBLISH_WORKFLOWS = ( - ".github/workflows/release-publish.yml", - ".github/workflows/sdk-go-tag.yml", - ".github/workflows/sdk-typescript-publish.yml", - ".github/workflows/snap-publish.yml", - ".github/workflows/wheel-publish.yml", -) - - -def publish_jobs() -> dict: - return load(".github/workflows/release-publish.yml")["jobs"] - - -def all_publish_jobs() -> dict: - return { - f"{path}:{name}": job - for path in PUBLISH_WORKFLOWS - for name, job in load(path)["jobs"].items() - } - - -def test_publish_logic_is_loaded_from_the_workflow_revision() -> None: - for name, job in all_publish_jobs().items(): - for step in job.get("steps", []): - uses = step.get("uses", "") - if uses.startswith("./.github/actions/"): - assert uses in SOURCE_ACTIONS, ( - f"{name} loads {uses} from the release source checkout; " - "check it out from github.workflow_sha into release-workflow " - "so retries of older tags use the current publish logic" - ) - - -def test_release_workflow_checkouts_use_the_workflow_revision() -> None: - for name, job in all_publish_jobs().items(): - steps = job.get("steps", []) - loaded = { - step["uses"].removeprefix("./release-workflow/") - for step in steps - if step.get("uses", "").startswith("./release-workflow/") - } - if not loaded: - continue - checkouts = [ - step - for step in steps - if step.get("with", {}).get("path") == "release-workflow" - ] - assert len(checkouts) == 1, name - checkout = checkouts[0]["with"] - assert checkout["ref"] == WORKFLOW_SHA, name - sparse = set(checkout.get("sparse-checkout", "").split()) - assert loaded <= sparse, f"{name} needs {loaded - sparse} in sparse-checkout" - - -def test_helm_charts_come_from_the_release_source() -> None: - steps = publish_jobs()["release-helm"]["steps"] - source = [ - step - for step in steps - if step.get("with", {}).get("ref") == SOURCE_SHA - and "path" not in step.get("with", {}) - ] - assert len(source) == 1 - assert steps.index(source[0]) < next( - i - for i, step in enumerate(steps) - if step.get("uses") == "./release-workflow/.github/actions/release-helm-oci" - ) - - -def test_helm_action_keeps_published_public_charts() -> None: - steps = load(".github/actions/release-helm-oci/action.yml")["runs"]["steps"] - check = next(step for step in steps if step.get("id") == "published") - assert check["if"] == "inputs.release-kind == 'public'" - assert "Refuse duplicate" not in str(steps) - skipped = "steps.published.outputs.published != 'true'" - for name in ("Package Helm chart", "Push Helm chart to GHCR OCI"): - step = next(step for step in steps if step.get("name") == name) - assert step["if"] == skipped, name - - -def test_needed_jobs_skip_only_when_their_dependents_skip() -> None: - # A skipped job fails the implicit success() check of every job after it, - # so a needed job may only carry the same condition as each dependent. - jobs = publish_jobs() - for name, job in jobs.items(): - if job.get("if") == "always()": - continue - needs = job.get("needs", []) - for need in [needs] if isinstance(needs, str) else needs: - condition = jobs[need].get("if") - if condition is not None: - assert job.get("if") == condition, ( - f"{name} needs {need}, which can be skipped by {condition!r}; " - "decide inside its steps instead" - ) - - -def test_publication_check_needs_every_publish_job() -> None: - # The check fails the run when an expected publisher was skipped, so it - # must see every other job in the graph. - jobs = publish_jobs() - check = jobs["check-publication"] - assert check["if"] == "always()" - assert set(check["needs"]) == set(jobs) - {"check-publication"} diff --git a/tasks/test.toml b/tasks/test.toml index df14b2bc6d..30e8982599 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -18,7 +18,6 @@ depends = [ "test:gateway-config", "test:packaging-assets", "test:qualification-summary", - "test:release-workflows", "test:codex-security-release-range", "test:codex-security-diagnostics", "test:docs-website", @@ -77,11 +76,6 @@ run = "tasks/scripts/test-qualification-summary.sh" run_windows = "echo Skipping test:qualification-summary: the release workflow runs on Unix hosts." hide = true -["test:release-workflows"] -description = "Test that stable release publication can be retried" -run = "uv run --no-project --with pytest --with pyyaml pytest -o \"python_files=*_test.py\" tasks/scripts/release_publish_workflow_test.py" -hide = true - ["test:codex-security-release-range"] description = "Test Codex Security release-range resolution" run = "uv run --no-project --with pytest pytest -o \"python_files=*_test.py\" tasks/scripts/codex_security_range_test.py"