Skip to content
Closed
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
106 changes: 100 additions & 6 deletions .github/workflows/npm-stage-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ name: npm stage publish
#
# Auth: OIDC trusted publishing only — id-token: write, NO long-lived NPM_TOKEN.
# Each @perryts/* package must list this workflow + the `npm-publish` environment
# as a trusted publisher on npmjs.com (same one-time setup npm/README.md
# describes for release-packages.yml). The auth-posture gate in
# as its one trusted publisher on npmjs.com, with the `npm stage publish`
# action explicitly allowed (see docs/src/contributing/releasing.md).
# The auth-posture gate in
# scripts/publish/auth-posture.mts refuses any long-lived token present here.
#
# Guardrails: dispatches for the same dist-tag are serialized (concurrency:
Expand All @@ -44,6 +45,10 @@ on:
description: 'Reuse an existing release-packages.yml stage-mode build run instead of dispatching a new one.'
type: string
default: ''
candidate-sha:
description: 'Expected release-candidate commit. Required when publish=true; protects a branch-tip race.'
type: string
default: ''

permissions:
contents: read
Expand All @@ -57,11 +62,69 @@ concurrency:
cancel-in-progress: false

jobs:
# A real registry stage is a release operation even though it is not public
# yet. Require the same exact-SHA gates as the release workflow before
# spending the cross-platform build or writing staged registry entries.
release-gates:
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
steps:
- name: Require full Tests + Simulator Tests on this commit
if: inputs.publish
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
REF: ${{ github.ref }}
REF_NAME: ${{ github.ref_name }}
SHA: ${{ github.sha }}
EXPECTED_SHA: ${{ inputs.candidate-sha }}
run: |
set -euo pipefail
if [[ "$REF" != refs/heads/* ]] || [ "$REF_NAME" = "main" ]; then
echo "::error::A real staged publish requires a named non-main candidate branch; got $REF." >&2
exit 1
fi
if [ -z "$EXPECTED_SHA" ]; then
echo "::error::candidate-sha is required for a real staged publish. Use the local npm run publish:stage command." >&2
exit 1
fi
if [ "$SHA" != "$EXPECTED_SHA" ]; then
echo "::error::Candidate branch moved: workflow is on $SHA, but the dispatcher pinned $EXPECTED_SHA." >&2
exit 1
fi
Comment thread
proggeramlug marked this conversation as resolved.
tests=$(gh api --paginate --slurp \
"/repos/$REPO/actions/workflows/test.yml/runs?head_sha=$SHA&per_page=100")
full_run=""
for run_id in $(echo "$tests" | jq -r '.[] | .workflow_runs[] | select(.status == "completed" and .conclusion == "success") | .id'); do
if gh api "/repos/$REPO/actions/runs/$run_id/jobs?per_page=100" \
--jq '.jobs[] | select(.name == "full-suite-gate" and .conclusion == "success") | .name' \
| grep -q full-suite-gate; then
full_run="$run_id"
break
fi
done
if [ -z "$full_run" ]; then
echo "::error::No successful full-tier test.yml run (full-suite-gate) exists on $SHA. Dispatch test.yml with tier=full first." >&2
exit 1
fi
sim_runs=$(gh api --paginate --slurp \
"/repos/$REPO/actions/workflows/simctl-tests.yml/runs?head_sha=$SHA&per_page=100")
sim_run=$(echo "$sim_runs" | jq -r \
'first(.[] | .workflow_runs[] | select(.status == "completed" and .conclusion == "success") | .id) // empty')
if [ -z "$sim_run" ]; then
echo "::error::No successful simctl-tests.yml run exists on $SHA. Dispatch it first." >&2
exit 1
fi
echo "Release gates passed on $SHA: test run $full_run, simulator run $sim_run."

# Resolve the build run to stage from: either a caller-supplied build-run-id
# (re-use), or dispatch release-packages.yml in stage mode and capture its
# run id. The build matrix itself lives in release-packages.yml — single
# source of truth, no drift.
resolve-build:
needs: release-gates
runs-on: ubuntu-latest
permissions:
actions: write # dispatch release-packages.yml (stage mode) + watch it
Expand All @@ -76,6 +139,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
EXISTING: ${{ inputs.build-run-id }}
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
# The commit THIS workflow run is dispatching from — every downstream
Expand Down Expand Up @@ -109,8 +173,9 @@ jobs:
fi
echo "Reusing build run $RUN_ID (verified: Release Packages, success, sha $RUN_SHA)."
else
# Dispatch release-packages.yml in stage mode, pinned to OUR exact
# commit via --ref. Without this, `gh workflow run` dispatches
# Dispatch release-packages.yml in stage mode on our named branch,
# after proving that its remote tip is OUR exact commit. Without
# this, `gh workflow run` dispatches
# against the repo's default branch tip at call time — which can
# differ from the commit this npm-stage-publish.yml run itself
# checked out (a different ref triggered it, or main advanced in
Expand All @@ -120,15 +185,20 @@ jobs:
# preflight job's else-branch sets MODE=stage), so no other input
# is needed.
DISPATCHED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
gh workflow run release-packages.yml -R "$REPO" --ref "$CUR_SHA"
TIP_SHA=$(gh api "/repos/$REPO/git/ref/heads/$REF_NAME" --jq '.object.sha')
if [ "$TIP_SHA" != "$CUR_SHA" ]; then
echo "::error::$REF_NAME moved to $TIP_SHA, but this staging run is on $CUR_SHA — re-dispatch from a pinned candidate branch." >&2
exit 1
fi
gh workflow run release-packages.yml -R "$REPO" --ref "$REF_NAME"
# Poll for the workflow_dispatch run we just created. Match by
# event + createdAt AFTER the dispatch timestamp AND headSha ==
# our commit — createdAt alone could still match a concurrent
# dispatch of the SAME workflow from a different ref that happened
# to land in the same window, which would stage the wrong commit.
sleep 5
RUN_ID=""
for i in $(seq 1 20); do
for _attempt in $(seq 1 20); do
RUN_ID=$(gh run list --workflow release-packages.yml -R "$REPO" \
--event workflow_dispatch --limit 5 \
--json databaseId,createdAt,headSha \
Expand Down Expand Up @@ -298,3 +368,27 @@ jobs:
# code, same gate, whether it runs in CI or locally.
if: env.PUBLISH == 'true'
run: node scripts/publish/pipeline.mts --scan-only

- name: Bundle exact staged-package proofs
if: env.PUBLISH == 'true'
run: |
set -euo pipefail
mapfile -t proofs < <(
find npm -mindepth 2 -maxdepth 2 -type f -name '*.tgz' | sort
)
if [ "${#proofs[@]}" -ne 9 ]; then
echo "::error::Expected 9 exact tarballs after verify + scan; found ${#proofs[@]}." >&2
printf ' %s\n' "${proofs[@]}" >&2
exit 1
fi
tar -cf npm-staged-package-proofs.tar "${proofs[@]}"

- name: Retain exact staged-package proofs for local 2FA approval
if: env.PUBLISH == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: npm-staged-package-proofs
path: npm-staged-package-proofs.tar
if-no-files-found: error
retention-days: 7
compression-level: 0 # the nine .tgz payloads are already compressed
19 changes: 11 additions & 8 deletions .github/workflows/release-packages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ on:
workflow_dispatch:
inputs:
cut_release:
description: "Tag-last release: gate on tests, build every leg, then create the vX.Y.Z tag (version from Cargo.toml) + GitHub Release and publish. Dispatch on a branch pinned at the release-candidate commit."
description: "Legacy build-before-tag route: gates/builds, then tags before npm. Normal releases use npm run publish:stage/approve for registry-first tag-last ordering."
type: boolean
default: false
existing_tag:
Expand Down Expand Up @@ -48,10 +48,10 @@ jobs:
# Modes:
# release release: published event (legacy tag-first path — a human
# created the tag + release; still fully supported)
# cut-release workflow_dispatch with cut_release=true — tag-LAST: nothing
# is tagged or published until the gate + every build leg is
# green; create-release then makes the tag + GH release and
# the publish legs run in this same workflow run
# cut-release workflow_dispatch with cut_release=true — legacy route:
# nothing is tagged until gate + build legs are green, but
# the tag still precedes npm. The staged local pipeline is
# canonical when registry-first/tag-last ordering is required.
# republish workflow_dispatch with existing_tag=vX.Y.Z — rebuild and
# re-run the publish legs for an already-published release
# (the old "bypass" lever, now with an explicit tag)
Expand Down Expand Up @@ -1971,9 +1971,12 @@ jobs:
# ---------------------------------------------------------------------------
# Publish npm packages (@perryts/perry + 8 per-platform packages)
#
# Uses OIDC / Trusted Publishers (no long-lived NPM_TOKEN). Each of the 9
# package names must be registered on npmjs.com with this repo + workflow
# as a Trusted Publisher. See npm/README.md for the one-time setup.
# Uses OIDC / Trusted Publishers (no long-lived NPM_TOKEN). This direct npm
# leg is legacy: npm permits only one trusted publisher per package, while
# Perry's canonical posture assigns that slot to npm-stage-publish.yml with
# the `npm stage publish` action. A normal registry-first release reaches
# this release-event job only after all versions are live, so its idempotency
# checks skip every direct publish. See the release runbook.
# ---------------------------------------------------------------------------
npm-publish:
# The irreversible leg. build-cross is in `needs` (completed, not
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ __pycache__/
# Pinned Node oracle for the builtin-module compat matrix
# (scripts/node_compat_matrix.mjs downloads + SRI-verifies + caches here).
.cache/node-pin/
# Local receipts for the staged npm release pipeline. These must survive
# between `publish:stage` and `publish:approve`, but must never make a clean
# release-candidate checkout appear dirty or be committed.
.cache/perry/publish-pipeline/

# Android Gradle: caches and build outputs are regenerable. Source under
# android-build/ that we DO track: build.gradle.kts files, gradle wrapper,
Expand Down
Loading
Loading