Skip to content

fix: allow retry after partial prerelease publish #3

fix: allow retry after partial prerelease publish

fix: allow retry after partial prerelease publish #3

Workflow file for this run

name: Release CI
on:
push:
branches:
- 'release/[0-9]+\.[0-9]+\.[0-9]+'
- 'hotfix/[0-9]+\.[0-9]+\.[0-9]+'
- 'pre-release/[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+'
- 'pre-release/[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+'
- 'pre-release/[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+'
- 'pre-release/[0-9]+\.[0-9]+\.[0-9]+-hotfix\.[0-9]+'
jobs:
release:
runs-on: macos-latest
permissions:
id-token: write
contents: write
pull-requests: write
strategy:
matrix:
node-version: [20.x]
concurrency:
group: vgraph-release-${{ github.ref_name }}
cancel-in-progress: false
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure Git identity
run: |
git config user.name ${{ github.actor }}
git config user.email ${{ github.actor }}@users.noreply.github.com
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
registry-url: 'https://registry.npmjs.org'
cache: 'npm'
cache-dependency-path: './common/config/rush/pnpm-lock.yaml'
- name: Update npm
run: npm install -g npm@latest
- name: Install pnpm
run: npm install -g pnpm@10.7.0
- name: Install Python distutils (macOS)
if: runner.os == 'macOS'
run: python3 -m pip install setuptools --break-system-packages
- name: Install native deps for node-canvas (macOS)
if: runner.os == 'macOS'
run: |
brew update
brew install pkg-config cairo pango libpng jpeg giflib librsvg
- name: Install rush
run: node common/scripts/install-run-rush.js install --bypass-policy
- name: Parse semver (release)
if: startsWith(github.ref_name, 'release/')
id: semver_release
uses: xile611/read-package-version-action@main
with:
path: packages/vgraph
semver_string: ${{ github.ref_name }}
semver_pattern: '^release/(.*)$'
- name: Check version-policy version (release)
if: startsWith(github.ref_name, 'release/')
id: release_version_policy
env:
RELEASE_VERSION: ${{ steps.semver_release.outputs.full }}
run: |
node <<'NODE'
const fs = require('fs');
const policies = JSON.parse(fs.readFileSync('common/config/rush/version-policies.json', 'utf8'));
const policyVersion = policies[0] && policies[0].version;
const releaseVersion = process.env.RELEASE_VERSION;
const parseVersion = version => String(version).split('-')[0].split('.').map(Number);
const compareVersion = (a, b) => {
const av = parseVersion(a);
const bv = parseVersion(b);
for (let i = 0; i < Math.max(av.length, bv.length); i++) {
const diff = (av[i] || 0) - (bv[i] || 0);
if (diff !== 0) {
return diff;
}
}
return 0;
};
if (!releaseVersion || !policyVersion) {
console.error('Missing release version or version-policy version.', { releaseVersion, policyVersion });
process.exit(1);
}
const result = compareVersion(releaseVersion, policyVersion);
if (result < 0) {
console.error(`Release version ${releaseVersion} is lower than version-policy version ${policyVersion}.`);
process.exit(1);
}
const shouldSkip = result === 0 ? 'true' : 'false';
fs.appendFileSync(process.env.GITHUB_OUTPUT, `skip_next_bump=${shouldSkip}\n`);
console.log(
result === 0
? `Release version ${releaseVersion} equals version-policy version ${policyVersion}, skip nextBump update.`
: `Release version ${releaseVersion} is greater than version-policy version ${policyVersion}, update nextBump.`
);
NODE
- name: Update nextBump (release)
if: startsWith(github.ref_name, 'release/') && steps.release_version_policy.outputs.skip_next_bump != 'true'
uses: xile611/set-next-bump-of-rush@main
with:
release_version: ${{ steps.semver_release.outputs.full }}
write_next_bump: true
- name: Generate changelog blocks from changefiles (release)
if: startsWith(github.ref_name, 'release/')
env:
CHANGELOG_API_URL: ${{ secrets.VGRAPH_CHANGELOG_API_URL }}
CHANGELOG_API_TOKEN: ${{ secrets.VGRAPH_CHANGELOG_API_TOKEN }}
RELEASE_VERSION: ${{ steps.semver_release.outputs.main }}
run: |
node <<'NODE'
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const baseDir = path.join(process.cwd(), 'common', 'changes', '@visactor', 'vgraph');
let changefiles = [];
try {
const files = fs.readdirSync(baseDir).filter(name => name.endsWith('.json'));
for (const file of files) {
const fullPath = path.join(baseDir, file);
const json = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
changefiles.push(json);
}
console.log('Collected changefiles:', changefiles.length);
} catch (e) {
console.log('No changefiles found or unable to read, fallback to empty list.', e.message || e);
}
const version = process.env.RELEASE_VERSION;
if (!version) {
console.error('Missing RELEASE_VERSION env.');
process.exit(1);
}
let prevVersion = process.env.PREV_VERSION || '';
try {
if (!prevVersion) {
const out = execSync('git tag --list "v*.*.*" --sort=-v:refname', { encoding: 'utf8' });
const tags = out.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
if (tags.length > 0) {
const first = tags[0].replace(/^v/i, '');
if (first !== version && !first.startsWith(version + '-')) {
prevVersion = first;
} else if (tags.length > 1) {
prevVersion = tags[1].replace(/^v/i, '');
}
}
}
} catch (e) {
console.log('Failed to detect previous version from tags, continue without it:', e.message || e);
}
const payload = {
version,
prevVersion: prevVersion || undefined,
date: new Date().toISOString().slice(0, 10),
changefiles,
langs: ['en', 'zh'],
template: 'default',
};
const baseUrl = process.env.CHANGELOG_API_URL;
const token = process.env.CHANGELOG_API_TOKEN;
function failChangelog(message) {
console.error(message);
process.exit(1);
}
function validateBlock(block, lang) {
if (!block || /TODO/i.test(block)) {
throw new Error(`Invalid ${lang} changelog block: empty or contains TODO`);
}
}
if (!baseUrl || !token) {
failChangelog('CHANGELOG_API_URL or CHANGELOG_API_TOKEN not configured.');
}
const url = new URL('/api/changelog/vgraph/generate', baseUrl);
const body = JSON.stringify(payload);
const isHttps = url.protocol === 'https:';
const httpModule = isHttps ? require('https') : require('http');
const options = {
method: 'POST',
hostname: url.hostname,
port: url.port || (isHttps ? 443 : 80),
path: url.pathname + url.search,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
'Content-Length': Buffer.byteLength(body),
},
};
const req = httpModule.request(options, res => {
let data = '';
res.on('data', chunk => (data += chunk));
res.on('end', () => {
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
try {
const parsed = JSON.parse(data || '{}');
const en = parsed.blocks && parsed.blocks.en;
const zh = parsed.blocks && parsed.blocks.zh;
if (!en || !zh) {
throw new Error('Missing en/zh blocks in response');
}
validateBlock(String(en), 'en');
validateBlock(String(zh), 'zh');
fs.mkdirSync('.changelog', { recursive: true });
fs.writeFileSync('.changelog/en.md', String(en).trimEnd() + '\n', 'utf8');
fs.writeFileSync('.changelog/zh.md', String(zh).trimEnd() + '\n', 'utf8');
console.log('Changelog blocks generated via API. traceId:', parsed.traceId || '(none)');
} catch (e) {
failChangelog(`Failed to parse API response: ${e.message || e}`);
}
} else {
failChangelog(`Changelog API returned non-2xx status: ${res.statusCode} ${data}`);
}
});
});
req.on('error', err => {
failChangelog(`Changelog API request failed: ${err.message || err}`);
});
req.write(body);
req.end();
NODE
- name: Prepend changelog blocks into release docs (release)
if: startsWith(github.ref_name, 'release/')
env:
RELEASE_VERSION: ${{ steps.semver_release.outputs.main }}
run: |
set -euo pipefail
mkdir -p docs/assets/changelog/en docs/assets/changelog/zh
for lang in en zh; do
block_file=".changelog/${lang}.md"
target_file="docs/assets/changelog/${lang}/release.md"
if [ -f "$block_file" ]; then
node -e '
const fs = require("fs");
const [blockFile, targetFile, version] = process.argv.slice(1);
const block = fs.readFileSync(blockFile, "utf8").trimEnd() + "\n\n";
const oldContent = fs.existsSync(targetFile) ? fs.readFileSync(targetFile, "utf8") : "";
const heading = `# v${version}`;
let contentWithoutSameVersion = oldContent;
if (oldContent.startsWith(`${heading}\n`) || oldContent.startsWith(`${heading}\r\n`)) {
const nextVersionIndex = oldContent.slice(1).search(/\n# v\d/);
contentWithoutSameVersion = nextVersionIndex >= 0 ? oldContent.slice(nextVersionIndex + 2) : "";
}
fs.writeFileSync(targetFile, block + contentWithoutSameVersion.replace(/^\n+/, ""), "utf8");
' "$block_file" "$target_file" "$RELEASE_VERSION"
else
echo "Missing changelog block for $lang, skip."
fi
done
- name: Generate rush version (release)
if: startsWith(github.ref_name, 'release/')
run: node common/scripts/install-run-rush.js version --bump
- name: Update package versions (release)
if: startsWith(github.ref_name, 'release/')
run: node common/scripts/apply-release-version.js none ${{ steps.semver_release.outputs.main }}
- name: Parse semver (hotfix)
if: startsWith(github.ref_name, 'hotfix/')
id: semver_hotfix
uses: xile611/read-package-version-action@main
with:
path: packages/vgraph
semver_string: ${{ github.ref_name }}
semver_pattern: '^hotfix/(.*)$'
- name: Update nextBump (hotfix)
if: startsWith(github.ref_name, 'hotfix/')
uses: xile611/set-next-bump-of-rush@main
with:
release_version: ${{ steps.semver_hotfix.outputs.full }}
write_next_bump: true
- name: Generate rush version (hotfix)
if: startsWith(github.ref_name, 'hotfix/')
run: node common/scripts/install-run-rush.js version --bump
- name: Update package versions (hotfix)
if: startsWith(github.ref_name, 'hotfix/')
run: node common/scripts/apply-release-version.js none ${{ steps.semver_hotfix.outputs.main }}
- name: Parse semver (pre-release)
if: startsWith(github.ref_name, 'pre-release/')
id: semver_prerelease
uses: xile611/read-package-version-action@main
with:
path: packages/vgraph
semver_string: ${{ github.ref_name }}
semver_pattern: '^pre-release/(.*)$'
- name: Update package versions (pre-release)
if: startsWith(github.ref_name, 'pre-release/')
run: node common/scripts/apply-release-version.js ${{ steps.semver_prerelease.outputs.pre_release_name }} ${{ steps.semver_prerelease.outputs.main }}
- name: Build packages
env:
NODE_OPTIONS: '--max_old_space_size=4096'
NO_EMIT_ON_ERROR: 'true'
run: |
set -euo pipefail
package_names=()
while IFS= read -r package_name; do
[ -n "$package_name" ] && package_names+=("$package_name")
done < <(node <<'NODE'
const fs = require('fs');
const path = require('path');
const packagesDir = path.join(process.cwd(), 'packages');
const packageNames = fs
.readdirSync(packagesDir, { withFileTypes: true })
.filter(entry => entry.isDirectory())
.map(entry => path.join(packagesDir, entry.name, 'package.json'))
.filter(packageJsonPath => fs.existsSync(packageJsonPath))
.map(packageJsonPath => JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).name)
.filter(Boolean)
.sort();
console.log(packageNames.join('\n'));
NODE
)
build_args=()
for package_name in "${package_names[@]}"; do
build_args+=(--to "$package_name")
done
echo "Build packages: ${package_names[*]}"
node common/scripts/install-run-rush.js build "${build_args[@]}"
- name: Check npm versions (release)
if: startsWith(github.ref_name, 'release/')
id: npm_version_release
env:
PACKAGE_VERSION: ${{ steps.semver_release.outputs.main }}
run: |
set -euo pipefail
node <<'NODE'
const fs = require('fs');
const { execSync } = require('child_process');
const packageVersion = process.env.PACKAGE_VERSION;
if (!packageVersion) {
console.error('Missing PACKAGE_VERSION for release publish check.');
process.exit(1);
}
const rushJson = JSON.parse(fs.readFileSync('rush.json', 'utf8'));
const publishablePackages = rushJson.projects
.filter(project => project.shouldPublish)
.map(project => project.packageName);
const published = [];
const missing = [];
publishablePackages.forEach(packageName => {
try {
execSync(`npm view "${packageName}@${packageVersion}" version --registry=https://registry.npmjs.org`, {
stdio: 'pipe'
});
published.push(packageName);
} catch (error) {
missing.push(packageName);
}
});
if (missing.length === 0) {
console.log(`All publishable packages already exist on npm for ${packageVersion}, skip publish.`);
fs.appendFileSync(process.env.GITHUB_OUTPUT, 'skip_publish=true\n');
process.exit(0);
}
if (published.length > 0) {
console.log(
`Detected partially published packages for ${packageVersion}. Published: ${published.join(', ')}. Missing: ${missing.join(
', '
)}. Continue publish to let rush skip existing packages and publish missing ones.`
);
} else {
console.log(`No publishable package exists on npm for ${packageVersion}, continue publish.`);
}
fs.appendFileSync(process.env.GITHUB_OUTPUT, 'skip_publish=false\n');
NODE
- name: Publish to npm (release)
if: startsWith(github.ref_name, 'release/') && steps.npm_version_release.outputs.skip_publish != 'true'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: node common/scripts/install-run-rush.js publish --publish --include-all --tag latest
- name: Check npm versions (hotfix)
if: startsWith(github.ref_name, 'hotfix/')
id: npm_version_hotfix
env:
PACKAGE_VERSION: ${{ steps.semver_hotfix.outputs.main }}
run: |
set -euo pipefail
node <<'NODE'
const fs = require('fs');
const { execSync } = require('child_process');
const packageVersion = process.env.PACKAGE_VERSION;
if (!packageVersion) {
console.error('Missing PACKAGE_VERSION for hotfix publish check.');
process.exit(1);
}
const rushJson = JSON.parse(fs.readFileSync('rush.json', 'utf8'));
const publishablePackages = rushJson.projects
.filter(project => project.shouldPublish)
.map(project => project.packageName);
const published = [];
const missing = [];
publishablePackages.forEach(packageName => {
try {
execSync(`npm view "${packageName}@${packageVersion}" version --registry=https://registry.npmjs.org`, {
stdio: 'pipe'
});
published.push(packageName);
} catch (error) {
missing.push(packageName);
}
});
if (missing.length === 0) {
console.log(`All publishable packages already exist on npm for ${packageVersion}, skip publish.`);
fs.appendFileSync(process.env.GITHUB_OUTPUT, 'skip_publish=true\n');
process.exit(0);
}
if (published.length > 0) {
console.error(
`Detected partially published packages for ${packageVersion}. Published: ${published.join(', ')}. Missing: ${missing.join(
', '
)}.`
);
process.exit(1);
}
console.log(`No publishable package exists on npm for ${packageVersion}, continue publish.`);
fs.appendFileSync(process.env.GITHUB_OUTPUT, 'skip_publish=false\n');
NODE
- name: Publish to npm (hotfix)
if: startsWith(github.ref_name, 'hotfix/') && steps.npm_version_hotfix.outputs.skip_publish != 'true'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: node common/scripts/install-run-rush.js publish --publish --include-all --tag hotfix
- name: Check npm versions (pre-release)
if: startsWith(github.ref_name, 'pre-release/')
id: npm_version_prerelease
env:
PACKAGE_VERSION: ${{ steps.semver_prerelease.outputs.full }}
run: |
set -euo pipefail
node <<'NODE'
const fs = require('fs');
const { execSync } = require('child_process');
const packageVersion = process.env.PACKAGE_VERSION;
if (!packageVersion) {
console.error('Missing PACKAGE_VERSION for pre-release publish check.');
process.exit(1);
}
const rushJson = JSON.parse(fs.readFileSync('rush.json', 'utf8'));
const publishablePackages = rushJson.projects
.filter(project => project.shouldPublish)
.map(project => project.packageName);
const published = [];
const missing = [];
publishablePackages.forEach(packageName => {
try {
execSync(`npm view "${packageName}@${packageVersion}" version --registry=https://registry.npmjs.org`, {
stdio: 'pipe'
});
published.push(packageName);
} catch (error) {
missing.push(packageName);
}
});
if (missing.length === 0) {
console.log(`All publishable packages already exist on npm for ${packageVersion}, skip publish.`);
fs.appendFileSync(process.env.GITHUB_OUTPUT, 'skip_publish=true\n');
process.exit(0);
}
if (published.length > 0) {
console.error(
`Detected partially published packages for ${packageVersion}. Published: ${published.join(', ')}. Missing: ${missing.join(
', '
)}.`
);
process.exit(1);
}
console.log(`No publishable package exists on npm for ${packageVersion}, continue publish.`);
fs.appendFileSync(process.env.GITHUB_OUTPUT, 'skip_publish=false\n');
NODE
- name: Publish to npm (pre-release)
if: startsWith(github.ref_name, 'pre-release/') && steps.npm_version_prerelease.outputs.skip_publish != 'true'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: node common/scripts/install-run-rush.js publish --publish --include-all --tag ${{ steps.semver_prerelease.outputs.pre_release_type }}
- name: Update shrinkwrap
run: node common/scripts/install-run-rush.js update
- name: Get npm version (release)
if: startsWith(github.ref_name, 'release/')
id: package_version_release
uses: xile611/read-package-version-action@main
with:
path: packages/vgraph
- name: Get npm version (hotfix)
if: startsWith(github.ref_name, 'hotfix/')
id: package_version_hotfix
uses: xile611/read-package-version-action@main
with:
path: packages/vgraph
- name: Get npm version (pre-release)
if: startsWith(github.ref_name, 'pre-release/')
id: package_version_prerelease
uses: xile611/read-package-version-action@main
with:
path: packages/vgraph
- name: Commit & Push changes (release)
if: startsWith(github.ref_name, 'release/')
run: |
set -euo pipefail
if git diff --quiet; then
echo 'No changes to commit for release branch.'
else
rm -rf .changelog
find docs/assets/changelog -name '*.bak' -delete 2>/dev/null || true
git add .
git commit -m "build: release version ${{ steps.package_version_release.outputs.current_version }} [skip ci]" -n
git push --no-verify origin ${{ github.ref_name }}
fi
- name: Commit & Push changes (hotfix)
if: startsWith(github.ref_name, 'hotfix/')
run: |
set -euo pipefail
if git diff --quiet; then
echo 'No changes to commit for hotfix branch.'
else
git add .
git commit -m "build: hotfix version ${{ steps.package_version_hotfix.outputs.current_version }} [skip ci]" -n
git push --no-verify origin ${{ github.ref_name }}
fi
- name: Commit & Push changes (pre-release)
if: startsWith(github.ref_name, 'pre-release/')
run: |
set -euo pipefail
if git diff --quiet; then
echo 'No changes to commit for pre-release branch.'
else
git add .
git commit -m "build: prerelease version ${{ steps.package_version_prerelease.outputs.current_version }} [skip ci]" -n
git push --no-verify origin ${{ github.ref_name }}
fi
- name: Create Pull Request to main (release)
if: startsWith(github.ref_name, 'release/')
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
BRANCH="${GITHUB_REF_NAME}"
if gh pr list --base main --head "$BRANCH" --state open --json number --limit 1 | grep -q '"number"'; then
echo "PR from $BRANCH to main already exists, skip creating."
exit 0
fi
TITLE="[Auto release] release ${{ steps.package_version_release.outputs.current_version }}"
BODY="This PR merges release branch $BRANCH into main."
gh pr create --base main --head "$BRANCH" --title "$TITLE" --body "$BODY"