Skip to content
Open
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
73 changes: 11 additions & 62 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
publish:
name: Publish
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 60
environment: npm-publish
permissions:
contents: write
Expand Down Expand Up @@ -83,6 +83,15 @@ jobs:
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: node scripts/validate-release-channel.mjs

- name: Guard stable release
if: github.event_name == 'pull_request'
env:
RELEASE_GUARD_TOKEN: ${{ secrets.RELEASE_GUARD_TOKEN }}
STABLE_RELEASE_GUARD_TIMEOUT_MINUTES: >-
${{ vars.STABLE_RELEASE_GUARD_TIMEOUT_MINUTES || '25' }}
VERSION: ${{ steps.version.outputs.version }}
run: node scripts/stable-release-guard.mjs

- name: Create release tag
if: github.event_name == 'pull_request'
env:
Expand Down Expand Up @@ -127,67 +136,7 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
VERSION: ${{ steps.version.outputs.version }}
DIST_TAG: ${{ steps.version.outputs.dist_tag }}
run: |
FAILED=0

publish_pkg() {
local filter="$1"
local name="$2"

# Check if this version is already published
if npm view "${name}@${VERSION}" version >/dev/null 2>&1; then
echo "⏭️ ${name}@${VERSION} already published — skipping"
return 0
fi

echo "📦 Publishing ${name}@${VERSION}..."
if pnpm --filter "$filter" publish --access public --no-git-checks --tag "$DIST_TAG"; then
echo "✅ ${name}@${VERSION} published"
else
echo "❌ ${name}@${VERSION} failed to publish"
FAILED=1
fi
}

publish_pkg "@hyperframes/parsers" "@hyperframes/parsers"
publish_pkg "@hyperframes/lint" "@hyperframes/lint"
publish_pkg "@hyperframes/studio-server" "@hyperframes/studio-server"
publish_pkg "@hyperframes/core" "@hyperframes/core"
publish_pkg "@hyperframes/sdk" "@hyperframes/sdk"
publish_pkg "@hyperframes/engine" "@hyperframes/engine"
publish_pkg "@hyperframes/player" "@hyperframes/player"
publish_pkg "@hyperframes/producer" "@hyperframes/producer"
publish_pkg "@hyperframes/shader-transitions" "@hyperframes/shader-transitions"
publish_pkg "@hyperframes/studio" "@hyperframes/studio"
publish_pkg "@hyperframes/aws-lambda" "@hyperframes/aws-lambda"
publish_pkg "@hyperframes/gcp-cloud-run" "@hyperframes/gcp-cloud-run"

# CLI is @hyperframes/cli in the monorepo but published as unscoped "hyperframes" on npm.
# Rewrite the name in package.json before publishing, then use npm publish directly
# since pnpm --filter won't match the rewritten name.
if npm view "hyperframes@${VERSION}" version >/dev/null 2>&1; then
echo "⏭️ hyperframes@${VERSION} already published — skipping"
else
node -e "
const fs = require('fs');
const p = 'packages/cli/package.json';
const pkg = JSON.parse(fs.readFileSync(p, 'utf8'));
pkg.name = 'hyperframes';
fs.writeFileSync(p, JSON.stringify(pkg, null, 2) + '\n');
"
echo "📦 Publishing hyperframes@${VERSION}..."
if (cd packages/cli && npm publish --access public --tag "$DIST_TAG"); then
echo "✅ hyperframes@${VERSION} published"
else
echo "❌ hyperframes@${VERSION} failed to publish"
FAILED=1
fi
fi

if [ "$FAILED" -ne 0 ]; then
echo "::error::One or more packages failed to publish"
exit 1
fi
run: node --import tsx scripts/publish-packages.ts

- name: Create GitHub Release
env:
Expand Down
54 changes: 54 additions & 0 deletions .github/workflows/release-guard-health.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: Release guard credential health

permissions: {}

on:
pull_request:
types: [opened, synchronize, reopened]
branches: [main]
paths:
- ".github/workflows/release-guard-health.yml"
- "scripts/stable-release-guard.mjs"
schedule:
- cron: "17 13 * * 1"
workflow_dispatch: {}

jobs:
health:
name: Verify release guard credential health
if: >-
(github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository) ||
(github.event_name != 'pull_request' &&
github.ref == format('refs/heads/{0}', github.event.repository.default_branch))
runs-on: ubuntu-latest
timeout-minutes: 45
environment:
name: release-guard-health
deployment: false
permissions:
contents: read
env:
EXPECTED_HEALTH_SHA: >-
${{ github.event_name == 'pull_request'
&& github.event.pull_request.head.sha
|| github.sha }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ env.EXPECTED_HEALTH_SHA }}

- name: Verify immutable health checkout
run: |
ACTUAL_SHA="$(git rev-parse HEAD)"
EXPECTED_COMMIT_SHA="$(git rev-parse "${EXPECTED_HEALTH_SHA}^{commit}")"
if [ "$ACTUAL_SHA" != "$EXPECTED_COMMIT_SHA" ]; then
echo "::error::Expected health source $EXPECTED_COMMIT_SHA, checked out $ACTUAL_SHA"
exit 1
fi

- name: Verify release guard health
env:
GITHUB_REPOSITORY: ${{ github.repository }}
RELEASE_GUARD_TOKEN: ${{ secrets.RELEASE_GUARD_TOKEN }}
run: node scripts/stable-release-guard.mjs --health
87 changes: 87 additions & 0 deletions docs/contributing/release-channels.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,90 @@ The publish workflow validates release channel boundaries before publishing:
- Every publish job verifies that its checkout matches the immutable event SHA.

This prevents an alpha-only feature from being included in a stable hotfix by accident.

### Stable guard maintenance

Stable publishing requires the environment-scoped secret `RELEASE_GUARD_TOKEN`. Its named owner must
provision and rotate a dedicated fine-grained personal access token with an explicit expiry and only
these repository permissions: Administration (read-only) and Pull requests (read-only). Metadata read
access is added automatically by GitHub. Scope the token to this repository only; do not grant
Contents, Packages, Workflows, or organization write access. Store the credential under the same name
in both the `release-guard-health` and `npm-publish` environments; never create it as a repository or
organization secret. The publish workflow passes this credential only to the read-only stable guard;
checkout, tag creation, npm provenance, and GitHub Release writes continue to use their existing
credentials.

Before the initial upload and before every rotation, run this read-only API verification. It fails
unless the exact environment has a non-bypassable required-reviewer boundary with self-review
prevention and at least one reviewer. Do not upload either environment-secret copy unless it exits 0.

{/* release-guard-environment-verification:start */}
```bash
ENVIRONMENT_JSON="$(gh api repos/heygen-com/hyperframes/environments/release-guard-health)"
jq -e '
.can_admins_bypass == false and
any(
.protection_rules[]?;
.type == "required_reviewers" and
.prevent_self_review == true and
((.reviewers // []) | length >= 1)
)
' <<<"$ENVIRONMENT_JSON" >/dev/null || {
echo "release-guard-health is not protected; do not upload RELEASE_GUARD_TOKEN." >&2
exit 1
}
echo "release-guard-health protection verified; environment secret upload may proceed."
```
{/* release-guard-environment-verification:end */}

After provisioning or rotation, an administrator must run the read-only capability check from a
protected environment that injects the secret without putting its value on the command line:

```bash
GITHUB_REPOSITORY=heygen-com/hyperframes \
RELEASE_GUARD_PROBE_PR=<existing-pr-number> \
RELEASE_GUARD_PROBE_SHA=<existing-commit-sha> \
node scripts/stable-release-guard.mjs --preflight
```

The protected environment must supply `RELEASE_GUARD_TOKEN`. The check reads effective `main`
rules, repository rule suites, reviews for the probe PR, and check runs for the probe SHA. Public
check-run reads may not expose a separate fine-grained permission in GitHub's PAT UI, so this
same-client capability proof is authoritative. It reports only endpoint/status/request-ID
classification on failure and never prints response bodies, headers, or the credential. Record a
successful check before merge. If the secret is missing, expired, incorrectly scoped, or the
capability check does not pass, this PR must not merge and the stable workflow will fail before
creating a tag.

The separate release-guard credential health workflow repeats these reads on same-repository pull
requests, every Monday, and through a safe manual run after provisioning or rotation. The pull-request
trigger is the bootstrap path before the workflow exists on the default branch (GitHub does not
register `workflow_dispatch` there until after merge): each PR run pins checkout to the exact head SHA
and refuses fork pull requests. Because that source is still mutable PR code, the credential-bearing
job references the `release-guard-health` environment and cannot start until a required reviewer
approves that exact run. Configure the environment with required reviewers (at least one), prevent
self-review enabled, and administrator bypass disabled **before** uploading its environment secret.
An unconfigured environment or missing environment secret is a fail-closed blocker, not a reason to
fall back to a repository secret. Scheduled and manual runs are restricted to the repository's default
branch. The check discovers a recent merged `main` pull request and verifies effective rules, its exact
month-window rule suite, reviews, and check runs. It has only `contents: read` on the built-in token,
passes `RELEASE_GUARD_TOKEN` only to the health step, and has no release, tag, package, or publish path.

Required checks remain dynamically sourced from effective rules, but each must identify a concrete
GitHub App integration so the guard can reduce actual check runs. A new required context without a
positive integration ID is unsupported and fails closed; the guard never falls back to legacy commit
statuses. Configured stable-guard timeouts must be 15–40 minutes, with a 25-minute default.

The stable guard intentionally fails closed when GitHub adds a rule type it does not recognize.
If a release reports `Unsupported effective repository rule`, inspect the effective rules for
`main` and decide whether the new rule needs an explicit check or is already enforced by the
exact update rule-suite result. Add a rule to `NON_CHECK_RULES` only when a `pass` rule suite
fully covers it; never soften the unknown-rule failure into a warning.

After updating and reviewing the guard, rerun the original merged-PR workflow for recovery.
Do not push a stable tag, select a different SHA, or introduce a manual publish path.

Bypass rejection is intentional: a ruleset result of `bypass` permanently blocks that merge SHA
from publishing. Release PRs must merge normally with their required approval and checks. If a
release PR was bypass-merged, prepare a fresh release PR and merge it normally; rerunning the
bypassed event cannot turn its historical rule-suite result into `pass`.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
"player:perf": "bun run --filter @hyperframes/player perf",
"format:check": "oxfmt --check .",
"knip": "knip",
"test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs && vitest run scripts/catalog/",
"test:scripts": "node --import tsx --test scripts/release-packages.test.ts scripts/publish-packages.test.ts scripts/stable-release-guard.test.mjs scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs && vitest run scripts/catalog/",
"typecheck:scripts": "tsc --noEmit -p scripts/tsconfig.json",
"test:skills": "node --test 'skills/**/*.test.mjs'",
"generate:previews": "tsx scripts/generate-template-previews.ts",
Expand Down
Loading
Loading