473 lines
23 KiB
YAML
473 lines
23 KiB
YAML
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/<name>@<version>` 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
|