From c24abbea717f94b355417042e2004cc70961096a Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Wed, 14 Oct 2026 13:59:07 -0400 Subject: [PATCH 1/3] fix(templates): build Next.js in the test job, not the job that can mint a deploy token The Next.js deploy job installed dependencies and ran next build with id-token: write, so a compromised dependency's script could request the job's identity token and trade it for the deploy user's Harper token. The test job now builds and uploads .next (without .next/cache) as an artifact on main, and the deploy job downloads it after checkout and otherwise matches the shared deploy job: it installs only the Harper CLI. Co-Authored-By: Claude Opus 5.5 --- template-nextjs-ts/README.md | 4 +- .../_github/workflows/deploy.yaml | 31 +++-- template-nextjs/README.md | 4 +- template-nextjs/_github/workflows/deploy.yaml | 31 +++-- template.tests/deployWorkflow.test.js | 113 ++++++++++++++---- templates-shared/applySharedTemplates.js | 8 +- 6 files changed, 141 insertions(+), 50 deletions(-) diff --git a/template-nextjs-ts/README.md b/template-nextjs-ts/README.md index cdd5973..78446c2 100644 --- a/template-nextjs-ts/README.md +++ b/template-nextjs-ts/README.md @@ -56,7 +56,7 @@ Put data access in **server actions** (see [`app/actions.ts`](./app/actions.ts)) ## Deployment -Your app deploys from GitHub: every pull request runs lint, and merging to `main` deploys to your Harper cluster with the included [GitHub Actions workflow](./.github/workflows/deploy.yaml). No Harper password or token is stored in GitHub. +Your app deploys from GitHub: every pull request runs lint and `next build`, and merging to `main` deploys to your Harper cluster with the included [GitHub Actions workflow](./.github/workflows/deploy.yaml). No Harper password or token is stored in GitHub. First, head to [https://fabric.harper.fast/](https://fabric.harper.fast/), log in, and create a cluster. Then log your local CLI in to it as a super user: @@ -95,7 +95,7 @@ The deploy user can deploy any app on the cluster, so who can deploy comes down ### What a deploy does -The workflow runs `next build` and uploads the project with its `.next` build, so no build runs on the cluster (building there currently fails; see the note in [`config.yaml`](./config.yaml)). It deploys with `restart=rolling`: each node restarts in turn, so the cluster keeps serving. On Harper 5.4 and later, each node first loads the release in a canary worker, and only serves it if it loads. A release that fails to load is rejected, the run fails and names the node, and the previous release keeps serving. +The workflow's test job runs `next build`, and the deploy job uploads the project with that `.next` build, so no build runs on the cluster (building there currently fails; see the note in [`config.yaml`](./config.yaml)). The build stays out of the deploy job, so your dependencies' install and build scripts never run where the OIDC token can be requested. It deploys with `restart=rolling`: each node restarts in turn, so the cluster keeps serving. On Harper 5.4 and later, each node first loads the release in a canary worker, and only serves it if it loads. A release that fails to load is rejected, the run fails and names the node, and the previous release keeps serving. The run's summary names the deployment and its `certification`: diff --git a/template-nextjs-ts/_github/workflows/deploy.yaml b/template-nextjs-ts/_github/workflows/deploy.yaml index 16da4b4..0ca278c 100644 --- a/template-nextjs-ts/_github/workflows/deploy.yaml +++ b/template-nextjs-ts/_github/workflows/deploy.yaml @@ -1,9 +1,9 @@ # Tests every pull request, and deploys to your Harper cluster when a change merges to `main`. The # deploy job holds no Harper credential: its `harper` commands authenticate with GitHub's OIDC token # under a trust policy on the cluster. Set that up once with -# `your-package-manager-run-here deploy:setup-ci` (see "Deployment" in the README). The deploy job -# runs `next build` and uploads it, because building on the cluster currently fails -# (HarperFast/nextjs#57, HarperFast/nextjs#58). +# `your-package-manager-run-here deploy:setup-ci` (see "Deployment" in the README). The test job +# runs `next build` and hands it to the deploy job, which uploads it, because building on the cluster +# currently fails (HarperFast/nextjs#57, HarperFast/nextjs#58). name: Deploy to Harper on: pull_request: @@ -38,6 +38,20 @@ jobs: run: your-package-manager-install-here - name: Run lint run: your-package-manager-run-here lint + - name: Build + run: your-package-manager-run-here build + - name: Hand the build to the deploy job + # Building here keeps your dependencies' scripts out of the job that can request an OIDC token. + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: next-build + path: | + .next/ + !.next/cache/ + include-hidden-files: true + # A re-run of the whole workflow builds again, under the same name. + overwrite: true deploy: needs: test @@ -63,16 +77,15 @@ jobs: with: # The deploy uploads this directory, `.git` included, so keep the job's token out of it. persist-credentials: false - # your-package-manager-setup-step-here + - name: Download the build + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: next-build + path: .next - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - # your-package-manager-node-cache-here node-version-file: '.nvmrc' - - name: Install dependencies - run: your-package-manager-install-here - - name: Build - run: your-package-manager-run-here build - name: Install Harper CLI # 5.3 is the first CLI that authenticates with the OIDC token. run: npm install --global harper@^5.3 diff --git a/template-nextjs/README.md b/template-nextjs/README.md index 184d2bd..8b49d70 100644 --- a/template-nextjs/README.md +++ b/template-nextjs/README.md @@ -56,7 +56,7 @@ Put data access in **server actions** (see [`app/actions.js`](./app/actions.js)) ## Deployment -Your app deploys from GitHub: every pull request runs lint, and merging to `main` deploys to your Harper cluster with the included [GitHub Actions workflow](./.github/workflows/deploy.yaml). No Harper password or token is stored in GitHub. +Your app deploys from GitHub: every pull request runs lint and `next build`, and merging to `main` deploys to your Harper cluster with the included [GitHub Actions workflow](./.github/workflows/deploy.yaml). No Harper password or token is stored in GitHub. First, head to [https://fabric.harper.fast/](https://fabric.harper.fast/), log in, and create a cluster. Then log your local CLI in to it as a super user: @@ -95,7 +95,7 @@ The deploy user can deploy any app on the cluster, so who can deploy comes down ### What a deploy does -The workflow runs `next build` and uploads the project with its `.next` build, so no build runs on the cluster (building there currently fails; see the note in [`config.yaml`](./config.yaml)). It deploys with `restart=rolling`: each node restarts in turn, so the cluster keeps serving. On Harper 5.4 and later, each node first loads the release in a canary worker, and only serves it if it loads. A release that fails to load is rejected, the run fails and names the node, and the previous release keeps serving. +The workflow's test job runs `next build`, and the deploy job uploads the project with that `.next` build, so no build runs on the cluster (building there currently fails; see the note in [`config.yaml`](./config.yaml)). The build stays out of the deploy job, so your dependencies' install and build scripts never run where the OIDC token can be requested. It deploys with `restart=rolling`: each node restarts in turn, so the cluster keeps serving. On Harper 5.4 and later, each node first loads the release in a canary worker, and only serves it if it loads. A release that fails to load is rejected, the run fails and names the node, and the previous release keeps serving. The run's summary names the deployment and its `certification`: diff --git a/template-nextjs/_github/workflows/deploy.yaml b/template-nextjs/_github/workflows/deploy.yaml index 16da4b4..0ca278c 100644 --- a/template-nextjs/_github/workflows/deploy.yaml +++ b/template-nextjs/_github/workflows/deploy.yaml @@ -1,9 +1,9 @@ # Tests every pull request, and deploys to your Harper cluster when a change merges to `main`. The # deploy job holds no Harper credential: its `harper` commands authenticate with GitHub's OIDC token # under a trust policy on the cluster. Set that up once with -# `your-package-manager-run-here deploy:setup-ci` (see "Deployment" in the README). The deploy job -# runs `next build` and uploads it, because building on the cluster currently fails -# (HarperFast/nextjs#57, HarperFast/nextjs#58). +# `your-package-manager-run-here deploy:setup-ci` (see "Deployment" in the README). The test job +# runs `next build` and hands it to the deploy job, which uploads it, because building on the cluster +# currently fails (HarperFast/nextjs#57, HarperFast/nextjs#58). name: Deploy to Harper on: pull_request: @@ -38,6 +38,20 @@ jobs: run: your-package-manager-install-here - name: Run lint run: your-package-manager-run-here lint + - name: Build + run: your-package-manager-run-here build + - name: Hand the build to the deploy job + # Building here keeps your dependencies' scripts out of the job that can request an OIDC token. + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: next-build + path: | + .next/ + !.next/cache/ + include-hidden-files: true + # A re-run of the whole workflow builds again, under the same name. + overwrite: true deploy: needs: test @@ -63,16 +77,15 @@ jobs: with: # The deploy uploads this directory, `.git` included, so keep the job's token out of it. persist-credentials: false - # your-package-manager-setup-step-here + - name: Download the build + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: next-build + path: .next - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - # your-package-manager-node-cache-here node-version-file: '.nvmrc' - - name: Install dependencies - run: your-package-manager-install-here - - name: Build - run: your-package-manager-run-here build - name: Install Harper CLI # 5.3 is the first CLI that authenticates with the OIDC token. run: npm install --global harper@^5.3 diff --git a/template.tests/deployWorkflow.test.js b/template.tests/deployWorkflow.test.js index 3d064cc..441c44b 100644 --- a/template.tests/deployWorkflow.test.js +++ b/template.tests/deployWorkflow.test.js @@ -18,6 +18,30 @@ const workflowPath = path.join('_github', 'workflows', 'deploy.yaml'); const templateDirs = fs.readdirSync(root) .filter((name) => name.startsWith('template-') && fs.existsSync(path.join(root, name, workflowPath))); +/** + * A template's workflow with LF line endings: a Windows checkout has CRLF. + * + * @param {string} dir - The template directory, relative to the repository root. + * @returns {string} - The workflow's contents. + */ +function readWorkflow(dir) { + return fs.readFileSync(path.join(root, dir, workflowPath), 'utf-8').replaceAll('\r\n', '\n'); +} + +/** + * The text of one top-level job, from its key to the next job's. + * + * @param {string} workflow - The workflow's contents. + * @param {string} job - The job's key. + * @returns {string} - That job's lines. + */ +function jobBlock(workflow, job) { + const start = workflow.indexOf(`\n ${job}:\n`); + expect(start, `job ${job}`).toBeGreaterThan(-1); + const next = workflow.slice(start + 1).search(/\n {2}[\w-]+:\n/); + return next === -1 ? workflow.slice(start) : workflow.slice(start, start + 1 + next); +} + /** * The deploy workflows must follow the package manager that scaffolded the project. create-harper * installs with whichever one invoked it, so a project created by `pnpm create harper` commits @@ -53,30 +77,6 @@ describe('deploy workflows are package-manager agnostic', () => { * environment (`harper deploy setup=true provider=github-actions`, run as `deploy:setup-ci`). */ describe('deploy workflows deploy on merge with OIDC', () => { - /** - * A template's workflow with LF line endings: a Windows checkout has CRLF. - * - * @param {string} dir - The template directory. - * @returns {string} - The workflow's contents. - */ - function readWorkflow(dir) { - return fs.readFileSync(path.join(root, dir, workflowPath), 'utf-8').replaceAll('\r\n', '\n'); - } - - /** - * The text of one top-level job, from its key to the next job's. - * - * @param {string} workflow - The workflow's contents. - * @param {string} job - The job's key. - * @returns {string} - That job's lines. - */ - function jobBlock(workflow, job) { - const start = workflow.indexOf(`\n ${job}:\n`); - expect(start, `job ${job}`).toBeGreaterThan(-1); - const next = workflow.slice(start + 1).search(/\n {2}[\w-]+:\n/); - return next === -1 ? workflow.slice(start) : workflow.slice(start, start + 1 + next); - } - for (const dir of templateDirs) { test(`${dir} tests pull requests and deploys merges to main`, () => { const workflow = readWorkflow(dir); @@ -102,6 +102,17 @@ describe('deploy workflows deploy on merge with OIDC', () => { expect(jobBlock(workflow, 'deploy')).toContain('persist-credentials: false'); }); + // Every step of a job with `id-token: write` can request the token, so a dependency's install + // or build script running there could trade it for the deploy user's Harper token. + test(`${dir} installs and builds nothing in the job that can request an identity token`, () => { + const deploy = jobBlock(readWorkflow(dir), 'deploy'); + + expect(deploy).not.toContain('# your-package-manager-setup-step-here'); + expect(deploy).not.toContain('your-package-manager-install-here'); + expect(deploy).not.toContain('run: your-package-manager-run-here'); + expect(deploy.match(/npm (?:install|i|ci)\b[^\n]*/g)).toEqual(['npm install --global harper@^5.3']); + }); + test(`${dir} deploys an explicit project and waits for the rolling job`, () => { const deploy = jobBlock(readWorkflow(dir), 'deploy'); @@ -146,6 +157,58 @@ describe('deploy workflows deploy on merge with OIDC', () => { } }); +/** + * The Next.js templates deploy a `.next` built in CI, because building on the cluster currently fails + * (HarperFast/nextjs#57, #58). The test job builds it and the deploy job downloads it, so the build + * never runs where an identity token can be requested. + */ +describe('Next.js workflows build outside the deploy job', () => { + const nextjsDirs = templateDirs.filter((dir) => dir.startsWith('template-nextjs')); + + test('finds both Next.js templates', () => { + expect(nextjsDirs).toEqual(['template-nextjs', 'template-nextjs-ts']); + }); + + for (const dir of nextjsDirs) { + test(`${dir} builds in the test job and uploads the build from main only`, () => { + const testJob = jobBlock(readWorkflow(dir), 'test'); + const build = testJob.indexOf('run: your-package-manager-run-here build'); + const upload = testJob.slice(testJob.indexOf('- name: Hand the build to the deploy job')); + + expect(build).toBeGreaterThan(-1); + expect(build).toBeLessThan(testJob.length - upload.length); + expect(upload).toContain("if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'"); + expect(upload).toContain('uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2'); + expect(upload).toContain('name: next-build\n'); + expect(upload).toContain(' .next/\n !.next/cache/\n'); + // upload-artifact skips dot-directories such as `.next` unless told otherwise. + expect(upload).toContain('include-hidden-files: true'); + expect(upload).toContain('overwrite: true'); + }); + + test(`${dir} deploys the build the test job uploaded`, () => { + const deploy = jobBlock(readWorkflow(dir), 'deploy'); + const download = deploy.indexOf( + 'uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2', + ); + + // The checkout cleans the workspace, so the build has to land after it. + expect(download).toBeGreaterThan(deploy.indexOf('uses: actions/checkout@')); + expect(download).toBeLessThan(deploy.indexOf('- name: Deploy\n')); + expect(deploy.slice(download)).toMatch(/^[^\n]+\n {8}with:\n {10}name: next-build\n {10}path: \.next\n/); + }); + } + + // Apart from fetching the build, the Next.js deploy job is the shared one. + test('the Next.js deploy job matches the shared one but for the download', () => { + const shared = jobBlock(readWorkflow(path.join('templates-shared', 'all')), 'deploy'); + for (const dir of nextjsDirs) { + const deploy = jobBlock(readWorkflow(dir), 'deploy'); + expect(deploy.replace(/\n {6}- name: Download the build\n(?: {8}.*\n)+/, '\n')).toBe(shared); + } + }); +}); + /** * The cluster installs a deployed app's dependencies itself, and that install omits devDependencies, * so a component package `config.yaml` loads from devDependencies is missing there, and a 5.4 @@ -299,6 +362,8 @@ describe('generated deploy workflows', () => { expect(workflow).toContain("cache: 'pnpm'"); expect(workflow).toContain('run: pnpm install --frozen-lockfile'); expect(workflow).toContain('run: pnpm run build'); + expect(jobBlock(workflow, 'deploy')).not.toContain('pnpm/action-setup'); + expect(jobBlock(workflow, 'deploy')).not.toContain('run: pnpm'); expect(workflow).not.toContain('npm ci'); expect(workflow).not.toContain('your-package-manager'); diff --git a/templates-shared/applySharedTemplates.js b/templates-shared/applySharedTemplates.js index f25472f..3b60020 100644 --- a/templates-shared/applySharedTemplates.js +++ b/templates-shared/applySharedTemplates.js @@ -24,10 +24,10 @@ import { copyDir } from '../lib/fs/copyDir.js'; // modern `harper` CLI. It still gets every other shared file. const excludeGithubForEarlyHints = (src) => !src.split(path.sep).includes('_github'); - // The Next.js templates are absent from the list above on purpose. Their deploy job runs - // `next build` before uploading, because building on the cluster currently fails - // (HarperFast/nextjs#57, #58), so they keep their own copy of the workflow with that step. Keep - // the two in step; once those issues land they can join this fan-out. + // The Next.js templates are absent from the list above on purpose. Their test job runs + // `next build` and hands it to the deploy job as an artifact, because building on the cluster + // currently fails (HarperFast/nextjs#57, #58), so they keep their own copy of the workflow with + // those steps. Keep the two in step; once those issues land they can join this fan-out. for (const key in copiesToMake) { const fromShared = path.resolve(import.meta.dirname, key); From a4cff8afbabc9c197c83609e1f1a56d52a55becd Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Wed, 14 Oct 2026 14:03:21 -0400 Subject: [PATCH 2/3] test: drop a comment that repeats its test's name Co-Authored-By: Claude Opus 5.5 --- template.tests/deployWorkflow.test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/template.tests/deployWorkflow.test.js b/template.tests/deployWorkflow.test.js index 441c44b..55ae597 100644 --- a/template.tests/deployWorkflow.test.js +++ b/template.tests/deployWorkflow.test.js @@ -199,7 +199,6 @@ describe('Next.js workflows build outside the deploy job', () => { }); } - // Apart from fetching the build, the Next.js deploy job is the shared one. test('the Next.js deploy job matches the shared one but for the download', () => { const shared = jobBlock(readWorkflow(path.join('templates-shared', 'all')), 'deploy'); for (const dir of nextjsDirs) { From 98f4fdc9fdd92f1bc5d91ba35607cc3694e4f68d Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Wed, 14 Oct 2026 14:23:17 -0400 Subject: [PATCH 3/3] test: read the scaffolded Next.js workflow with LF line endings, since a Windows checkout has CRLF Co-Authored-By: Claude Opus 5.5 --- template.tests/deployWorkflow.test.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/template.tests/deployWorkflow.test.js b/template.tests/deployWorkflow.test.js index 55ae597..22eb74b 100644 --- a/template.tests/deployWorkflow.test.js +++ b/template.tests/deployWorkflow.test.js @@ -361,8 +361,9 @@ describe('generated deploy workflows', () => { expect(workflow).toContain("cache: 'pnpm'"); expect(workflow).toContain('run: pnpm install --frozen-lockfile'); expect(workflow).toContain('run: pnpm run build'); - expect(jobBlock(workflow, 'deploy')).not.toContain('pnpm/action-setup'); - expect(jobBlock(workflow, 'deploy')).not.toContain('run: pnpm'); + const deploy = jobBlock(workflow.replaceAll('\r\n', '\n'), 'deploy'); + expect(deploy).not.toContain('pnpm/action-setup'); + expect(deploy).not.toContain('run: pnpm'); expect(workflow).not.toContain('npm ci'); expect(workflow).not.toContain('your-package-manager');