Skip to content

Release

Release #197

Workflow file for this run

name: Release
on:
push:
tags:
- 'v*'
issue_comment:
types: [created]
workflow_run:
workflows:
- Lint
- Package Candidate
types: [completed]
permissions:
contents: read
jobs:
beta-reaction:
if: >-
github.event_name == 'issue_comment' &&
github.event.action == 'created' &&
github.event.issue.pull_request &&
github.event.comment.body == '/beta'
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Check owner or maintainer permission
id: permission
uses: actions/github-script@v8
with:
script: |
try {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: context.payload.comment.user.login,
});
return { authorized: ['admin', 'maintain'].includes(data.permission) };
} catch (error) {
core.warning(`Could not verify beta comment permission: ${error.message}`);
return { authorized: false };
}
- name: Add reaction to authorized beta comment
if: fromJson(steps.permission.outputs.result).authorized
uses: peter-evans/create-or-update-comment@v5
continue-on-error: true
with:
comment-id: ${{ github.event.comment.id }}
reactions: eyes
release:
if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 24
registry-url: 'https://registry.npmjs.org'
- name: Extract version from tag
id: tag_version
run: |
TAG_NAME=${GITHUB_REF#refs/tags/}
VERSION=${TAG_NAME#v}
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT
echo "Extracted version: $VERSION from tag: $TAG_NAME"
- name: Verify root package version matches release tag
env:
TAG_VERSION: ${{ steps.tag_version.outputs.version }}
run: |
PACKAGE_VERSION=$(node -p "require('./package.json').version")
if [ "$PACKAGE_VERSION" != "$TAG_VERSION" ]; then
echo "::error::Root package version $PACKAGE_VERSION does not match release tag $TAG_VERSION"
exit 1
fi
# Lint CI uses setup-node's npm as-is. Do not bump to npm@latest here:
# npm v12 defaults allow-git=none and breaks github:apple-sign-in.
- name: Install dependencies
run: npm ci --allow-git=all
- name: Extract library projects
id: libraries
run: |
LIBRARIES=$(node -e "const fs=require('fs'); const angular=JSON.parse(fs.readFileSync('angular.json','utf8')); const libs=Object.keys(angular.projects).filter(p=>angular.projects[p].projectType==='library'); console.log(libs.join(' '));")
echo "list=$LIBRARIES" >> $GITHUB_OUTPUT
echo "Library projects: $LIBRARIES"
- name: Switch to default branch and align with tag commit
run: |
DEFAULT_BRANCH="${{ github.event.repository.default_branch }}"
git fetch origin "$DEFAULT_BRANCH"
git checkout "$DEFAULT_BRANCH"
git merge --ff-only "$GITHUB_SHA"
- name: Update project package.json versions
run: |
VERSION="${{ steps.tag_version.outputs.version }}"
echo "Setting project versions to $VERSION"
for project in ${{ steps.libraries.outputs.list }}; do
echo "Updating projects/$project/package.json..."
cd "projects/$project"
npm version "$VERSION" --no-git-tag-version
cd ../..
done
- name: Build all projects
run: npm run prebuild
- name: Verify packed package entry points
run: npm run test:package-consumer
- name: Commit changes
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add projects/*/package.json
if git diff --cached --quiet; then
echo "No project version changes to commit."
else
git commit -m "chore: update project versions to ${{ steps.tag_version.outputs.version }}"
fi
git push origin HEAD
- name: Publish packages
run: |
VERSION="${{ steps.tag_version.outputs.version }}"
IS_STABLE=$(echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' && echo true || echo false)
for project in ${{ steps.libraries.outputs.list }}; do
echo "Publishing $project..."
cd "dist/$project"
if [ "$IS_STABLE" = "true" ]; then
npm publish --provenance --access public --tag latest
elif echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+-[0-9A-Za-z.-]+$'; then
npm publish --provenance --access public --tag next
else
echo "::error::Refusing to publish an invalid release version: $VERSION"
exit 1
fi
cd ../..
done
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 }}
packages: ${{ steps.gate.outputs.packages }}
pr-number: ${{ steps.gate.outputs.pr-number }}
run-id: ${{ steps.gate.outputs.run-id }}
source-kind: ${{ steps.gate.outputs.source-kind }}
version: ${{ steps.gate.outputs.version }}
permissions:
actions: read
contents: read
issues: write
pull-requests: write
steps:
- name: Authorize immutable package set
id: gate
uses: actions/github-script@v8
with:
script: |
const { owner, repo } = context.repo;
const defaultBranch = context.payload.repository.default_branch;
const isComment = context.eventName === 'issue_comment';
const protectedWorkflows = [
'.github/workflows/lint.yml',
'.github/workflows/package-candidate.yml',
'.github/workflows/release.yml',
];
const tryCreateComment = async (body) => {
try {
await github.rest.issues.createComment({
owner,
repo,
issue_number: Number(context.issue.number),
body,
});
} catch (error) {
core.warning(`Could not post the beta gate comment: ${error.message}`);
}
};
const readJson = async (path, ref, targetOwner = owner, targetRepo = repo) => {
const { data } = await github.rest.repos.getContent({
owner: targetOwner,
repo: targetRepo,
path,
ref,
});
if (Array.isArray(data) || data.type !== 'file' || !data.content) {
throw new Error(`${path} is not a file at ${ref}`);
}
return JSON.parse(Buffer.from(data.content, 'base64').toString('utf8'));
};
const getTrustedPackages = async (ref) => {
const root = await readJson('package.json', ref);
const angular = await readJson('angular.json', ref);
const projects = Object.entries(angular.projects)
.filter(([, project]) => project.projectType === 'library')
.map(([name]) => name);
if (projects.length === 0) {
throw new Error('No Angular library projects are configured on the default branch.');
}
const packages = [];
for (const project of projects) {
const packageJson = await readJson(`projects/${project}/package.json`, ref);
packages.push(packageJson.name);
}
if (new Set(packages).size !== packages.length) {
throw new Error('Library package names must be unique.');
}
return {
baseVersion: root.version.split('-')[0],
packages: packages.sort(),
};
};
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 event;
let sourceKind;
let required;
if (isComment) {
const requester = context.payload.comment.user.login;
const { data: permission } = await github.rest.repos.getCollaboratorPermissionLevel({
owner,
repo,
username: requester,
});
if (!['admin', 'maintain'].includes(permission.permission)) {
core.notice(`@${requester} is not a repository owner or maintainer; /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 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) {
await tryCreateComment(`🚫 \`/beta\` cannot publish a pull request that changes a release-gating workflow: ${changedWorkflows.join(', ')}.`);
core.setOutput('allowed', 'false');
return;
}
headSha = pullRequest.head.sha;
event = 'pull_request';
sourceKind = 'comment';
required = new Map([
['Lint', '.github/workflows/lint.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;
}
const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner,
repo,
commit_sha: headSha,
});
const mergedPullRequest = pullRequests.find(
(pullRequest) => pullRequest.merged_at &&
pullRequest.base.ref === defaultBranch &&
pullRequest.merge_commit_sha === headSha,
);
if (!mergedPullRequest) {
core.notice('The default-branch push is not a merged pull request; no beta release was requested.');
core.setOutput('allowed', 'false');
return;
}
prNumber = String(mergedPullRequest.number);
const changedFiles = await github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: mergedPullRequest.number,
per_page: 100,
});
const changedWorkflows = changedFiles
.map((file) => file.filename)
.filter((filename) => protectedWorkflows.includes(filename));
if (changedWorkflows.length > 0) {
core.notice(`The merged pull request changed a release-gating workflow; beta was skipped: ${changedWorkflows.join(', ')}`);
core.setOutput('allowed', 'false');
return;
}
event = 'push';
sourceKind = 'merge';
required = new Map([
['Lint', '.github/workflows/lint.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 (isComment) {
await tryCreateComment(`🚫 \`/beta\` did not publish commit \`${headSha.slice(0, 12)}\`. Required CI has not passed: ${incomplete.join(', ')}. Run \`/beta\` again after CI succeeds.`);
}
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 trusted = await getTrustedPackages(defaultBranch);
const version = `${trusted.baseVersion}-beta.pr${prNumber}.sha${headSha.slice(0, 12)}`;
core.setOutput('allowed', 'true');
core.setOutput('artifact', artifactName);
core.setOutput('dist-tag', 'beta');
core.setOutput('head-sha', headSha);
core.setOutput('packages', JSON.stringify(trusted.packages));
core.setOutput('pr-number', prNumber);
core.setOutput('run-id', String(candidateRun.id));
core.setOutput('source-kind', sourceKind);
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_REGISTRY: https://registry.npmjs.org/
permissions:
actions: read
contents: read
id-token: write
pull-requests: read
steps:
- name: Revalidate authorization and package set
uses: actions/github-script@v8
env:
HEAD_SHA: ${{ needs.authorize-candidate.outputs.head-sha }}
PACKAGES: ${{ needs.authorize-candidate.outputs.packages }}
PR_NUMBER: ${{ needs.authorize-candidate.outputs.pr-number }}
RUN_ID: ${{ needs.authorize-candidate.outputs.run-id }}
SOURCE_KIND: ${{ needs.authorize-candidate.outputs.source-kind }}
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.SOURCE_KIND === 'comment' ? '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 set no longer has an authorized successful source run.');
return;
}
if (process.env.SOURCE_KIND === 'comment') {
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 owner or maintainer /beta is required.');
return;
}
const requester = context.payload.comment.user.login;
const { data: permission } = await github.rest.repos.getCollaboratorPermissionLevel({
owner,
repo,
username: requester,
});
if (!['admin', 'maintain'].includes(permission.permission)) {
core.setFailed(`@${requester} is no longer a repository owner or maintainer.`);
return;
}
} else if (process.env.SOURCE_KIND === 'merge') {
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 publish a stale merge candidate.');
return;
}
const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner,
repo,
commit_sha: process.env.HEAD_SHA,
});
const mergedPullRequest = pullRequests.find(
(pullRequest) => pullRequest.number === Number(process.env.PR_NUMBER) &&
pullRequest.merged_at &&
pullRequest.base.ref === defaultBranch &&
pullRequest.merge_commit_sha === process.env.HEAD_SHA,
);
if (!mergedPullRequest) {
core.setFailed('The candidate is not the merge commit of the authorized pull request.');
return;
}
} else {
core.setFailed(`Unsupported candidate source: ${process.env.SOURCE_KIND}`);
return;
}
const readJson = async (path) => {
const { data } = await github.rest.repos.getContent({
owner,
repo,
path,
ref: defaultBranch,
});
if (Array.isArray(data) || data.type !== 'file' || !data.content) {
throw new Error(`${path} could not be read from the default branch.`);
}
return JSON.parse(Buffer.from(data.content, 'base64').toString('utf8'));
};
const root = await readJson('package.json');
const angular = await readJson('angular.json');
const trustedPackages = [];
for (const [project, definition] of Object.entries(angular.projects)) {
if (definition.projectType !== 'library') continue;
const packageJson = await readJson(`projects/${project}/package.json`);
trustedPackages.push(packageJson.name);
}
trustedPackages.sort();
const expectedPackages = JSON.parse(process.env.PACKAGES);
const expectedVersion = `${root.version.split('-')[0]}-beta.pr${process.env.PR_NUMBER}.sha${process.env.HEAD_SHA.slice(0, 12)}`;
if (
JSON.stringify(trustedPackages) !== JSON.stringify(expectedPackages) ||
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 set
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 complete package set
env:
DIST_TAG: ${{ needs.authorize-candidate.outputs.dist-tag }}
EXPECTED_PACKAGES: ${{ needs.authorize-candidate.outputs.packages }}
EXPECTED_VERSION: ${{ needs.authorize-candidate.outputs.version }}
run: |
if [ "$DIST_TAG" != "beta" ]; then
echo "::error::Refusing unsupported dist-tag: $DIST_TAG"
exit 1
fi
node --input-type=module <<'NODE'
import { readdirSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
const directory = process.env.RUNNER_TEMP + '/npm-candidate';
const archives = readdirSync(directory).filter((file) => file.endsWith('.tgz')).sort();
const expected = JSON.parse(process.env.EXPECTED_PACKAGES).sort();
if (archives.length !== expected.length) {
throw new Error(`Expected ${expected.length} package archives, found ${archives.length}.`);
}
const actual = [];
for (const archive of archives) {
const packageJson = JSON.parse(execFileSync(
'tar',
['-xOf', `${directory}/${archive}`, 'package/package.json'],
{ encoding: 'utf8' },
));
const registry = packageJson.publishConfig?.registry || '';
if (
registry &&
registry !== 'https://registry.npmjs.org' &&
registry !== 'https://registry.npmjs.org/'
) {
throw new Error(`Refusing ${packageJson.name} with unexpected registry ${registry}.`);
}
if (packageJson.version !== process.env.EXPECTED_VERSION) {
throw new Error(`${packageJson.name} has version ${packageJson.version}; expected ${process.env.EXPECTED_VERSION}.`);
}
actual.push(packageJson.name);
}
actual.sort();
if (new Set(actual).size !== actual.length || JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(`Artifact package set mismatch: ${JSON.stringify(actual)}`);
}
NODE
- name: Publish all candidates
env:
DIST_TAG: ${{ needs.authorize-candidate.outputs.dist-tag }}
EXPECTED_VERSION: ${{ needs.authorize-candidate.outputs.version }}
run: |
node --input-type=module <<'NODE'
import { readdirSync } from 'node:fs';
import { execFileSync, spawnSync } from 'node:child_process';
const directory = process.env.RUNNER_TEMP + '/npm-candidate';
const archives = readdirSync(directory).filter((file) => file.endsWith('.tgz')).sort();
for (const archive of archives) {
const path = `${directory}/${archive}`;
const packageJson = JSON.parse(execFileSync(
'tar',
['-xOf', path, 'package/package.json'],
{ encoding: 'utf8' },
));
const existing = spawnSync(
'npm',
['view', `${packageJson.name}@${process.env.EXPECTED_VERSION}`, 'version', '--registry', process.env.NPM_CONFIG_REGISTRY],
{ stdio: 'ignore' },
);
if (existing.status === 0) {
console.log(`${packageJson.name}@${process.env.EXPECTED_VERSION} already exists; skipping publish.`);
continue;
}
execFileSync(
'npm',
['publish', path, '--ignore-scripts', '--provenance', '--access', 'public', '--registry', process.env.NPM_CONFIG_REGISTRY, '--tag', process.env.DIST_TAG],
{ stdio: 'inherit' },
);
}
NODE
comment-candidate:
needs: [authorize-candidate, publish-candidate]
if: >-
always() &&
needs.authorize-candidate.outputs.allowed == 'true' &&
needs.publish-candidate.result == 'success'
runs-on: ubuntu-latest
concurrency:
group: npm-comment-${{ needs.authorize-candidate.outputs.head-sha }}
cancel-in-progress: false
permissions:
issues: write
pull-requests: write
steps:
- 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 }}
PACKAGES: ${{ needs.authorize-candidate.outputs.packages }}
PR_NUMBER: ${{ needs.authorize-candidate.outputs.pr-number }}
SOURCE_KIND: ${{ needs.authorize-candidate.outputs.source-kind }}
VERSION: ${{ needs.authorize-candidate.outputs.version }}
with:
script: |
const { owner, repo } = context.repo;
const shortSha = process.env.HEAD_SHA.slice(0, 12);
const issueNumber = Number(process.env.PR_NUMBER);
const packages = JSON.parse(process.env.PACKAGES);
const marker = `<!-- npm-${process.env.DIST_TAG}:${process.env.HEAD_SHA} -->`;
const authorization = process.env.SOURCE_KIND === 'comment'
? `CI passed and a repository owner or maintainer requested \`/beta\` for commit \`${shortSha}\``
: `CI passed for the merge commit \`${shortSha}\``;
const install = `npm install ${packages.map((name) => `${name}@${process.env.VERSION}`).join(' ')}`;
const body = `${marker}\n### npm beta packages published\n\n${authorization}. Install the immutable package set with:\n\n\`\`\`sh\n${install}\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;
}
try {
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 });
}
} catch (error) {
core.warning(`Could not post the beta install comment: ${error.message}`);
}