name: Publish release # Draft-first, and reachable. # # This used to trigger on `release: [created]` with every outward-facing job # gated on `github.event.release.draft`. GitHub does not emit `release.created` # for a *draft* release, so the intended entry point could never fire; a # non-draft creation does emit it, and then the draft conditions skipped # attachment and publication. The pipeline was unreachable by construction. # # The entry point is now explicit: a maintainer creates the draft release # (`gh release create vX.Y.Z --draft --verify-tag`; a tag push creates nothing), # then runs this workflow against that tag. With # `publish` unset it is a dry run — everything is built and verified, nothing # leaves. With `publish` set the run must name an existing *draft*, which the # guard job proves before anything else starts. # # The ordering and every check live in scripts/release_pipeline.py rather than # in this file, because a step that can only run on a release event is tested # by the thing it is supposed to protect. That script runs on a laptop, under # --dry-run, and under pytest. on: workflow_dispatch: inputs: tag: description: "Release tag to build (e.g. v0.2.0). Required to publish." required: false default: "" publish: description: "Stage onto the existing draft, publish to PyPI, then make the GitHub release public" type: boolean required: false default: false pypi_only: description: "Publish only the wheel/sdist to PyPI for an ALREADY-PUBLIC release of this tag; nothing is staged onto the release and nothing else is touched" type: boolean required: false default: false macos_asset: description: "notarized = sign, notarytool submit --wait, staple, and upload the DMG; omit (default) = do not upload a preview DMG" type: choice required: false options: - omit - notarized default: omit # Two dispatches naming the same tag could interleave, and both `attach` jobs # would `gh release upload --clobber` onto the same draft. The pipeline detects # that afterwards -- `publish` anchors to immutable PyPI digests -- but nothing # prevented it. Not cancel-in-progress: a half-staged release is worse than a # queued one. concurrency: group: release-${{ inputs.tag || github.ref }} cancel-in-progress: false permissions: contents: read jobs: # Validate the immutable workflow SHA once, before anything else starts. # # A user-supplied tag is only an identity assertion: it must peel to the # immutable github.sha selected for this run. No checkout ever resolves an # input or a step output. The ancestor-of-main assertion applies # to dry runs and recovery publication as well as ordinary publication, so # untrusted code never reaches a release runner's cache credentials. freeze: name: Freeze the source SHA runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout the immutable workflow SHA uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false - name: Validate the workflow SHA and optional release tag env: TAG: ${{ inputs.tag }} PUBLISH: ${{ inputs.publish }} PYPI_ONLY: ${{ inputs.pypi_only }} SHA: ${{ github.sha }} run: | set -euo pipefail case "$SHA" in [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) ;; *) echo "::error::github.sha is not a full commit SHA: $SHA"; exit 1 ;; esac if [ -n "$TAG" ]; then if ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "::error::release tag must be a stable vMAJOR.MINOR.PATCH" exit 1 fi TAG_SHA="$(git rev-parse --verify "${TAG}^{commit}")" if [ "$TAG_SHA" != "$SHA" ]; then echo "::error::$TAG peels to $TAG_SHA, not immutable workflow SHA $SHA" exit 1 fi if [ "$PUBLISH" = "true" ] || [ "$PYPI_ONLY" = "true" ]; then if [ "$(git cat-file -t "$TAG")" != "tag" ]; then echo "::error::Release tags must be annotated." exit 1 fi fi fi git fetch --no-tags origin main if ! git merge-base --is-ancestor "$SHA" origin/main; then echo "::error::$SHA is not an ancestor of origin/main." exit 1 fi echo "validated immutable release source $SHA" guard: name: Require the release state the mode expects if: inputs.publish || inputs.pypi_only runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: The named tag must exist, in the state the chosen mode requires env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} TAG: ${{ inputs.tag }} PUBLISH: ${{ inputs.publish }} PYPI_ONLY: ${{ inputs.pypi_only }} run: | if [ "$PUBLISH" = "true" ] && [ "$PYPI_ONLY" = "true" ]; then echo '::error::set either "publish" or "pypi_only", not both' exit 1 fi if [ -z "$TAG" ]; then echo '::error::publishing requires the "tag" input' exit 1 fi if ! gh release view "$TAG" --json isDraft,isPrerelease > release.json; then echo "::error::no release $TAG exists; create the draft first" exit 1 fi if [ "$PYPI_ONLY" = "true" ]; then # The inverse requirement. This mode exists for exactly one gap: a # release already made public whose PyPI version was never # published. Nothing is staged and nothing is flipped, so the # half-published hazard the draft rule below guards does not # apply here — what would be wrong is running it against a draft, # where the full publish path is the correct tool. if [ "$(jq -r .isDraft release.json)" != "false" ]; then echo "::error::$TAG is still a draft; use the normal publish path" exit 1 fi else if [ "$(jq -r .isDraft release.json)" != "true" ]; then echo "::error::$TAG is already public; staging assets onto it is the half-published state this pipeline exists to prevent" exit 1 fi fi if [ "$(jq -r .isPrerelease release.json)" = "true" ]; then echo "::error::$TAG is marked as a prerelease; stable publication requires a non-prerelease draft" exit 1 fi quality: name: Quality gates at the release SHA needs: [freeze] runs-on: ubuntu-latest # The suite runs twice here, serially and then under the schema capture: # ~44 minutes projected at 0.3.0, and busy runners have been 1.3-2.8x slower. timeout-minutes: 90 steps: - name: Checkout the frozen release SHA uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false - name: Install uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: python-version: "3.12" enable-cache: false - name: Install dev, science, and chemistry dependencies run: uv sync --locked --extra science --extra chemistry # The browser matrix and the Python support matrix already ran on the push # to `main` that this tag points at, and cost more than the rest of the # release combined. So they are proven from GitHub's own check runs *pinned # to this exact commit* rather than re-executed. `attest_check_runs` # requires each required check to be the latest attempt, to have concluded # `success`, and to name this SHA as its `head_sha` -- without that last # condition it would degrade into "some run somewhere was green". - name: Collect the check runs recorded against the frozen SHA env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} SHA: ${{ github.sha }} # `--slurp` wraps every page in one JSON array. Without it gh writes a # document per page back to back, which is not one JSON value; the # reader accepts both, so a SHA with more than 100 check runs (nightly # schedules, CodeQL, Dependabot) stays releasable either way. run: | set -euo pipefail gh api --paginate --slurp \ "repos/${GH_REPO}/commits/${SHA}/check-runs?per_page=100" \ > check-runs.json - name: Run every offline gate and record what happened env: # The runner has no keychain and no session bus, which is exactly the # condition the secret store is designed to fail closed on. ci.yml's # browser job sets the same thing for the same reason; without it the # suite fails on a policy that is working correctly. OPENAI4S_SECRET_STORE: plaintext run: | uv run python scripts/run_quality_gates.py \ --output quality-receipt.json \ --check-runs check-runs.json - name: Upload the receipt for staging to verify uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: quality-receipt path: quality-receipt.json if-no-files-found: error retention-days: 1 # The platform checks cannot be attested: the nightly sandbox jobs in ci.yml # run only on `schedule`/`workflow_dispatch`, so no check run for them exists at # a release SHA and requiring one would make every release unreachable. They are # executed here instead, against the frozen SHA. platform-checks: name: Sandbox enforcement at the release SHA (${{ matrix.platform }}) needs: [freeze] runs-on: ${{ matrix.runner }} timeout-minutes: 30 strategy: fail-fast: false matrix: include: - platform: macos runner: macos-14 module: harness.smoke.macos_sandbox # No linux leg. The former hosted `harness.smoke.linux_sandbox` run # failed, and the complete boundary has not been re-evaluated with # ci.yml's newer restricted bwrap profile. Because `build` needs this # job, requiring an unproven leg made every release unreachable. It is # declared unprovable in # `release_gates.PLATFORM_CHECKS_UNAVAILABLE`, which the evidence # bundle carries, rather than dropped. Adding it back here without # removing it there fails `test_the_release_declares_every_platform`. steps: - name: Checkout the frozen release SHA uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.sha }} persist-credentials: false - name: Install uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: python-version: "3.12" enable-cache: false - name: Install bubblewrap if: matrix.platform == 'linux' run: sudo apt-get update && sudo apt-get install -y bubblewrap - name: Install the zero-dependency runtime run: uv sync --locked - name: Require a real enforced sandbox and child-env scrubbing env: OPENAI4S_KERNEL_SANDBOX: enforce run: uv run python -m ${{ matrix.module }} build: name: Build verified distributions needs: [freeze, quality, platform-checks] runs-on: ubuntu-latest timeout-minutes: 30 steps: - name: Checkout the frozen release SHA uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false - name: Install uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: python-version: "3.12" enable-cache: false - name: Match the tag to both version declarations if: inputs.tag != '' # Through env, not inlined. ci.yml states the rule for # `github.head_ref` and this file broke it for `inputs.tag` -- the same # substitution, in the most security-sensitive workflow. `attach` and # `finalize` already do it correctly; these two did not. env: TAG: ${{ inputs.tag }} run: uv run --locked python scripts/verify_release_tag.py "$TAG" - name: Scan release sources run: uv run --locked python scripts/source_secret_scan.py - name: Build wheel and source distribution run: uv build --no-sources --out-dir dist --clear - name: Verify metadata and packaged runtime resources run: uv run --locked python scripts/verify_release_artifacts.py dist # Binds these exact bytes to the frozen commit and records what built them. # Nothing did: `attach` hashed whatever it downloaded and treated that as # the baseline, so an artifact built from a moved tag was indistinguishable # from one built from the released sources. - name: Receipt the distributions against the frozen SHA env: SHA: ${{ github.sha }} OPENAI4S_RELEASE_INPUTS_JSON: ${{ toJSON(github.event.inputs) }} run: | uv run --locked python scripts/release_receipts.py \ --kind dist --source-sha "$SHA" \ --workflow-run-id "$GITHUB_RUN_ID" \ dist/*.whl dist/*.tar.gz - name: Upload the exact distributions for publication uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: python-package-distributions path: dist/ if-no-files-found: error retention-days: 1 macos-app: name: Build the macOS app image (arm64) needs: [freeze, quality, platform-checks] # The DMG embeds a relocatable CPython plus the native science wheels, so it # cannot be cross-built: this must run on an Apple Silicon runner. runs-on: macos-14 timeout-minutes: 90 # The `secrets` context is not available in a step-level `if`, so the # certificate's presence is surfaced here, at job level, where secrets are # allowed — the import step then conditions on this env value instead. env: HAS_SIGNING_CERT: ${{ secrets.MACOS_SIGNING_CERTIFICATE != '' }} HAS_NOTARY_APPLE_ID: ${{ secrets.APPLE_ID != '' && secrets.APPLE_TEAM_ID != '' && secrets.APPLE_NOTARY_PASSWORD != '' }} HAS_NOTARY_API_KEY: ${{ secrets.APPLE_API_KEY_ID != '' && secrets.APPLE_API_ISSUER != '' && (secrets.APPLE_API_KEY != '' || secrets.APPLE_API_KEY_PATH != '') }} MACOS_ASSET: ${{ inputs.macos_asset || 'omit' }} steps: - name: Reject an unknown macos_asset value env: MACOS_ASSET: ${{ inputs.macos_asset || 'omit' }} run: | set -euo pipefail case "$MACOS_ASSET" in omit) echo "macos_asset=omit" ;; notarized) echo "macos_asset=notarized" ;; *) echo "::error::macos_asset must be omit or notarized, got '$MACOS_ASSET'" exit 1 ;; esac - name: Omit the macOS asset rather than upload a preview DMG if: ${{ inputs.macos_asset != 'notarized' }} run: echo "macos_asset=omit; not building or uploading a preview DMG" - name: Checkout the frozen release SHA if: ${{ inputs.macos_asset == 'notarized' }} uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.sha }} persist-credentials: false - name: Fail fast when notarization was requested without credentials if: ${{ inputs.macos_asset == 'notarized' }} env: OPENAI4S_MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_NOTARY_PASSWORD: ${{ secrets.APPLE_NOTARY_PASSWORD }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY_PATH: ${{ secrets.APPLE_API_KEY_PATH }} HAS_SIGNING_CERT: ${{ env.HAS_SIGNING_CERT }} run: | set -euo pipefail if [ "$HAS_SIGNING_CERT" != "true" ]; then echo "::error::macos_asset=notarized requires MACOS_SIGNING_CERTIFICATE; set macos_asset=omit (the default) instead of uploading a preview DMG" exit 1 fi python3 scripts/describe_macos_image.py --check-notary-credentials - name: Install uv if: ${{ inputs.macos_asset == 'notarized' }} uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: false - name: Import the Developer ID certificate into a temporary keychain # A signing *identity name* is not a signing *identity*: codesign looks # the name up in a keychain, and a fresh runner has none. Passing only # the name made codesign fail (identity not found) while an empty name # produced an ad-hoc image the gate rejects — so release mode could # never succeed as written. The certificate and its password are # separate secrets; when they are absent the build ad-hoc signs and the # release gate is what refuses to publish it, which is the correct # fail-closed behaviour rather than a green build. if: ${{ inputs.macos_asset == 'notarized' && env.HAS_SIGNING_CERT == 'true' }} env: MACOS_SIGNING_CERTIFICATE: ${{ secrets.MACOS_SIGNING_CERTIFICATE }} MACOS_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_SIGNING_CERTIFICATE_PASSWORD }} KEYCHAIN_PASSWORD: ${{ secrets.MACOS_KEYCHAIN_PASSWORD }} run: | set -euo pipefail KEYCHAIN="$RUNNER_TEMP/openai4s-signing.keychain-db" CERT_PATH="$RUNNER_TEMP/certificate.p12" echo "$MACOS_SIGNING_CERTIFICATE" | base64 --decode > "$CERT_PATH" security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" security set-keychain-settings -lut 21600 "$KEYCHAIN" security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" security import "$CERT_PATH" -P "$MACOS_SIGNING_CERTIFICATE_PASSWORD" \ -A -t cert -f pkcs12 -k "$KEYCHAIN" security set-key-partition-list -S apple-tool:,apple: \ -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" >/dev/null # Put the temporary keychain in the search list so codesign finds it. security list-keychains -d user -s "$KEYCHAIN" login.keychain-db rm -f "$CERT_PATH" - name: Build the .app and .dmg # The identity reaches the build, which is the only place it can be # used. Passing it only to the staging job — as before — meant setting # the secret changed nothing about how the image was signed while # making the gate believe it had been. The certificate the identity # names was imported into the keychain above. if: ${{ inputs.macos_asset == 'notarized' }} env: OPENAI4S_MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }} run: bash scripts/build_macos_dmg.sh - name: Notarize and staple the disk image # sign → notarytool submit --wait → staple → stapler validate → spctl. # Credentials were fail-fast checked above; missing secrets never # reach Apple and never fall back to uploading a preview DMG. if: ${{ inputs.macos_asset == 'notarized' }} env: OPENAI4S_MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_NOTARY_PASSWORD: ${{ secrets.APPLE_NOTARY_PASSWORD }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY_PATH: ${{ secrets.APPLE_API_KEY_PATH }} run: bash scripts/notarize_macos_dmg.sh dist/*.dmg - name: Verify the image # Fails closed on a runtime that did not relocate, an incomplete science # stack, a missing Web UI / R worker, a broken signature, or any # credential material swept into the bundle. if: ${{ inputs.macos_asset == 'notarized' }} run: python3 scripts/verify_macos_bundle.py dist/*.dmg - name: Record what actually signed the image, whether it is notarized, and what is inside it # The staging job runs on Linux and has neither `codesign` nor `xcrun`, so # the evidence has to be produced here and travel with the image. Without # it the gate had nothing to read and fell back to "the identity variable # is set", which an ad-hoc image satisfies. The notarization half is newer: # `notarized` was hardcoded `None` in the pipeline and gated nothing, so a # Developer-ID-signed image with no stapled ticket passed -- and Gatekeeper # refuses exactly that image on a user's machine. if: ${{ inputs.macos_asset == 'notarized' }} run: python3 scripts/describe_macos_image.py dist/*.dmg - name: Receipt the image against the frozen SHA if: ${{ inputs.macos_asset == 'notarized' }} env: SHA: ${{ github.sha }} OPENAI4S_RELEASE_INPUTS_JSON: ${{ toJSON(github.event.inputs) }} run: | python3 scripts/release_receipts.py \ --kind macos --source-sha "$SHA" \ --workflow-run-id "$GITHUB_RUN_ID" \ dist/*.dmg - name: Upload the verified app image if: ${{ inputs.macos_asset == 'notarized' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: macos-app-image path: | dist/*.dmg dist/*.dmg.codesign.json dist/*.dmg.components.json dist/build-receipt-macos.json if-no-files-found: error retention-days: 1 linux-app: name: Build the Linux app bundle (x86_64) # Buildable on any host — nothing is compiled, uv fetches a relocatable # CPython for the target and manylinux wheels only — but only a Linux runner # can *execute* the result, and the verifier's import probe is the one check # that proves the science stack imports rather than merely being on disk. # # Gated on `quality` for the same reason `build` and `macos-app` are: an # artifact produced before the offline gates ran at this SHA is one nothing # has vouched for, and the Windows package is a wrapper around this exact # tarball, so letting it start early would put an unvouched payload inside # a second artifact too. # # `freeze` is in the list for the ref below, not for ordering: `quality` # already depends on it. This job arrived on a branch that had no `freeze` # job and checked out `inputs.tag || github.ref`, which is a moving ref -- # a tag can be repointed between the gate and the build, and P0-0's whole # claim is that every artifact came from one immutable SHA. needs: [freeze, quality] runs-on: ubuntu-latest timeout-minutes: 90 steps: - name: Checkout the frozen release SHA uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.sha }} persist-credentials: false - name: Install uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: false - name: Build the .tar.gz run: bash scripts/build_linux_bundle.sh - name: Verify the bundle # Fails closed on a runtime that did not relocate, an incomplete science # stack, a missing Web UI / R worker, a desktop entry that launches # nothing, or any credential material swept into the archive. run: python3 scripts/verify_linux_bundle.py dist/OpenAI4S-*-linux-x86_64.tar.gz # Same binding the wheel and the DMG get. Without it the tarball was # staged with no document tying its bytes to the frozen commit -- covered # only by the in-run `incoming` digests, which say "these are the bytes # `attach` downloaded", not "these are the bytes built from this source". - name: Receipt the bundle against the frozen SHA env: SHA: ${{ github.sha }} OPENAI4S_RELEASE_INPUTS_JSON: ${{ toJSON(github.event.inputs) }} run: | uv run --locked python scripts/release_receipts.py \ --kind linux --source-sha "$SHA" \ --workflow-run-id "$GITHUB_RUN_ID" \ dist/OpenAI4S-*-linux-x86_64.tar.gz - name: Upload the verified bundle # The .sha256 sidecar travels because `windows-package` embeds it in the # zip, so the digest the Windows launcher checks against is the one this # job produced. It is not a release asset: `release_pipeline.py` collects # only distribution suffixes and publishes one SHA256SUMS covering them. uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: linux-app-bundle path: | dist/OpenAI4S-*-linux-x86_64.tar.gz dist/OpenAI4S-*-linux-x86_64.tar.gz.sha256 dist/build-receipt-*.json if-no-files-found: error retention-days: 1 windows-package: name: Build the Windows package (WSL2 payload) # Not a native Windows build, on purpose: platform_support.py refuses to # start a kernel on win32, so the Windows deliverable is a Windows launcher # around the Linux bundle, which it installs into WSL2. See the header of # scripts/build_windows_zip.sh. # # It consumes `linux-app`'s artifact rather than rebuilding: that is what # makes the payload byte-identical to the Linux bundle this same release # publishes, instead of a second build that merely ought to match. # `freeze` for the ref, `linux-app` for the payload: same reason as above. needs: [freeze, linux-app] runs-on: ubuntu-latest timeout-minutes: 45 steps: - name: Checkout the frozen release SHA uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.sha }} persist-credentials: false - name: Download the verified Linux bundle uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: linux-app-bundle path: dist/ # The receipt step below runs `uv run --locked`, and this job never # installed uv — which nothing noticed for as long as the pipeline was # unreachable, and the first complete dispatch (v0.2.0 pypi_only, run # 32831018097) died here with `uv: command not found` after every build # had succeeded. - name: Install uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: false - name: Build the .zip run: bash scripts/build_windows_zip.sh - name: Verify the package # Fails closed on CRLF in the WSL bootstrap, a payload that no longer # matches its checksum, macOS resource-fork litter, or a launcher that # has grown a native Windows execution path. run: python3 scripts/verify_windows_zip.py dist/OpenAI4S-*-windows-x86_64.zip - name: Receipt the package against the frozen SHA env: SHA: ${{ github.sha }} OPENAI4S_RELEASE_INPUTS_JSON: ${{ toJSON(github.event.inputs) }} run: | uv run --locked python scripts/release_receipts.py \ --kind windows --source-sha "$SHA" \ --workflow-run-id "$GITHUB_RUN_ID" \ dist/OpenAI4S-*-windows-x86_64.zip - name: Upload the verified package uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: windows-package path: | dist/OpenAI4S-*-windows-x86_64.zip dist/OpenAI4S-*-windows-x86_64.zip.sha256 dist/build-receipt-*.json if-no-files-found: error retention-days: 1 windows-launcher: name: Parse the shipped Windows launcher on Windows # The launcher is the only artifact in this release nothing else can run: a # syntax error in the .ps1 is invisible to every Linux and macOS job and # fails on the first user's machine instead. This job parses the copy that # is actually inside the built zip — not the source file — because the build # rewrites its line endings on the way in. needs: windows-package runs-on: windows-latest timeout-minutes: 20 steps: - name: Download the built package uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: windows-package path: pkg/ - name: Parse the packaged launcher shell: pwsh run: | $zip = Get-ChildItem pkg\*.zip | Select-Object -First 1 Expand-Archive -Path $zip.FullName -DestinationPath unpacked -Force $ps1 = Get-ChildItem -Path unpacked -Recurse -Filter openai4s.ps1 | Select-Object -First 1 if (-not $ps1) { throw 'the package ships no openai4s.ps1' } $errors = $null $tokens = $null [void][System.Management.Automation.Language.Parser]::ParseFile( $ps1.FullName, [ref] $tokens, [ref] $errors) if ($errors -and $errors.Count -gt 0) { $errors | ForEach-Object { Write-Host "::error::$($_.Extent.StartLineNumber): $($_.Message)" } throw "the packaged launcher does not parse" } Write-Host "parsed OK ($($tokens.Count) tokens)" - name: The refusal path must actually refuse # These runners have no WSL, which is exactly the machine the guidance # was written for. A launcher that exits 0 here is one that would go on # to do something on a machine that cannot run the app. Driving the # `.cmd` rather than the `.ps1` is deliberate: the wrapper and its # -ExecutionPolicy handoff are what a user actually double-clicks. shell: pwsh env: OPENAI4S_NONINTERACTIVE: "1" run: | $cmd = Get-ChildItem -Path unpacked -Recurse -Filter OpenAI4S.cmd | Select-Object -First 1 if (-not $cmd) { throw 'the package ships no OpenAI4S.cmd' } $out = & $cmd.FullName 2>&1 | Out-String # Captured immediately, and the step ends with an explicit `exit 0`. # A refusal is a NON-ZERO exit -- that is the thing being asserted -- # and the pwsh shell appends `exit $LASTEXITCODE`, so leaving it set # fails the step for the launcher behaving correctly. $code = $LASTEXITCODE Write-Host $out if ($code -eq 0) { throw 'the launcher succeeded on a machine with no WSL' } if ($out -notmatch 'wsl --install') { throw 'the refusal does not tell the user how to install WSL' } Write-Host "refused with exit $code and named the way out" exit 0 attach: name: Stage assets onto the draft # Staging only. This job never makes anything public: the flip lives in # `finalize`, after PyPI. if: inputs.publish needs: [freeze, guard, quality, build, macos-app, linux-app, windows-package, windows-launcher] runs-on: ubuntu-latest timeout-minutes: 45 permissions: contents: write steps: - name: Download the verified distributions uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: python-package-distributions path: assets/ - name: Download the verified app image if: ${{ inputs.macos_asset == 'notarized' }} uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: macos-app-image path: assets/ - name: Download the verified Linux bundle uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: linux-app-bundle path: assets/ - name: Download the verified Windows package uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: windows-package path: assets/ # The proof that the gates ran at this SHA. `step_test` refuses to stage # without it, and refuses one written for a different commit. - name: Download the quality receipt uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: quality-receipt path: assets/ - name: Check out the pipeline at the frozen release SHA uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false path: source - name: Revalidate the remote annotated tag before staging env: TAG: ${{ inputs.tag }} SHA: ${{ github.sha }} working-directory: source run: bash scripts/revalidate_release_tag.sh "$TAG" "$SHA" # `--from-artifacts` is what stops this job from re-running `build` and # `pytest`: its inputs are the artifacts the earlier jobs produced and # verified, and rebuilding here would put different bytes on GitHub than # PyPI receives. `--stop-after reverify` is what stops it from publishing. # The remote-tag check above catches a tag that moved after `freeze`; # `--source-sha` independently proves this checkout is the frozen commit # instead of believing the label carried through the job graph. - name: Stage, prove and verify the release env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} TAG: ${{ inputs.tag }} SHA: ${{ github.sha }} OPENAI4S_RELEASE_INPUTS_JSON: ${{ toJSON(github.event.inputs) }} # Run from inside the checkout: the pipeline asks git which commit it # is releasing, and outside a work tree that question has no answer -- # which the receipt check correctly refuses to guess at. working-directory: source run: | python scripts/release_pipeline.py \ --version "${TAG#v}" \ --mode release \ --from-artifacts \ --source-sha "$SHA" \ --workflow-run-id "$GITHUB_RUN_ID" \ --stop-after reverify \ --assets-dir ../assets \ --json # The attestation must leave through the workflow's artifact store, not # through the draft. `step_publish` used to re-hash the draft against the # draft's own `SHA256SUMS` -- a document that is itself one of the assets, # so anything able to replace an asset could replace the manifest in the # same motion and the check passed. - name: Upload the staging attestation for finalize to verify against uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: stage-attestation path: assets/stage-attestation.json if-no-files-found: error retention-days: 7 # The evidence bundle is a mandatory product of the run, so it is kept # where a reader can find it even if the release never goes public. - name: Upload the sealed evidence bundle uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-evidence path: assets/openai4s-*-evidence*.zip if-no-files-found: error retention-days: 90 pypi: name: Publish to PyPI with OIDC # A version number, once taken on an index, is taken forever, so this may # only run from a path where every check has already passed. In # `pypi_only` mode `attach` is deliberately skipped (there is no draft to # stage onto), so the status functions replace the implicit success(): # every other needs-failure still blocks this job via !failure(), and # attach is required to be either 'success' (publish) or exactly # 'skipped' (pypi_only). if: >- !failure() && !cancelled() && (inputs.publish || inputs.pypi_only) && (needs.attach.result == 'success' || (inputs.pypi_only && needs.attach.result == 'skipped')) needs: [freeze, guard, quality, build, macos-app, linux-app, windows-package, windows-launcher, attach] runs-on: ubuntu-latest timeout-minutes: 30 environment: name: pypi url: https://pypi.org/project/openai4s/ permissions: contents: read id-token: write steps: - name: Check out the pipeline at the frozen release SHA uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.sha }} persist-credentials: false path: source - name: Download verified distributions uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: python-package-distributions path: dist/ - name: Keep only index-distributable files # The artifact also carries build-receipt-dist.json — staging consumes # it, but the publish action hands twine the whole directory and twine # refuses anything that is not a wheel or an sdist (first complete # dispatch, run 32835188788: `InvalidDistribution: Unknown # distribution format: 'build-receipt-dist.json'`). Deleting here, in # this job's own workspace, leaves the artifact and every other # consumer of it untouched. run: | find dist -type f ! -name '*.whl' ! -name '*.tar.gz' -print -delete - name: Revalidate the remote annotated tag before PyPI publication env: TAG: ${{ inputs.tag }} SHA: ${{ github.sha }} working-directory: source run: bash scripts/revalidate_release_tag.sh "$TAG" "$SHA" - name: Publish package distributions uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 finalize: name: Make the GitHub release public # The last cross-channel step, and the only irreversible one on this side. # Flipping the draft inside `attach` — with PyPI running afterwards — meant # an OIDC failure, a denied environment approval or a rejected upload left # a public release with no matching package version. # # If this job fails after PyPI succeeded, the release stays a draft and # nothing needs rebuilding: re-run this workflow with the same tag, or run # `release_pipeline.py --only publish` by hand. Do not bump the version and # do not rebuild — the artifacts on the draft are the ones PyPI has. if: inputs.publish needs: [freeze, attach, pypi] runs-on: ubuntu-latest timeout-minutes: 20 permissions: contents: write # Only to dispatch publish-image.yml for the tag once it is public; see # the last step. actions: write steps: - name: Check out the pipeline at the frozen release SHA uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.sha }} # This job holds `contents: write`, so the checkout is immutable and # its token is never left in git configuration for repository code. persist-credentials: false path: source # The record the draft cannot rewrite. Downloaded rather than re-derived: # the whole point is that this job's view of "what was staged" does not # come from the mutable thing it is about to publish. - name: Download the staging attestation uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: stage-attestation path: attestation/ - name: Revalidate the remote annotated tag before release publication env: TAG: ${{ inputs.tag }} SHA: ${{ github.sha }} working-directory: source run: bash scripts/revalidate_release_tag.sh "$TAG" "$SHA" - name: Publish, once PyPI has the version env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} TAG: ${{ inputs.tag }} OPENAI4S_RELEASE_INPUTS_JSON: ${{ toJSON(github.event.inputs) }} run: | python source/scripts/release_pipeline.py \ --version "${TAG#v}" \ --mode release \ --only publish \ --attestation attestation/stage-attestation.json \ --workflow-run-id "$GITHUB_RUN_ID" \ --json # publish-image.yml listens for `release: published`, but the step above # makes the release public with this job's GITHUB_TOKEN, and GitHub starts # no workflow run for an event that token raised -- workflow_dispatch is # the documented exception. Without this the image never built and # `:latest` stayed on the previous version. `--ref` is the tag so the # image job's `github.sha` is the tagged commit its tag revalidation # compares against; `-f ref=` is the tag it builds and pushes. Unconditional # on purpose: steps stop at the first failure, so this runs only after a # successful publish. If this step alone fails, the release is already # public: run the same `gh workflow run` line by hand rather than # re-running the job. A duplicate dispatch only rebuilds the same tag # under publish-image's non-cancelling per-tag concurrency group. - name: Dispatch the container image for the published tag env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} TAG: ${{ inputs.tag }} run: | set -euo pipefail gh workflow run publish-image.yml --repo "$GH_REPO" --ref "$TAG" -f ref="$TAG"