diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9329cf..9775cd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,9 @@ jobs: - name: Install dependencies run: yarn install --frozen-lockfile + - name: Verify release workflow + run: node --test scripts/tests/release-workflow.test.cjs + - name: Run lint and tests with coverage run: yarn lint && yarn test:coverage @@ -82,6 +85,7 @@ jobs: package_name="$(node -p "require('./package.json').name")" base_branch="${GITHUB_REF_NAME}" + release_head="${GITHUB_SHA}" published_version="$(npm view "$package_name" version 2>/dev/null || true)" auth_header="$( node -e "process.stdout.write('AUTHORIZATION: basic ' + Buffer.from('x-access-token:' + process.env.GITHUB_TOKEN).toString('base64'))" @@ -91,6 +95,23 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git fetch origin "$base_branch" --tags --force + # Older PRs were already shipped by the legacy release workflow. + release_baseline="$(git log "$release_head" --first-parent -n 1 --format=%H --extended-regexp --grep='^chore\(release\): .*Release v[^ ]+ for #[0-9]+$')" + if [ -z "$release_baseline" ]; then + echo "Cannot determine the historical release baseline." + exit 1 + fi + + is_queued_release() { + git merge-base --is-ancestor "$1" "$release_head" && + ! git merge-base --is-ancestor "$1" "$release_baseline" + } + + create_release_tag() { + # Tag an existing remote commit; do not manufacture historical workflows. + git tag -a "$tag" "$merge_commit" -m "Release v${next_version} for #${pr_number}" + } + max_version() { node - "$1" "$2" <<'NODE' const [left, right] = process.argv.slice(2); @@ -139,7 +160,10 @@ jobs: while IFS= read -r tag; do release_commit="$(git rev-parse "${tag}^{commit}")" - subject="$(git show -s --format=%s "$release_commit")" + subject="$(git for-each-ref --format='%(contents:subject)' "refs/tags/${tag}")" + if ! [[ "$subject" =~ ^Release\ v([^[:space:]]+)\ for\ \#${pr_number}$ ]]; then + subject="$(git show -s --format=%s "$release_commit")" + fi if [[ "$subject" =~ Release\ v([^[:space:]]+)\ for\ \#${pr_number}$ ]]; then version="${BASH_REMATCH[1]}" @@ -232,6 +256,14 @@ jobs: ;; esac + if [ -z "$merge_commit" ]; then + echo "Merged PR #${pr_number} has no merge commit." + exit 1 + fi + if ! is_queued_release "$merge_commit"; then + continue + fi + if release_info="$(release_for_pr "$pr_number")"; then IFS=$'\t' read -r tag version release_commit <<< "$release_info" @@ -240,9 +272,11 @@ jobs: else echo "Resuming npm publication for PR #${pr_number} as ${version}." git checkout --detach "$release_commit" + npm version "$version" --no-git-tag-version --allow-same-version yarn install --frozen-lockfile yarn build npm publish --access public --tag latest + git restore -- package.json fi published_version="$(max_version "$published_version" "$version")" @@ -250,11 +284,6 @@ jobs: continue fi - if [ -z "$merge_commit" ]; then - echo "Merged PR #${pr_number} has no merge commit." - exit 1 - fi - git checkout --detach "$merge_commit" manifest_version="$(node -p "require('./package.json').version")" base_version="$(max_version "$manifest_version" "$published_version")" @@ -270,14 +299,11 @@ jobs: yarn install --frozen-lockfile yarn build - git add package.json - git commit -m "chore(release): 🔖 Release v${next_version} for #${pr_number}" - release_commit="$(git rev-parse HEAD)" - - git tag -a "$tag" "$release_commit" -m "Release v${next_version} for #${pr_number}" + create_release_tag git -c "http.https://github.com/.extraheader=$auth_header" push origin "refs/tags/${tag}" npm publish --access public --tag latest + git restore -- package.json published_version="$next_version" ensure_github_release "$pr_json" "$tag" "$next_version" "$bump" done diff --git a/scripts/tests/release-workflow.test.cjs b/scripts/tests/release-workflow.test.cjs new file mode 100644 index 0000000..18fb7f9 --- /dev/null +++ b/scripts/tests/release-workflow.test.cjs @@ -0,0 +1,106 @@ +const assert = require('node:assert/strict'); +const { execFileSync, spawnSync } = require('node:child_process'); +const { mkdtempSync, readFileSync, rmSync, writeFileSync } = require('node:fs'); +const { tmpdir } = require('node:os'); +const { resolve, join } = require('node:path'); +const { test } = require('node:test'); +const workflow = readFileSync( + resolve(__dirname, '../../.github/workflows/ci.yml'), + 'utf8', +) + .split('\n') + .map((line) => (line.startsWith(' ') ? line.slice(10) : line)) + .join('\n'); +function shellFunction(name) { + const start = workflow.indexOf(`${name}() {`); + assert.notEqual(start, -1, `Missing workflow function ${name}`); + const end = workflow.indexOf('\n}', start); + assert.notEqual(end, -1); + return workflow.slice(start, end + 2); +} +function fixture(t) { + const cwd = mkdtempSync(join(tmpdir(), 'release-queue-test-')); + t.after(() => rmSync(cwd, { recursive: true, force: true })); + const git = (...args) => + execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + git('init', '-b', 'master'); + git('config', 'user.name', 'Test'); + git('config', 'user.email', 'test@example.invalid'); + const commit = (subject) => { + writeFileSync(join(cwd, 'file'), subject); + git('add', '.'); + git('commit', '-m', subject); + return git('rev-parse', 'HEAD'); + }; + const historical = commit('old feature (#1)'); + const baseline = commit('chore(release): 🔖 Release v4.0.1 for #51'); + const pending = commit('new feature (#53)'); + const head = commit('package feature (#59)'); + const future = commit('later feature (#60)'); + return { cwd, git, commit, historical, baseline, pending, head, future }; +} +test('release queue excludes historical and future merges', (t) => { + const f = fixture(t); + for (const [commit, expected] of [ + [f.historical, 1], + [f.baseline, 1], + [f.pending, 0], + [f.head, 0], + [f.future, 1], + ]) { + const result = spawnSync( + 'bash', + [ + '-c', + `${shellFunction('is_queued_release')}\nrelease_baseline=$1; release_head=$2; is_queued_release "$3"`, + 'test', + f.baseline, + f.head, + commit, + ], + { cwd: f.cwd, encoding: 'utf8' }, + ); + assert.equal(result.status, expected, result.stderr); + } +}); +test('annotated release tag records version without creating a new commit', (t) => { + const f = fixture(t); + execFileSync( + 'bash', + [ + '-c', + `${shellFunction('create_release_tag')}\ntag=v5.0.0; next_version=5.0.0; pr_number=53; merge_commit=$1; create_release_tag`, + 'test', + f.pending, + ], + { cwd: f.cwd }, + ); + assert.equal(f.git('rev-parse', 'v5.0.0^{commit}'), f.pending); + const result = execFileSync( + 'bash', + ['-c', `${shellFunction('release_for_pr')}\nrelease_for_pr 53`], + { cwd: f.cwd, encoding: 'utf8' }, + ).trim(); + assert.equal(result, `v5.0.0\t5.0.0\t${f.pending}`); +}); +test('legacy release commits remain recognized and PR numbers match exactly', (t) => { + const f = fixture(t); + const release = f.commit('chore(release): 🔖 Release v4.0.1 for #51'); + f.git('tag', '-a', 'v4.0.1', release, '-m', 'v4.0.1'); + const result = execFileSync( + 'bash', + ['-c', `${shellFunction('release_for_pr')}\nrelease_for_pr 51`], + { cwd: f.cwd, encoding: 'utf8' }, + ).trim(); + assert.equal(result, `v4.0.1\t4.0.1\t${release}`); + const absent = spawnSync( + 'bash', + ['-c', `${shellFunction('release_for_pr')}\nrelease_for_pr 5`], + { cwd: f.cwd }, + ); + assert.equal(absent.status, 1); +});