Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions .github/workflows/integration.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,68 @@ jobs:
fi

echo "Integration test passed for ${{ matrix.pkg-manager }} with ${{ matrix.template }}!"

# The matrix above exercises Yarn 1.x (Classic). Yarn 2+ (Berry) is a different animal: it isn't
# on the runner, and a scaffolded project pins no packageManager/.yarnrc.yml, so the generated
# deploy workflow provisions it with Corepack (lib/pkg/getWorkflowSubstitutions.js). This job runs
# that generated setup + install path for real, in the workflow's own ordering — Corepack
# activation with the runner's default Node *before* setup-node swaps it — to prove the activated
# Yarn survives the Node swap and that `yarn install --immutable` (which Classic would reject) then
# succeeds. deployWorkflow.test.js asserts the generator still emits exactly these commands.
yarn-berry-install:
name: Yarn Berry deploy-install path
runs-on: ubuntu-latest
env:
YARN_VERSION: 4.9.1
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

# Runs against the runner's default Node, before the pinned Node below — exactly as the
# generated workflow's "Set up Yarn" step precedes its "Set up Node.js" step.
- name: Activate Yarn Berry with Corepack
run: corepack enable && corepack prepare "yarn@$YARN_VERSION" --activate

- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: '.nvmrc'
cache: npm

- name: Install dependencies
run: npm ci

- name: Run the generated Yarn Berry setup + install path
env:
CREATE_HARPER_SKIP_UPDATE: 'true'
run: |
set -euo pipefail
REPO="$(pwd)"
# Scaffold outside the repo: Berry looks upward for its project root, and create-harper's
# own package.json would otherwise capture the new project as a stray workspace.
PROJECT_DIR="$(mktemp -d)/berry-app"
mkdir -p "$(dirname "$PROJECT_DIR")"

( cd "$(dirname "$PROJECT_DIR")" \
&& npm_config_user_agent="yarn/$YARN_VERSION npm/? node/$(node -v) linux x64" \
node "$REPO/index.js" berry-app --template vanilla --no-interactive --overwrite --skip-install )

workflow="$PROJECT_DIR/.github/workflows/deploy.yaml"
echo "Asserting the generated workflow drives Yarn through Corepack, not the runner's Yarn 1..."
grep -q "corepack prepare yarn@$YARN_VERSION --activate" "$workflow"
grep -q 'yarn install --immutable' "$workflow"

cd "$PROJECT_DIR"
echo "yarn is $(yarn --version) at $(command -v yarn)"

# Stand in for the yarn.lock the user commits: Berry auto-enables immutable installs on a
# public PR runner, which forbids creating a lockfile, so disable it for this one seeding
# install. (A fresh scaffold ships no lockfile; the deploy workflow runs against a repo
# that already has one.)
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install

# The reviewer's failure mode: `yarn install --immutable` under the runner's preinstalled
# Yarn 1 errors on the unknown flag. That `yarn is 4.9.1` line above already shows the
# Corepack activation survived setup-node's Node swap; this proves the generated command
# then succeeds under Berry.
yarn install --immutable
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
.DS_Store
.temp-integration-tests

# Matches a `node_modules` *symlink* too — git worktrees commonly link to the main checkout's
# install, and the `node_modules/` entry in the vendored Node.gitignore below has a trailing
# slash, so it only ever matches a real directory.
node_modules

# Playwright e2e artifacts
/test-results/
/playwright-report/
Expand Down
2 changes: 1 addition & 1 deletion lib/init.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export async function init() {
// Write out the contents based on all prior steps.
const cwd = process.cwd();
const root = path.join(cwd, targetDir);
scaffoldProject(root, projectName, packageName, template, envVars, pkgManager);
scaffoldProject(root, projectName, packageName, template, envVars, pkgManager, pkgInfo?.version);

// Log out the next steps.
installAndOptionallyStart(root, pkgManager, immediate, args.skipInstall, selectedSkills, selectedAgents);
Expand Down
2 changes: 2 additions & 0 deletions lib/init.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ describe('init.js', () => {
'vanilla',
{ target: 't' },
expect.any(String),
// The package manager's version, which pins it in the scaffolded CI workflows.
expect.any(String),
);
expect(installAndOptionallyStart).toHaveBeenCalledWith(
expect.stringContaining('my-dir'),
Expand Down
188 changes: 188 additions & 0 deletions lib/pkg/getWorkflowSubstitutions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
// Indentation of the scaffolded GitHub Actions workflows (2-space YAML, so a `steps:` entry's
// `-` sits six columns in). Each multi-line value below replaces a placeholder comment that
// already sits at that indent, so a value's *first* line carries no indentation and every
// continuation line carries it explicitly.
const STEP_INDENT = ' '.repeat(6);
const KEY_INDENT = ' '.repeat(2);
const INPUT_INDENT = ' '.repeat(4);

/**
* Builds one `steps:` entry, or a bare comment when a package manager needs no setup step.
*
* @param {{name?: string, comment?: string[], uses?: string, inputs?: Record<string, string>, run?: string}} step
* @returns {string} - The step's YAML, indented for substitution into the workflow.
*/
function buildStep({ name, comment, uses, inputs, run }) {
const lines = [];
if (name) { lines.push(`- name: ${name}`); }
for (const line of comment ?? []) {
// A comment-only value stands in for a step, so it starts at the step indent; a comment
// documenting a step is nested with that step's other keys.
lines.push(`${name ? KEY_INDENT : ''}# ${line}`);
}
if (uses) { lines.push(`${KEY_INDENT}uses: ${uses}`); }
if (inputs) {
lines.push(`${KEY_INDENT}with:`);
for (const [input, value] of Object.entries(inputs)) {
lines.push(`${INPUT_INDENT}${input}: ${value}`);
}
}
if (run) { lines.push(`${KEY_INDENT}run: ${run}`); }
return lines.join(`\n${STEP_INDENT}`);
}

/**
* Extracts the major version from a package manager version string.
*
* @param {string | undefined} version - A version such as '4.9.1'.
* @returns {number | undefined} - The major version, or undefined if it can't be determined.
*/
function majorVersion(version) {
const major = Number.parseInt(version ?? '', 10);
return Number.isNaN(major) ? undefined : major;
}

/**
* Builds the step that puts the project's package manager on PATH, pinned to the version that
* generated its lockfile. npm and Yarn 1.x (Classic) need none — npm ships with Node.js, and
* Classic is preinstalled on GitHub's Ubuntu runners — so they get a comment saying so instead.
*
* @param {string} agent - The package manager agent ('npm', 'pnpm', 'yarn', 'bun' or 'deno').
* @param {string} [version] - The agent's version, as reported by the user agent that invoked us.
* @returns {string} - The step's YAML.
*/
function getSetupStep(agent, version) {
switch (agent) {
case 'pnpm':
return buildStep({
name: 'Set up pnpm',
comment: [
"Pinned to the pnpm that wrote this project's lockfile. The action is SHA-pinned, but a",
'floating `version:` would still let it self-install an unvetted pnpm at run time.',
],
uses: 'pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9',
inputs: { version: version ?? 'latest' },
});
case 'bun':
return buildStep({
name: 'Set up Bun',
comment: ["Pinned to the Bun that wrote this project's lockfile."],
uses: 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0',
inputs: { 'bun-version': version ?? 'latest' },
});
case 'deno':
return buildStep({
name: 'Set up Deno',
comment: ["Pinned to the Deno that wrote this project's lockfile."],
uses: 'denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2.0.5',
inputs: { 'deno-version': version ?? 'vx.x.x' },
});
case 'yarn':
Comment thread
dawsontoth marked this conversation as resolved.
// Only Yarn 2+ (Berry) needs provisioning. The runner ships Yarn 1.x (Classic), and a
// scaffolded project pins no `packageManager`/`.yarnrc.yml`, so without this Berry's
// `--immutable` (below) would run under Classic, which rejects the flag and stops the
// deploy at install. Corepack activates the exact Yarn that wrote yarn.lock: `enable`
// installs the shim, `prepare … --activate` sets that version as the default a bare
// `yarn` resolves to (no `packageManager` field required).
if ((majorVersion(version) ?? 1) < 2) {
return buildStep({
comment: ["Yarn 1.x (Classic) is preinstalled on GitHub's Ubuntu runners, so it needs no setup step."],
});
}
return buildStep({
name: `Set up Yarn ${version}`,
comment: ["Pinned to the Yarn that wrote this project's lockfile."],
run: `corepack enable && corepack prepare yarn@${version} --activate`,
});
default:
return buildStep({ comment: ['npm ships with Node.js, so it needs no setup step.'] });
}
}

/**
* Builds `actions/setup-node`'s `cache:` input. Only npm, Yarn and pnpm are supported there;
* Bun and Deno cache through their own setup actions, so they get a comment explaining the gap
* rather than an input setup-node would reject.
*
* @param {string} agent - The package manager agent ('npm', 'pnpm', 'yarn', 'bun' or 'deno').
* @returns {string} - The `cache:` input, or a comment.
*/
function getNodeCacheInput(agent) {
switch (agent) {
case 'bun':
return "# setup-node caches npm, Yarn and pnpm only; oven-sh/setup-bun caches Bun's store itself.";
case 'deno':
return '# setup-node caches npm, Yarn and pnpm only; denoland/setup-deno caches DENO_DIR itself.';
case 'pnpm':
case 'yarn':
return `cache: '${agent}'`;
default:
return "cache: 'npm'";
}
}

/**
* Gets the lockfile-respecting install command for CI, which must fail rather than update the
* lockfile when it has drifted from package.json.
*
* @param {string} agent - The package manager agent ('npm', 'pnpm', 'yarn', 'bun' or 'deno').
* @param {string} [version] - The agent's version, as reported by the user agent that invoked us.
* @returns {string} - The install command.
*/
function getCiInstallCommand(agent, version) {
switch (agent) {
case 'pnpm':
case 'bun':
return `${agent} install --frozen-lockfile`;
case 'yarn':
// Yarn renamed the flag in 2.0; Yarn 1 rejects `--immutable` and Yarn 2+ rejects
// `--frozen-lockfile`, so pick by the version that scaffolded the project.
return (majorVersion(version) ?? 1) >= 2 ? 'yarn install --immutable' : 'yarn install --frozen-lockfile';
case 'deno':
return 'deno install --frozen';
default:
return 'npm ci';
}
}

/**
* Gets the command prefix that runs a package.json script, e.g. `npm run` in `npm run deploy`.
*
* @param {string} agent - The package manager agent ('npm', 'pnpm', 'yarn', 'bun' or 'deno').
* @returns {string} - The prefix, without a trailing space.
*/
function getRunScriptPrefix(agent) {
switch (agent) {
case 'deno':
return 'deno task';
case 'pnpm':
case 'yarn':
case 'bun':
return `${agent} run`;
default:
return 'npm run';
}
}

/**
* Builds the substitutions that adapt a scaffolded project's GitHub Actions workflows to the
* package manager that invoked us. Without them the workflows would hard-code npm and fail for
* everyone else: setup-node can't resolve a package lock for a project whose lockfile is
* `pnpm-lock.yaml`, and `npm ci` errors out before the job ever reaches tests or deploy.
*
* Placeholders that stand in for a whole line are written as YAML comments in the templates, so
* the committed workflows stay valid, formattable YAML; the indentation contract for their
* multi-line replacements lives in this module.
*
* @param {string} agent - The package manager agent ('npm', 'pnpm', 'yarn', 'bun' or 'deno').
* @param {string} [version] - The agent's version, as reported by the user agent that invoked us.
* @returns {Record<string, string>} - A mapping of placeholder to replacement.
*/
export function getWorkflowSubstitutions(agent, version) {
return {
'# your-package-manager-setup-step-here': getSetupStep(agent, version),
'# your-package-manager-node-cache-here': getNodeCacheInput(agent),
'your-package-manager-install-here': getCiInstallCommand(agent, version),
'your-package-manager-run-here': getRunScriptPrefix(agent),
};
}
Loading