Skip to content
Open
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
4 changes: 2 additions & 2 deletions template-nextjs-ts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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`:

Expand Down
31 changes: 22 additions & 9 deletions template-nextjs-ts/_github/workflows/deploy.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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/
Comment thread
dawsontoth marked this conversation as resolved.
include-hidden-files: true
# A re-run of the whole workflow builds again, under the same name.
overwrite: true

deploy:
needs: test
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions template-nextjs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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`:

Expand Down
31 changes: 22 additions & 9 deletions template-nextjs/_github/workflows/deploy.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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/
Comment thread
dawsontoth marked this conversation as resolved.
include-hidden-files: true
# A re-run of the whole workflow builds again, under the same name.
overwrite: true

deploy:
needs: test
Expand All @@ -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
Expand Down
113 changes: 89 additions & 24 deletions template.tests/deployWorkflow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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');

Expand Down Expand Up @@ -146,6 +157,57 @@ 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');
Comment thread
dawsontoth marked this conversation as resolved.
// 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/);
});
}

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
Expand Down Expand Up @@ -299,6 +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');
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');
Expand Down
8 changes: 4 additions & 4 deletions templates-shared/applySharedTemplates.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading