Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 4 additions & 20 deletions .github/workflows/npm-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,6 @@ name: Publish Yield packages
on:
push:
branches: [main]
workflow_run:
workflows: ["Release Yield"]
types: [completed]
workflow_dispatch:
inputs:
version:
Expand All @@ -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 }}
Expand All @@ -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:
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ jobs:
runs-on: ubuntu-latest
environment: release-control
permissions:
actions: write
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
Expand Down Expand Up @@ -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"
5 changes: 3 additions & 2 deletions scripts/check-release-control.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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");
Expand Down Expand Up @@ -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`);
Expand Down