name: Publish @openmaic packages # Publishes the @openmaic/* package family (dsl, generation, storage, renderer, editor, importer) to npm. # # Scope is pinned to those five packages by name on purpose: the workspace also # contains vendored forks (mathml2omml, pptxgenjs) whose names we do NOT own, so # a publish must never touch them. # # THE ONLY RELEASE INPUT IS A VERSION BUMP THAT LANDED ON MAIN. # # A package's version in its manifest is the release intent, and merging that # bump is what releases it. Which packages go out is decided by comparing those # manifests against the registry, never by how the run was triggered. That keeps # one source of truth, puts every release through pull request review and CI, # and is the only shape a branch protection rule can actually guard. # # `@openmaic/@` tags are an OUTPUT of a release, written after a # package lands on the registry. They are markers, not triggers: a tag cannot # start a publish, because tags can be created by anyone with write access on # any commit, including commits that never reached main. # # Triggers: # - push to `main` touching one of the @openmaic/* package manifests # (real publish; the normal path) # - manual run via the Actions tab. Validation runs from any ref; unchecking # `dry_run` performs a real publish and is therefore restricted to `main` by # the `release` environment. # # REQUIRED REPOSITORY SETUP: # - npm org `openmaic` created and owning the `@openmaic` scope # - a GitHub Environment named `release` whose deployment branch rule allows # ONLY `main`, holding NPM_TOKEN (an automation/granular token with publish # rights to @openmaic/*, 2FA set to "auth only") # - NPM_TOKEN must NOT also exist as a repository secret. A repository secret # is readable by a workflow on any branch, which would let a branch-local # edit of this file collect the token without the environment. on: push: branches: [main] paths: - "packages/@openmaic/dsl/package.json" - "packages/@openmaic/generation/package.json" - "packages/@openmaic/storage/package.json" - "packages/@openmaic/renderer/package.json" - "packages/@openmaic/editor/package.json" - "packages/@openmaic/importer/package.json" workflow_dispatch: inputs: dry_run: description: "Pack and validate only, do not publish" type: boolean default: true # One publication at a time for the whole repository. Keying this on the ref # would let two runs race for the same version, each deciding independently that # the version is unpublished. concurrency: group: publish-openmaic cancel-in-progress: false jobs: # A full commit SHA is the only immutable GitHub Action reference. Version # comments beside each pin keep the selected release human-readable. # SECURITY BOUNDARY: package installation, builds and packing happen only in # `validate`, before any job can read NPM_TOKEN. `publish` receives immutable # tarballs and verifies their digests before giving those exact files to npm. # This structurally removes build-code tampering with the git index, validated # bytes differing from published bytes, and build code rewriting enforcement # scripts before the token-bearing step uses them: the token-bearing job runs # neither install nor build code. # Everything that does not need the token, so it can run from any ref and a # dry run stays useful to contributors. validate: name: Validate release candidate runs-on: ubuntu-latest # This job runs `pnpm install` and the package builds, which execute # third-party code. It gets no write scope and no git credential. permissions: contents: read # storage's PostgreSQL contract suites skip themselves without a database. # Publishing storage without ever running them would ship the one backend # whose behaviour only a real PostgreSQL can confirm. services: postgres: image: postgres:16 env: POSTGRES_DB: openmaic POSTGRES_PASSWORD: postgres ports: - 5432:5432 options: >- --health-cmd "pg_isready -U postgres -d openmaic" --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22 cache: pnpm # ci.yml is a separate workflow, so its copy of the version check cannot # stop a release that reaches here with CI red, skipped, or never run. # This decides, against the registry, which packages may be published, and # refuses to reuse or downgrade a published version. - name: Validate package versions run: node scripts/check-package-version-bumps.mjs --release - run: pnpm install --frozen-lockfile # Build in dependency order (dsl first); generation/storage/renderer/editor/importer # resolve @openmaic/dsl through the workspace link. - name: Build @openmaic packages run: >- pnpm -r --filter "@openmaic/dsl" --filter "@openmaic/generation" --filter "@openmaic/storage" --filter "@openmaic/renderer" --filter "@openmaic/editor" --filter "@openmaic/importer" run build # Some packages generate tracked, publishable files as part of `build` # (renderer's fonts.css and its KaTeX font snapshot). If the committed # copies are stale, the build rewrites them here and the registry would # receive content that is not in the commit being released. # # Compare against GITHUB_SHA, not the index or HEAD. A bare `git diff` # misses staged changes, while build code can create a commit and move # HEAD. This check exists precisely because build code ran before it, so # its reference point must be something that code cannot move. Disabling # replacement objects also prevents a local replace ref from redirecting # the commit comparison. - name: Verify the build did not rewrite tracked files run: | set -euo pipefail actual_head="$(git --no-replace-objects rev-parse HEAD)" if [ "$actual_head" != "$GITHUB_SHA" ]; then echo "::error::HEAD moved from $GITHUB_SHA to $actual_head during the build." exit 1 fi if ! git --no-replace-objects diff --quiet "$GITHUB_SHA"; then echo "::error::The build modified tracked repository files." echo "Regenerate them and commit the result before releasing." git --no-replace-objects --no-pager diff --stat "$GITHUB_SHA" exit 1 fi echo "Tracked repository files match the commit being released." # Seal the checked build before any repository test code runs. Uploading # immediately after packing makes the publish input immutable before the # tests or smoke test can write to their local copies. `needs: validate` # still prevents publication unless every later validation step passes. # # Pack each package exactly once. pnpm resolves workspace:^ in the packed # manifest, while the config form suppresses prepack and prepare (pnpm # pack does not accept --ignore-scripts). - name: Pack release artifacts and record digests env: RELEASE_ARTIFACTS: ${{ runner.temp }}/openmaic-package-tarballs run: | set -euo pipefail mkdir "$RELEASE_ARTIFACTS" for pkg in dsl generation storage renderer editor importer; do echo "::group::@openmaic/$pkg" ( cd "packages/@openmaic/$pkg" \ && pnpm pack --config.ignore-scripts=true --pack-destination "$RELEASE_ARTIFACTS" ) echo "::endgroup::" done node scripts/verify-package-artifacts.mjs --write "$RELEASE_ARTIFACTS" - name: Verify freshly packed release artifacts env: RELEASE_ARTIFACTS: ${{ runner.temp }}/openmaic-package-tarballs run: node scripts/verify-package-artifacts.mjs "$RELEASE_ARTIFACTS" # actions/upload-artifact v4 artifacts cannot be modified after upload. # Tests below keep using the same local tarballs, while `publish` receives # this already-uploaded snapshot only after the whole validate job passes. - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: openmaic-package-tarballs-${{ github.sha }} path: ${{ runner.temp }}/openmaic-package-tarballs if-no-files-found: error overwrite: true retention-days: 7 # storage runs separately, under the json reporter, so that the run leaves # a record of WHICH suites executed. STORAGE_PG_CONTRACT_REQUIRED only # protects against a missing database: it is a `throw` inside the test # modules, so it never fires if vitest stops collecting those modules at # all, and what decides collection is storage's vitest.config.ts — an # ignored publishable input, so excluding `*.pg.test.ts` there needs no # version bump and leaves the run green. The assertion below is outside # the tests for exactly that reason. - name: Test & typecheck env: PG_CONTRACT_URL: postgresql://postgres:postgres@localhost:5432/openmaic STORAGE_PG_CONTRACT_REQUIRED: "1" STORAGE_VITEST_RESULTS: ${{ runner.temp }}/storage-vitest.json STORAGE_PG_BASELINE: ${{ runner.temp }}/storage-pg-baseline.json run: | set -euo pipefail pnpm --filter "@openmaic/dsl" \ --filter "@openmaic/generation" --filter "@openmaic/renderer" \ --filter "@openmaic/editor" \ --filter "@openmaic/importer" run test node scripts/assert-pg-contract-suites.mjs --capture-baseline "$STORAGE_PG_BASELINE" pnpm --filter "@openmaic/storage" exec \ vitest run --reporter=default --reporter=json \ --outputFile.json="$STORAGE_VITEST_RESULTS" node scripts/assert-pg-contract-suites.mjs "$STORAGE_VITEST_RESULTS" \ --baseline "$STORAGE_PG_BASELINE" pnpm --filter "@openmaic/dsl" --filter "@openmaic/generation" \ --filter "@openmaic/storage" --filter "@openmaic/renderer" \ --filter "@openmaic/editor" run typecheck # KNOWN LIMITATION: this smoke test reads the writable local directory, # not the uploaded snapshot. Because upload already happened, a test # process can replace a poisoned local tarball with a valid one and make # this pass while `publish` downloads the poisoned snapshot, whose own # SHA256SUMS can still be internally consistent. This cannot be closed # while packing, uploading, and testing share one job and filesystem; # closing it requires a separate packing-only job that runs no tests. - name: Smoke-test publishable tarballs env: RELEASE_ARTIFACTS: ${{ runner.temp }}/openmaic-package-tarballs run: pnpm test:package-tarballs -- "$RELEASE_ARTIFACTS" # The only job that can reach NPM_TOKEN. It is separate so the environment, # and therefore the token, is never attached to a run that is merely # validating. publish: name: Publish to npm needs: validate if: github.event_name == 'push' || inputs.dry_run == false runs-on: ubuntu-latest environment: release outputs: published_versions: ${{ steps.publish.outputs.published_versions }} permissions: actions: read # required to read this commit's CI conclusion id-token: write # required for npm provenance steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: # The first-parent check needs real history. fetch-depth: 0 # Publishing holds no repository credential. Writing release markers # remains isolated in the `mark` job. persist-credentials: false - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22 registry-url: https://registry.npmjs.org - name: Download validated package tarballs uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: openmaic-package-tarballs-${{ github.sha }} path: ${{ runner.temp }}/openmaic-package-tarballs - name: Verify validated package tarballs run: node scripts/verify-package-artifacts.mjs "$RELEASE_ARTIFACTS" env: RELEASE_ARTIFACTS: ${{ runner.temp }}/openmaic-package-tarballs # The environment's branch rule is the real boundary; this is defence in # depth, and it catches the case the rule cannot see. First-parent only: # plain reachability would also accept every intermediate commit of every # branch merged with a merge commit, including states no reviewer ever # saw, such as a bad tree reverted by the next commit in the same pull # request. - name: Require a commit on main's first-parent history run: | set -euo pipefail git fetch --no-tags origin main # Not `| grep -qx`: grep exits at the first match, git rev-list then # dies of SIGPIPE, and pipefail turns a found commit into a failure # as soon as main's history outgrows the pipe buffer. if [ "$(git rev-list --first-parent origin/main | grep -cx "$GITHUB_SHA" || true)" -eq 0 ]; then echo "::error::Refusing to publish $GITHUB_SHA: it is not on main's first-parent history." echo "Release a commit that landed on main, not one merged underneath it." exit 1 fi echo "$GITHUB_SHA is on main's first-parent history." # Being on main is not the same as having passed the gate. ci.yml runs # concurrently with this workflow on a push to main and blocks nothing, so # wait for CI on this exact commit and require it to be green. - name: Require a green CI run for this commit env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail # Identify the run by workflow file and triggering event, not by a # check-run display name: names are not unique, and any other workflow # or app publishing a check with the same name could otherwise stand # in for a red version gate. query="repos/$GITHUB_REPOSITORY/actions/workflows/ci.yml/runs?head_sha=$GITHUB_SHA&event=push&per_page=100" deadline=$((SECONDS + 1800)) while :; do gh api "$query" > "$RUNNER_TEMP/ci-runs.json" status="$(jq -r '([.workflow_runs[]] | sort_by(.run_started_at) | last // {}) | .status // "absent"' "$RUNNER_TEMP/ci-runs.json")" conclusion="$(jq -r '([.workflow_runs[]] | sort_by(.run_started_at) | last // {}) | .conclusion // "none"' "$RUNNER_TEMP/ci-runs.json")" if [ "$status" = "completed" ]; then if [ "$conclusion" = "success" ]; then echo "CI succeeded for $GITHUB_SHA." break fi echo "::error::Refusing to publish $GITHUB_SHA: CI concluded \"$conclusion\"." exit 1 fi if [ "$SECONDS" -ge "$deadline" ]; then echo "::error::No completed CI run for $GITHUB_SHA within the wait window (status: $status)." echo "Release from a commit that CI has actually validated on a push to main." exit 1 fi echo "Waiting for CI on $GITHUB_SHA (status: $status)..." sleep 30 done # No install or build runs in this job. This whole-tree check is defence # in depth after repository code has run. It exists precisely because # code ran before it, so its reference point must be GITHUB_SHA, which # that code cannot move, rather than HEAD, which it can. Disabling # replacement objects prevents local indirection during the comparison. - name: Verify the release checkout is unchanged run: | set -euo pipefail actual_head="$(git --no-replace-objects rev-parse HEAD)" if [ "$actual_head" != "$GITHUB_SHA" ]; then echo "::error::HEAD moved from $GITHUB_SHA to $actual_head before publication." exit 1 fi if ! git --no-replace-objects diff --quiet "$GITHUB_SHA"; then echo "::error::Tracked repository files differ from the release commit." git --no-replace-objects --no-pager diff --stat "$GITHUB_SHA" exit 1 fi # Publish one package at a time. A single recursive publish that dies # partway leaves earlier packages on the registry with nothing recording # it; per-package publishing makes an ordinary re-run pick up exactly # where it stopped, because a version already on the registry is no # longer in the plan. # The registry preflight is deliberately in this token-bearing step, # immediately before the sequential loop, to minimize the race window. - name: Registry preflight and publish validated tarballs id: publish env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_CONFIG_PROVENANCE: "true" RELEASE_PLAN_PATH: ${{ runner.temp }}/release-plan.json RELEASE_ARTIFACTS: ${{ runner.temp }}/openmaic-package-tarballs run: | set -euo pipefail published_versions="" # Anchor the validated digests in this shell before any repository # script runs with the npm token. A child process can alter files, # but it cannot rewrite its parent shell's scalar. trusted_digests="$(< "$RELEASE_ARTIFACTS/SHA256SUMS")" node scripts/verify-package-artifacts.mjs "$RELEASE_ARTIFACTS" node scripts/check-package-version-bumps.mjs --release for pkg in dsl generation storage renderer editor importer; do name="@openmaic/$pkg" version="$(node -p "require('./packages/@openmaic/$pkg/package.json').version")" planned="$(node -e 'const plan = require(process.env.RELEASE_PLAN_PATH); const [name, version] = process.argv.slice(1); process.stdout.write(plan.some((e) => e.package === name && e.version === version) ? "yes" : "no");' \ "$name" "$version")" if [ "$planned" != "yes" ]; then echo "Skipping $name@$version: already on the registry." continue fi filename="openmaic-$pkg-$version.tgz" tarball="$RELEASE_ARTIFACTS/$filename" expected_digest="$( printf '%s\n' "$trusted_digests" | awk -v filename="$filename" \ '$2 == filename { print $1 }' )" if [ -z "$expected_digest" ]; then echo "::error::$filename has no validated SHA-256 digest." exit 1 fi if ! actual_digest="$(sha256sum "$tarball" | awk '{print $1}')"; then echo "::error::Could not re-verify $filename immediately before publication." exit 1 fi if [ "$actual_digest" != "$expected_digest" ]; then echo "::error::$filename failed its pre-publish SHA-256 re-check." exit 1 fi echo "Re-verified $filename immediately before publication." # npm accepts a tarball package-spec and attaches provenance to the # supplied bytes. --ignore-scripts also prevents publish lifecycle # scripts from executing in the token-bearing job. npm publish "$tarball" --access public --provenance --ignore-scripts echo "Published $name@$version." published_versions="${published_versions:+$published_versions,}$name@$version" done printf 'published_versions=%s\n' "$published_versions" >> "$GITHUB_OUTPUT" # Writing the release markers is the only thing that needs a git credential, # so it is the only job that holds one, and it installs nothing and builds # nothing. It reconciles rather than only marking this run's publishes: a # marker that failed to write previously is retried here, which the release # plan alone could never do, because a version already on the registry is # excluded from it. # # Tags are created through the GitHub API with GITHUB_TOKEN, and GitHub does # not start new workflow runs from GITHUB_TOKEN writes. mark: name: Mark released versions needs: publish runs-on: ubuntu-latest permissions: contents: write # required to push the release marker tags steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 persist-credentials: false - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22 - name: Write missing release markers env: GIT_COMMIT: ${{ github.sha }} GH_TOKEN: ${{ github.token }} PUBLISHED_VERSIONS: ${{ needs.publish.outputs.published_versions }} run: | set -euo pipefail for pkg in dsl generation storage renderer editor importer; do name="@openmaic/$pkg" version="$(node -p "require('./packages/@openmaic/$pkg/package.json').version")" tag="$name@$version" if [[ ",$PUBLISHED_VERSIONS," == *",$tag,"* ]]; then echo "Published $tag in this run; registry propagation is not required." elif ! npm view "$name@$version" version --registry https://registry.npmjs.org >/dev/null 2>&1; then echo "$tag is not on the registry; nothing to mark." continue fi if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then existing="$(git rev-parse "refs/tags/$tag^{commit}")" if [ "$existing" = "$GIT_COMMIT" ]; then echo "Release marker $tag already points at $GIT_COMMIT." else echo "Release marker $tag already exists at $existing; leaving it." fi continue fi if gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ -f ref="refs/tags/$tag" -f sha="$GIT_COMMIT" >/dev/null; then echo "Marked $tag at $GIT_COMMIT." else echo "::warning::Could not write the release marker $tag; $name@$version is published." fi done