-
Notifications
You must be signed in to change notification settings - Fork 5
refactor(release): extract package resolution into a tested script #151
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -36,3 +36,6 @@ jobs: | |
|
|
||
| - name: Run tests | ||
| run: yarn test | ||
|
|
||
| - name: Run release script tests | ||
| run: yarn test:scripts | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| /** | ||
| * Releasable workspaces, keyed by their directory under `packages/`. | ||
| * | ||
| * Both release workflows resolve names and directories through here, so | ||
| * adding a package to the release rotation is a one-line change. | ||
| */ | ||
| export const RELEASABLE = { | ||
| builder: 'compact-builder', | ||
| cli: 'compact-cli', | ||
| simulator: 'compact-simulator', | ||
| }; | ||
|
|
||
| /** Workflow input name (`compact-cli`) to workspace directory (`cli`). */ | ||
| export function dirForPackage(name) { | ||
| const dir = Object.keys(RELEASABLE).find((key) => RELEASABLE[key] === name); | ||
| if (!dir) { | ||
| throw new Error(`unknown package: ${name}`); | ||
| } | ||
| return dir; | ||
| } | ||
|
|
||
| /** Workspace directory (`cli`) to workflow input name (`compact-cli`). */ | ||
| export function packageForDir(dir) { | ||
| const name = RELEASABLE[dir]; | ||
| if (!name) { | ||
| throw new Error(`unknown package directory: ${dir}`); | ||
|
Comment on lines
+23
to
+26
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -euo pipefail
# Verify that resolver outputs are consumed as release package paths or names.
rg -n -C 5 'resolve-package\.mjs|steps\..*\.outputs\.(dir|name)|packages/\$\{\{' \
.github/workflows scripts/releaseRepository: OpenZeppelin/compact-tools Length of output: 7484 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- repository conventions ---'
head -5 /tmp/coderabbit-repo-knowledge/openzeppelin-compact-tools-5d46d737/*/*.md 2>/dev/null || true
printf '%s\n' '--- release package definitions and resolver ---'
cat -n scripts/release/packages.mjs
cat -n scripts/release/resolve-package.mjsRepository: OpenZeppelin/compact-tools Length of output: 4512 Reject inherited property names in
🤖 Prompt for AI Agents |
||
| } | ||
| return name; | ||
| } | ||
|
|
||
| /** | ||
| * Resolve the released workspace from the `packages/*\/package.json` paths a | ||
| * merge touched. | ||
| * | ||
| * A release commit bumps exactly one package. Two or zero means the merge was | ||
| * not the one the publish workflow expected, and guessing which to ship would | ||
| * publish the wrong thing. | ||
| */ | ||
| export function dirFromChangedFiles(paths) { | ||
| const bumps = paths | ||
| .map((path) => path.trim()) | ||
| .filter((path) => /^packages\/[^/]+\/package\.json$/.test(path)); | ||
|
|
||
| if (bumps.length !== 1) { | ||
| throw new Error( | ||
| `expected exactly one packages/*/package.json change, found ${bumps.length}${ | ||
| bumps.length ? `: ${bumps.join(', ')}` : '' | ||
| }`, | ||
| ); | ||
| } | ||
|
|
||
| const dir = bumps[0].split('/')[1]; | ||
| packageForDir(dir); | ||
| return dir; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| #!/usr/bin/env node | ||
| /** | ||
| * Resolve the package a release workflow is acting on, and append `dir`, | ||
| * `name`, and `version` to `$GITHUB_OUTPUT`. | ||
| * | ||
| * node scripts/release/resolve-package.mjs --package compact-cli | ||
| * node scripts/release/resolve-package.mjs --changed-files "$CHANGED" | ||
| * | ||
| * `--changed-files` takes the newline-separated output of `git diff | ||
| * --name-only`. Without `$GITHUB_OUTPUT` the result goes to stdout instead, | ||
| * so the script is runnable by hand. | ||
| */ | ||
| import { appendFileSync, readFileSync } from 'node:fs'; | ||
| import { | ||
| dirForPackage, | ||
| dirFromChangedFiles, | ||
| packageForDir, | ||
| } from './packages.mjs'; | ||
|
|
||
| function parseArgs(argv) { | ||
| const args = {}; | ||
| for (let i = 0; i < argv.length; i += 2) { | ||
| args[argv[i]] = argv[i + 1]; | ||
| } | ||
| return args; | ||
| } | ||
|
|
||
| function main(argv) { | ||
| const args = parseArgs(argv); | ||
| if ( | ||
| args['--package'] === undefined && | ||
| args['--changed-files'] === undefined | ||
| ) { | ||
| throw new Error('pass either --package or --changed-files'); | ||
| } | ||
|
|
||
| const dir = | ||
| args['--package'] !== undefined | ||
| ? dirForPackage(args['--package']) | ||
| : dirFromChangedFiles(args['--changed-files'].split('\n')); | ||
|
|
||
| const { version } = JSON.parse( | ||
| readFileSync(`packages/${dir}/package.json`, 'utf8'), | ||
| ); | ||
| const output = `dir=${dir}\nname=${packageForDir(dir)}\nversion=${version}\n`; | ||
|
|
||
| if (process.env.GITHUB_OUTPUT) { | ||
| appendFileSync(process.env.GITHUB_OUTPUT, output); | ||
| } | ||
| process.stdout.write(output); | ||
| } | ||
|
|
||
| try { | ||
| main(process.argv.slice(2)); | ||
| } catch (error) { | ||
| // Annotations are read from stdout, so this must not go to stderr. | ||
| process.stdout.write(`::error::${error.message}\n`); | ||
| process.exit(1); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| import { describe, expect, it } from 'vitest'; | ||
| import { | ||
| dirForPackage, | ||
| dirFromChangedFiles, | ||
| packageForDir, | ||
| RELEASABLE, | ||
| } from '../packages.mjs'; | ||
|
|
||
| describe('dirForPackage', () => { | ||
| it.each(Object.entries(RELEASABLE))( | ||
| 'maps %s to its directory', | ||
| (dir, name) => { | ||
| expect(dirForPackage(name)).toBe(dir); | ||
| }, | ||
| ); | ||
|
|
||
| it('rejects a package outside the release rotation', () => { | ||
| expect(() => dirForPackage('compact-deployer')).toThrow(/unknown package/); | ||
| }); | ||
|
|
||
| it('rejects a scoped name', () => { | ||
| expect(() => dirForPackage('@openzeppelin/compact-cli')).toThrow( | ||
| /unknown package/, | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe('packageForDir', () => { | ||
| it.each(Object.entries(RELEASABLE))( | ||
| 'maps %s to its package name', | ||
| (dir, name) => { | ||
| expect(packageForDir(dir)).toBe(name); | ||
| }, | ||
| ); | ||
|
|
||
| it('rejects a directory outside the release rotation', () => { | ||
| expect(() => packageForDir('deployer')).toThrow( | ||
| /unknown package directory/, | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe('dirFromChangedFiles', () => { | ||
| it('resolves the single bumped package', () => { | ||
| expect(dirFromChangedFiles(['packages/simulator/package.json'])).toBe( | ||
| 'simulator', | ||
| ); | ||
| }); | ||
|
|
||
| it('ignores files that are not a workspace manifest', () => { | ||
| expect( | ||
| dirFromChangedFiles([ | ||
| 'packages/cli/src/index.ts', | ||
| 'packages/cli/package.json', | ||
| 'package.json', | ||
| 'RELEASING.md', | ||
| ]), | ||
| ).toBe('cli'); | ||
| }); | ||
|
|
||
| it('tolerates trailing whitespace from the git diff', () => { | ||
| expect( | ||
| dirFromChangedFiles(['packages/builder/package.json ', '', ' ']), | ||
| ).toBe('builder'); | ||
| }); | ||
|
|
||
| it('refuses to guess when two packages were bumped', () => { | ||
| expect(() => | ||
| dirFromChangedFiles([ | ||
| 'packages/cli/package.json', | ||
| 'packages/builder/package.json', | ||
| ]), | ||
| ).toThrow( | ||
| /found 2: packages\/cli\/package\.json, packages\/builder\/package\.json/, | ||
| ); | ||
| }); | ||
|
|
||
| it('refuses when no package was bumped', () => { | ||
| expect(() => dirFromChangedFiles(['README.md'])).toThrow(/found 0/); | ||
| }); | ||
|
|
||
| it('does not treat a nested manifest as a workspace bump', () => { | ||
| expect(() => | ||
| dirFromChangedFiles(['packages/cli/node_modules/x/package.json']), | ||
| ).toThrow(/found 0/); | ||
| }); | ||
|
|
||
| it('rejects a bump in a directory outside the release rotation', () => { | ||
| expect(() => | ||
| dirFromChangedFiles(['packages/deployer/package.json']), | ||
| ).toThrow(/unknown package directory: deployer/); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| import { defineConfig } from 'vitest/config'; | ||
|
|
||
| // The repo-level release helpers are not a workspace, so turbo's per-package | ||
| // `test` task does not reach them. `yarn test:scripts` runs them. | ||
| export default defineConfig({ | ||
| test: { | ||
| environment: 'node', | ||
| include: ['scripts/**/test/**/*.test.mjs'], | ||
| reporters: 'verbose', | ||
| }, | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Quote the step-summary path.
The summary block redirects to
$GITHUB_STEP_SUMMARYwithout quotes. ShellCheck reports SC2086. Use>> "$GITHUB_STEP_SUMMARY"to prevent word splitting and glob expansion.Proposed fix
🧰 Tools
🪛 actionlint (1.7.12)
[error] 73-73: shellcheck reported issue in this script: SC2086:info:6:6: Double quote to prevent globbing and word splitting
(shellcheck)
🤖 Prompt for AI Agents
Source: Linters/SAST tools