diff --git a/.github/workflows/package-candidate.yml b/.github/workflows/package-candidate.yml
new file mode 100644
index 0000000..2a7d518
--- /dev/null
+++ b/.github/workflows/package-candidate.yml
@@ -0,0 +1,67 @@
+name: Package Candidate
+
+on:
+ pull_request:
+ branches: [main]
+ types: [opened, synchronize, reopened, ready_for_review]
+ push:
+ branches: [main]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: package-candidate-${{ github.event.pull_request.head.sha || github.sha }}
+ cancel-in-progress: true
+
+jobs:
+ pack:
+ if: github.event_name == 'push' || github.event.pull_request.draft == false
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout candidate
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ persist-credentials: false
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: 24
+ package-manager-cache: false
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Set immutable candidate version
+ id: candidate
+ env:
+ HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ run: |
+ BASE_VERSION=$(node -p "require('./package.json').version.split('-')[0]")
+ if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
+ VERSION="${BASE_VERSION}-beta.pr${PR_NUMBER}.sha${HEAD_SHA:0:12}"
+ else
+ VERSION="${BASE_VERSION}-next.sha${HEAD_SHA:0:12}"
+ fi
+ npm version "$VERSION" --no-git-tag-version --ignore-scripts
+ echo "artifact=npm-candidate-${HEAD_SHA}" >> "$GITHUB_OUTPUT"
+
+ - name: Build package
+ run: npm run build
+
+ - name: Pack candidate
+ run: |
+ mkdir -p "$RUNNER_TEMP/npm-candidate"
+ npm pack --ignore-scripts --pack-destination "$RUNNER_TEMP/npm-candidate"
+
+ - name: Upload immutable package artifact
+ uses: actions/upload-artifact@v7
+ with:
+ name: ${{ steps.candidate.outputs.artifact }}
+ path: ${{ runner.temp }}/npm-candidate/*.tgz
+ if-no-files-found: error
+ retention-days: 7
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5310f50..6650751 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -4,14 +4,25 @@ on:
push:
tags:
- 'v*'
+ issue_comment:
+ types: [created]
+ workflow_run:
+ workflows:
+ - Lint
+ - E2E Screenshot Tests
+ - Package Candidate
+ types: [completed]
permissions:
- id-token: write
- contents: write
+ contents: read
jobs:
release:
+ if: github.event_name == 'push'
runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ id-token: write
steps:
- name: Checkout code
@@ -21,9 +32,6 @@ jobs:
with:
node-version: 24
registry-url: 'https://registry.npmjs.org'
- cache: npm
- cache-dependency-path: '**/package-lock.json'
-
- name: Update npm
run: npm install -g npm@latest
@@ -46,7 +54,7 @@ jobs:
run: |
VERSION="${{ steps.tag_version.outputs.version }}"
IS_STABLE=$(echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' && echo true || echo false)
-
+
echo "Publishing... $IS_STABLE"
if [ "$IS_STABLE" = "true" ]; then
npm publish --provenance --access public
@@ -54,3 +62,433 @@ jobs:
npm publish --provenance --access public --tag next
fi
+ authorize-candidate:
+ if: >-
+ (github.event_name == 'issue_comment' &&
+ github.event.action == 'created' &&
+ github.event.issue.pull_request &&
+ github.event.comment.body == '/beta') ||
+ (github.event_name == 'workflow_run' &&
+ github.event.action == 'completed')
+ runs-on: ubuntu-latest
+ outputs:
+ allowed: ${{ steps.gate.outputs.allowed }}
+ artifact: ${{ steps.gate.outputs.artifact }}
+ dist-tag: ${{ steps.gate.outputs.dist-tag }}
+ head-sha: ${{ steps.gate.outputs.head-sha }}
+ package: ${{ steps.gate.outputs.package }}
+ pr-number: ${{ steps.gate.outputs.pr-number }}
+ run-id: ${{ steps.gate.outputs.run-id }}
+ version: ${{ steps.gate.outputs.version }}
+ permissions:
+ actions: read
+ contents: read
+ issues: write
+ pull-requests: read
+
+ steps:
+ - name: Authorize immutable candidate
+ id: gate
+ uses: actions/github-script@v8
+ with:
+ script: |
+ const { owner, repo } = context.repo;
+ const defaultBranch = context.payload.repository.default_branch;
+ const isBeta = context.eventName === 'issue_comment';
+
+ const getTrustedPackage = async (ref) => {
+ const { data } = await github.rest.repos.getContent({
+ owner,
+ repo,
+ path: 'package.json',
+ ref,
+ });
+ if (Array.isArray(data) || data.type !== 'file' || !data.content) {
+ throw new Error(`package.json is not a file at ${ref}`);
+ }
+ const packageJson = JSON.parse(Buffer.from(data.content, 'base64').toString('utf8'));
+ return {
+ name: packageJson.name,
+ baseVersion: packageJson.version.split('-')[0],
+ };
+ };
+
+ const getRequiredRuns = async (headSha, event, required) => {
+ const runs = await github.paginate(github.rest.actions.listWorkflowRunsForRepo, {
+ owner,
+ repo,
+ head_sha: headSha,
+ event,
+ per_page: 100,
+ });
+ const latest = new Map();
+ for (const [name, path] of required) {
+ const run = runs
+ .filter((candidate) => candidate.name === name && candidate.path === path)
+ .sort((left, right) => right.run_number - left.run_number)[0];
+ latest.set(name, run);
+ }
+ return latest;
+ };
+
+ let headSha;
+ let prNumber = '';
+ let distTag;
+ let required;
+ let event;
+
+ if (isBeta) {
+ const requester = context.payload.comment.user.login;
+ const { data: permission } = await github.rest.repos.getCollaboratorPermissionLevel({
+ owner,
+ repo,
+ username: requester,
+ });
+ if (permission.permission !== 'admin') {
+ core.notice(`@${requester} is not a repository administrator; /beta was ignored.`);
+ core.setOutput('allowed', 'false');
+ return;
+ }
+
+ prNumber = String(context.payload.issue.number);
+ const { data: pullRequest } = await github.rest.pulls.get({
+ owner,
+ repo,
+ pull_number: Number(prNumber),
+ });
+ if (pullRequest.state !== 'open' || pullRequest.draft) {
+ core.notice('The PR must be open and ready for review.');
+ core.setOutput('allowed', 'false');
+ return;
+ }
+ if (!pullRequest.head.repo) {
+ core.setFailed('The pull request head repository is no longer available.');
+ return;
+ }
+ const protectedWorkflows = [
+ '.github/workflows/lint.yml',
+ '.github/workflows/e2e-pull_request.yml',
+ '.github/workflows/package-candidate.yml',
+ ];
+ const changedWorkflows = [];
+ for (const path of protectedWorkflows) {
+ const [{ data: trusted }, { data: candidate }] = await Promise.all([
+ github.rest.repos.getContent({ owner, repo, path, ref: defaultBranch }),
+ github.rest.repos.getContent({
+ owner: pullRequest.head.repo.owner.login,
+ repo: pullRequest.head.repo.name,
+ path,
+ ref: pullRequest.head.sha,
+ }),
+ ]);
+ if (Array.isArray(trusted) || Array.isArray(candidate) || trusted.sha !== candidate.sha) {
+ changedWorkflows.push(path);
+ }
+ }
+ if (changedWorkflows.length > 0) {
+ const body = `🚫 \`/beta\` cannot publish a pull request that changes a release-gating workflow: ${changedWorkflows.join(', ')}.`;
+ await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number: Number(prNumber),
+ body,
+ });
+ core.setOutput('allowed', 'false');
+ return;
+ }
+ headSha = pullRequest.head.sha;
+ distTag = 'beta';
+ event = 'pull_request';
+ required = new Map([
+ ['Lint', '.github/workflows/lint.yml'],
+ ['E2E Screenshot Tests Pull Request', '.github/workflows/e2e-pull_request.yml'],
+ ['Package Candidate', '.github/workflows/package-candidate.yml'],
+ ]);
+ } else {
+ const source = context.payload.workflow_run;
+ if (
+ source.event !== 'push' ||
+ source.head_branch !== defaultBranch ||
+ source.conclusion !== 'success'
+ ) {
+ core.notice('This workflow run is not a successful default-branch push.');
+ core.setOutput('allowed', 'false');
+ return;
+ }
+ headSha = source.head_sha;
+ const { data: branch } = await github.rest.repos.getBranch({
+ owner,
+ repo,
+ branch: defaultBranch,
+ });
+ if (branch.commit.sha !== headSha) {
+ core.notice('A newer default-branch commit exists; the stale candidate was skipped.');
+ core.setOutput('allowed', 'false');
+ return;
+ }
+ distTag = 'next';
+ event = 'push';
+ required = new Map([
+ ['Lint', '.github/workflows/lint.yml'],
+ ['E2E Screenshot Tests', '.github/workflows/e2e-main.yml'],
+ ['Package Candidate', '.github/workflows/package-candidate.yml'],
+ ]);
+ }
+
+ const runs = await getRequiredRuns(headSha, event, required);
+ const incomplete = [...required.keys()].filter((name) => {
+ const run = runs.get(name);
+ return !run || run.status !== 'completed' || run.conclusion !== 'success';
+ });
+ if (incomplete.length > 0) {
+ if (isBeta) {
+ const body = `🚫 \`/beta\` did not publish commit \`${headSha.slice(0, 12)}\`. Required CI has not passed: ${incomplete.join(', ')}. Run \`/beta\` again after CI succeeds.`;
+ await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number: Number(prNumber),
+ body,
+ });
+ }
+ core.notice(`Required CI has not passed: ${incomplete.join(', ')}`);
+ core.setOutput('allowed', 'false');
+ return;
+ }
+
+ const candidateRun = runs.get('Package Candidate');
+ const artifactName = `npm-candidate-${headSha}`;
+ const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
+ owner,
+ repo,
+ run_id: candidateRun.id,
+ per_page: 100,
+ });
+ const artifact = artifacts.find(
+ (candidate) => candidate.name === artifactName && !candidate.expired,
+ );
+ if (!artifact) {
+ core.setFailed(`The immutable package artifact ${artifactName} is missing or expired.`);
+ return;
+ }
+
+ const trustedPackage = await getTrustedPackage(defaultBranch);
+ const version = isBeta
+ ? `${trustedPackage.baseVersion}-beta.pr${prNumber}.sha${headSha.slice(0, 12)}`
+ : `${trustedPackage.baseVersion}-next.sha${headSha.slice(0, 12)}`;
+
+ core.setOutput('allowed', 'true');
+ core.setOutput('artifact', artifactName);
+ core.setOutput('dist-tag', distTag);
+ core.setOutput('head-sha', headSha);
+ core.setOutput('package', trustedPackage.name);
+ core.setOutput('pr-number', prNumber);
+ core.setOutput('run-id', String(candidateRun.id));
+ core.setOutput('version', version);
+
+ publish-candidate:
+ needs: authorize-candidate
+ if: needs.authorize-candidate.outputs.allowed == 'true'
+ runs-on: ubuntu-latest
+ concurrency:
+ group: npm-${{ needs.authorize-candidate.outputs.dist-tag }}-${{ needs.authorize-candidate.outputs.head-sha }}
+ cancel-in-progress: false
+ env:
+ NPM_CONFIG_TAG: ${{ needs.authorize-candidate.outputs.dist-tag }}
+ NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
+ permissions:
+ actions: read
+ contents: read
+ id-token: write
+ issues: write
+ pull-requests: read
+
+ steps:
+ - name: Revalidate authorization and source
+ uses: actions/github-script@v8
+ env:
+ DIST_TAG: ${{ needs.authorize-candidate.outputs.dist-tag }}
+ HEAD_SHA: ${{ needs.authorize-candidate.outputs.head-sha }}
+ PACKAGE_NAME: ${{ needs.authorize-candidate.outputs.package }}
+ PR_NUMBER: ${{ needs.authorize-candidate.outputs.pr-number }}
+ RUN_ID: ${{ needs.authorize-candidate.outputs.run-id }}
+ VERSION: ${{ needs.authorize-candidate.outputs.version }}
+ with:
+ script: |
+ const { owner, repo } = context.repo;
+ const defaultBranch = context.payload.repository.default_branch;
+ const { data: run } = await github.rest.actions.getWorkflowRun({
+ owner,
+ repo,
+ run_id: Number(process.env.RUN_ID),
+ });
+ const expectedEvent = process.env.DIST_TAG === 'beta' ? 'pull_request' : 'push';
+ if (
+ run.name !== 'Package Candidate' ||
+ run.path !== '.github/workflows/package-candidate.yml' ||
+ run.event !== expectedEvent ||
+ run.head_sha !== process.env.HEAD_SHA ||
+ run.status !== 'completed' ||
+ run.conclusion !== 'success'
+ ) {
+ core.setFailed('The package artifact no longer has an authorized successful source run.');
+ return;
+ }
+
+ if (process.env.DIST_TAG === 'beta') {
+ const pull_number = Number(process.env.PR_NUMBER);
+ const { data: pullRequest } = await github.rest.pulls.get({ owner, repo, pull_number });
+ if (pullRequest.state !== 'open' || pullRequest.head.sha !== process.env.HEAD_SHA) {
+ core.setFailed('The PR head changed after /beta; a fresh administrator /beta is required.');
+ return;
+ }
+ const requester = context.payload.comment.user.login;
+ const { data: permission } = await github.rest.repos.getCollaboratorPermissionLevel({
+ owner,
+ repo,
+ username: requester,
+ });
+ if (permission.permission !== 'admin') {
+ core.setFailed(`@${requester} is no longer a repository administrator.`);
+ return;
+ }
+ } else if (process.env.DIST_TAG === 'next') {
+ const { data: branch } = await github.rest.repos.getBranch({
+ owner,
+ repo,
+ branch: defaultBranch,
+ });
+ if (branch.commit.sha !== process.env.HEAD_SHA) {
+ core.setFailed('A newer default-branch commit exists; refusing to move next backwards.');
+ return;
+ }
+ } else {
+ core.setFailed(`Unsupported candidate dist-tag: ${process.env.DIST_TAG}`);
+ return;
+ }
+
+ const { data } = await github.rest.repos.getContent({
+ owner,
+ repo,
+ path: 'package.json',
+ ref: defaultBranch,
+ });
+ if (Array.isArray(data) || data.type !== 'file' || !data.content) {
+ core.setFailed('Trusted package.json could not be read.');
+ return;
+ }
+ const packageJson = JSON.parse(Buffer.from(data.content, 'base64').toString('utf8'));
+ const baseVersion = packageJson.version.split('-')[0];
+ const expectedVersion = process.env.DIST_TAG === 'beta'
+ ? `${baseVersion}-beta.pr${process.env.PR_NUMBER}.sha${process.env.HEAD_SHA.slice(0, 12)}`
+ : `${baseVersion}-next.sha${process.env.HEAD_SHA.slice(0, 12)}`;
+ if (
+ packageJson.name !== process.env.PACKAGE_NAME ||
+ expectedVersion !== process.env.VERSION
+ ) {
+ core.setFailed('Trusted package identity changed after authorization; rerun the release request.');
+ }
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: 24
+ registry-url: 'https://registry.npmjs.org'
+ package-manager-cache: false
+
+ - name: Update npm
+ run: npm install -g npm@latest
+
+ - name: Download immutable package artifact
+ uses: actions/download-artifact@v8
+ with:
+ name: ${{ needs.authorize-candidate.outputs.artifact }}
+ path: ${{ runner.temp }}/npm-candidate
+ github-token: ${{ github.token }}
+ repository: ${{ github.repository }}
+ run-id: ${{ needs.authorize-candidate.outputs.run-id }}
+
+ - name: Validate and publish candidate
+ env:
+ EXPECTED_NAME: ${{ needs.authorize-candidate.outputs.package }}
+ EXPECTED_VERSION: ${{ needs.authorize-candidate.outputs.version }}
+ DIST_TAG: ${{ needs.authorize-candidate.outputs.dist-tag }}
+ run: |
+ shopt -s nullglob
+ PACKAGES=("$RUNNER_TEMP"/npm-candidate/*.tgz)
+ if [ "${#PACKAGES[@]}" -ne 1 ]; then
+ echo "::error::Expected exactly one package archive, found ${#PACKAGES[@]}."
+ exit 1
+ fi
+ PACKAGE_ARCHIVE="${PACKAGES[0]}"
+ ACTUAL_NAME=$(tar -xOf "$PACKAGE_ARCHIVE" package/package.json | node -e "let input=''; process.stdin.on('data', chunk => input += chunk); process.stdin.on('end', () => process.stdout.write(JSON.parse(input).name));")
+ ACTUAL_VERSION=$(tar -xOf "$PACKAGE_ARCHIVE" package/package.json | node -e "let input=''; process.stdin.on('data', chunk => input += chunk); process.stdin.on('end', () => process.stdout.write(JSON.parse(input).version));")
+ ACTUAL_REGISTRY=$(tar -xOf "$PACKAGE_ARCHIVE" package/package.json | node -e "let input=''; process.stdin.on('data', chunk => input += chunk); process.stdin.on('end', () => process.stdout.write(JSON.parse(input).publishConfig?.registry || ''));")
+ if [ "$ACTUAL_NAME" != "$EXPECTED_NAME" ] || [ "$ACTUAL_VERSION" != "$EXPECTED_VERSION" ]; then
+ echo "::error::Artifact identity mismatch: ${ACTUAL_NAME}@${ACTUAL_VERSION}"
+ exit 1
+ fi
+ if [ -n "$ACTUAL_REGISTRY" ] && [ "$ACTUAL_REGISTRY" != "https://registry.npmjs.org" ] && [ "$ACTUAL_REGISTRY" != "https://registry.npmjs.org/" ]; then
+ echo "::error::Refusing package with an unexpected publish registry: $ACTUAL_REGISTRY"
+ exit 1
+ fi
+ if [ "$DIST_TAG" != "beta" ] && [ "$DIST_TAG" != "next" ]; then
+ echo "::error::Refusing unsupported dist-tag: $DIST_TAG"
+ exit 1
+ fi
+ if npm view "${EXPECTED_NAME}@${EXPECTED_VERSION}" version --registry "$NPM_CONFIG_REGISTRY" >/dev/null 2>&1; then
+ echo "${EXPECTED_NAME}@${EXPECTED_VERSION} already exists; skipping publish."
+ else
+ npm publish "$PACKAGE_ARCHIVE" --ignore-scripts --provenance --access public --registry "$NPM_CONFIG_REGISTRY" --tag "$DIST_TAG"
+ fi
+
+ - name: Comment exact install command
+ uses: actions/github-script@v8
+ env:
+ DIST_TAG: ${{ needs.authorize-candidate.outputs.dist-tag }}
+ HEAD_SHA: ${{ needs.authorize-candidate.outputs.head-sha }}
+ PACKAGE_NAME: ${{ needs.authorize-candidate.outputs.package }}
+ PR_NUMBER: ${{ needs.authorize-candidate.outputs.pr-number }}
+ VERSION: ${{ needs.authorize-candidate.outputs.version }}
+ with:
+ script: |
+ const { owner, repo } = context.repo;
+ const shortSha = process.env.HEAD_SHA.slice(0, 12);
+ let issueNumber = Number(process.env.PR_NUMBER);
+ if (process.env.DIST_TAG === 'next') {
+ const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
+ owner,
+ repo,
+ commit_sha: process.env.HEAD_SHA,
+ });
+ const merged = pullRequests.find(
+ (pullRequest) => pullRequest.merged_at && pullRequest.base.ref === context.payload.repository.default_branch,
+ );
+ issueNumber = merged?.number;
+ }
+
+ const marker = ``;
+ const title = process.env.DIST_TAG === 'beta' ? 'npm beta published' : 'npm next published';
+ const authorization = process.env.DIST_TAG === 'beta'
+ ? `CI passed and a repository administrator requested \`/beta\` for commit \`${shortSha}\``
+ : `All required CI passed for main commit \`${shortSha}\``;
+ const body = `${marker}\n### ${title}\n\n${authorization}. Install the immutable version with:\n\n\`\`\`sh\nnpm install ${process.env.PACKAGE_NAME}@${process.env.VERSION}\n\`\`\``;
+ await core.summary.addRaw(body).write();
+ if (!issueNumber) {
+ core.notice('No associated merged pull request was found; wrote the install command to the job summary only.');
+ return;
+ }
+
+ const comments = await github.paginate(github.rest.issues.listComments, {
+ owner,
+ repo,
+ issue_number: issueNumber,
+ per_page: 100,
+ });
+ const existing = comments.find(
+ (comment) => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(marker),
+ );
+ if (existing) {
+ await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
+ } else {
+ await github.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body });
+ }
diff --git a/README.md b/README.md
index ba0efdf..61b98c6 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,9 @@
A CSS/JS theme library that applies Material Design 3 design system to Ionic applications.
+

+
DEMO is here: https://ionic-theme-md3.rdlabo.dev/
@@ -181,9 +183,28 @@ npm run test:e2e:debug
npm run test:e2e:update
```
+### Prerelease channels
+
+An open, non-draft pull request can be published to the npm `beta` dist-tag after its `Lint`, `E2E Screenshot Tests Pull Request`, and `Package Candidate` workflows pass. A repository administrator must add a comment whose entire body is:
+
+```text
+/beta
+```
+
+The request authorizes only the pull request head SHA that existed when the comment was added. The workflow revalidates the administrator permission and head SHA immediately before publishing. Any new commit invalidates the request, regardless of its author; the new SHA must pass CI and receive a fresh administrator `/beta` comment. Fork pull requests are supported. Pull requests that change a release-gating workflow cannot be beta-published until those workflow changes land on `main`.
+
+Beta versions use `-beta.pr.sha<12-character SHA>`. The pull request receives a comment containing the immutable version and exact `npm install` command.
+
+After a commit reaches `main`, it is automatically published to the npm `next` dist-tag only when `Lint`, `E2E Screenshot Tests`, and `Package Candidate` all succeed for that exact commit and it is still the current `main` head. Main candidates use `-next.sha<12-character SHA>`. When the commit is associated with a merged pull request, that pull request receives the exact install command.
+
+Candidate code is built in a read-only workflow without npm publishing credentials. The privileged release workflow never checks out or executes pull request code; it revalidates the source workflow and package identity, then publishes only the immutable packed artifact with lifecycle scripts disabled.
+
+Neither `beta` nor `next` publishing changes the npm `latest` dist-tag. Only an explicit stable `vX.Y.Z` release tag publishes to `latest`; prerelease version tags publish to `next`.
+
+
## Maintainers
- [rdlabo](https://rdlabo.dev/)