diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 485d6af..70e5401 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -3,9 +3,6 @@ name: Publish Yield packages on: push: branches: [main] - workflow_run: - workflows: ["Release Yield"] - types: [completed] workflow_dispatch: inputs: version: @@ -22,10 +19,7 @@ concurrency: jobs: resolve: - if: >- - github.repository == 'operatorstack/yield' && - (github.event_name != 'workflow_run' || - (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main')) + if: github.repository == 'operatorstack/yield' runs-on: ubuntu-latest outputs: publish: ${{ steps.release.outputs.publish }} @@ -39,7 +33,7 @@ jobs: with: fetch-depth: 0 persist-credentials: false - ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.event_name == 'workflow_dispatch' && format('v{0}', inputs.version) || github.sha }} + ref: ${{ github.event_name == 'workflow_dispatch' && format('v{0}', inputs.version) || github.sha }} - id: release name: Resolve immutable source, version, and channel env: @@ -54,18 +48,8 @@ jobs: channel=canary dist_tag=canary else - if [[ "$GITHUB_EVENT_NAME" == workflow_run ]]; then - tag="$(git tag --points-at HEAD --list 'v[0-9]*' --sort=-v:refname | head -n 1)" - if [[ -z "$tag" ]]; then - echo "publish=false" >> "$GITHUB_OUTPUT" - echo "Release run created no tag; this was a dry run." - exit 0 - fi - version="${tag#v}" - else - version="$REQUESTED_VERSION" - tag="v${version}" - fi + version="$REQUESTED_VERSION" + tag="v${version}" [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] test "$(git rev-list -n 1 "v${version}")" = "$source_sha" channel=stable diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f0768c5..72a5b82 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -81,6 +81,7 @@ jobs: runs-on: ubuntu-latest environment: release-control permissions: + actions: write contents: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -131,3 +132,8 @@ jobs: --notes-file "$RUNNER_TEMP/release-notes.md" \ --draft \ --verify-tag + - name: Dispatch the protected package publisher + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.confirm.outputs.version }} + run: gh workflow run npm-publish.yml --repo "$GITHUB_REPOSITORY" --ref main -f version="$VERSION" diff --git a/scripts/check-release-control.mjs b/scripts/check-release-control.mjs index 06a7cc9..dd9114c 100644 --- a/scripts/check-release-control.mjs +++ b/scripts/check-release-control.mjs @@ -58,7 +58,7 @@ export async function checkReleaseControl(root = resolve(import.meta.dirname, ". expect(release, "release.yml is required"); expect(JSON.stringify(Object.keys(release.on ?? {}).sort()) === JSON.stringify(["workflow_dispatch"]), "stable release must be dispatch-only"); expect(release.permissions?.contents === "read", "release planning must be read-only"); - expect(release.jobs?.release?.permissions?.contents === "write", "tag creation alone needs contents:write"); + expect(release.jobs?.release?.permissions?.contents === "write" && release.jobs?.release?.permissions?.actions === "write", "the controller needs tag and publisher-dispatch authority"); expect(release.jobs?.release?.environment === "release-control", "release authorization must use the protected release-control environment"); expect(raw["release.yml"].includes('repos/$GITHUB_REPOSITORY/git/refs'), "release controller must create tags with its scoped GitHub token"); expect(!raw["release.yml"].includes('git push origin "refs/tags/$TAG"'), "release controller must not push tags without explicit authentication"); @@ -69,7 +69,7 @@ export async function checkReleaseControl(root = resolve(import.meta.dirname, ". expect(publisher, "npm-publish.yml is required because both registry trust policies bind to this workflow identity"); expect(publisher.permissions?.contents === "read", "package publisher must default to read-only source access"); expect(publisher.on?.push?.branches?.includes("main"), "npm canary must follow public main"); - expect(publisher.on?.workflow_run?.workflows?.includes("Release Yield"), "stable packages must consume the release controller receipt"); + expect(publisher.on?.workflow_dispatch?.inputs?.version?.required === true, "stable packages must require an exact version dispatch"); expect(publisher.jobs?.npm?.permissions?.contents === "read" && publisher.jobs?.npm?.permissions?.["id-token"] === "write", "npm publisher must use read-only source plus OIDC"); expect(publisher.jobs?.pypi?.permissions?.contents === "read" && publisher.jobs?.pypi?.permissions?.["id-token"] === "write", "PyPI publisher must use read-only source plus OIDC"); expect(publisher.jobs?.crates?.permissions?.contents === "read" && publisher.jobs?.crates?.permissions?.["id-token"] === "write", "crates.io publisher must use read-only source plus OIDC"); @@ -98,6 +98,7 @@ export async function checkReleaseControl(root = resolve(import.meta.dirname, ". expect(raw["release-finalize.yml"].includes("pypi-release.mjs verify"), "finalization must verify the PyPI wheel hashes"); expect(raw["release-finalize.yml"].includes("crates-release.mjs verify"), "finalization must verify the crates.io package hashes"); expect(raw["release-finalize.yml"].includes("--name \"crates-${version}-${SOURCE_SHA}\""), "finalization must consume the publisher-produced crates receipt"); + expect(raw["release.yml"].includes("gh workflow run npm-publish.yml"), "the release controller must dispatch the trusted-publishing event after tagging"); expect(!Object.values(raw).some((text) => text.includes("CRATES_BOOTSTRAP_TOKEN")), "crates.io publishing must not use a bootstrap token"); for (const [name, text] of Object.entries(raw)) { expect(!/NPM_TOKEN|NODE_AUTH_TOKEN|PYPI_TOKEN|secrets\.(npm|pypi)|password:/i.test(text), `${name}: long-lived registry credentials are forbidden`);