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
197 changes: 197 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
# Publishes the dashboard container image for self-hosted installs.
#
# Runs on a v* tag, which release-please creates when a release PR merges, and
# can be dispatched by hand for the first tag or a rebuild. Tags in this
# repository are immutable: a tag that already exists fails the run instead of
# moving, so a published image always means one commit.
#
# Nothing here may run on a pull request. The job holds a workload identity
# that can write to the registry, and a fork's pull request must never reach
# it.
name: Publish

on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
ref:
description: Commit, branch or tag to build from
required: true
default: main
type: string
tag:
description: Image tag to publish, e.g. v1.2.3 (must not exist yet)
required: true
type: string
dry_run:
description: Build everything but push nothing
required: false
default: false
type: boolean

permissions:
contents: read
id-token: write

concurrency:
group: publish-dashboard
cancel-in-progress: false

env:
IMAGE: us-docker.pkg.dev/e2b-artifacts/dashboard/dashboard

jobs:
publish:
name: Build and push the image
runs-on: ubuntu-latest
timeout-minutes: 30

steps:
- name: Check the publishing credentials are configured
env:
WIF_PROVIDER: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }}
PUBLISH_SA: ${{ vars.GCP_SERVICE_ACCOUNT }}
run: |
set -euo pipefail
if [ -z "${WIF_PROVIDER}" ] || [ -z "${PUBLISH_SA}" ]; then
echo "FIX: set the GCP_WORKLOAD_IDENTITY_PROVIDER and GCP_SERVICE_ACCOUNT repository variables before publishing" >&2
exit 1
fi

- name: Checkout code
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
ref: ${{ inputs.ref || github.ref }}

- name: Resolve the tag and the commit
id: meta
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_TAG: ${{ inputs.tag }}
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
if [ "${EVENT_NAME}" = workflow_dispatch ]; then
tag="${INPUT_TAG}"
else
tag="${REF_NAME}"
fi
if ! printf '%s' "${tag}" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "FIX: the tag must look like v1.2.3 (got '${tag}')" >&2
exit 1
fi
{
echo "tag=${tag}"
echo "sha=$(git rev-parse HEAD)"
} >> "$GITHUB_OUTPUT"

# A push run cannot disagree with itself: the ref being built is the tag.
# A dispatched run takes the two as separate inputs, so an image could
# otherwise be published as v1.2.3 from a commit that v1.2.3 does not
# name. A dry run publishes nothing, so it may rehearse with any tag.
- name: Check the tag names the commit being built
if: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run != true }}
env:
TAG: ${{ steps.meta.outputs.tag }}
HEAD_SHA: ${{ steps.meta.outputs.sha }}
run: |
set -euo pipefail
# The checkout is shallow and brings down no tags, so the tag ref has
# to be fetched; a tag that does not exist fails here.
if ! git fetch --no-tags --depth=1 origin "refs/tags/${TAG}"; then
echo "FIX: this repository has no tag ${TAG}; create the release tag first, or dispatch with a tag that exists" >&2
exit 1
fi
tag_sha="$(git rev-parse "FETCH_HEAD^{commit}")"
if [ "${tag_sha}" != "${HEAD_SHA}" ]; then
echo "FIX: tag ${TAG} names commit ${tag_sha} but this run checked out ${HEAD_SHA}; dispatch with ref=${TAG} to publish that tag" >&2
exit 1
fi

- name: Authenticate to Google Cloud
uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0
with:
workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ vars.GCP_SERVICE_ACCOUNT }}

- name: Set up gcloud
uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1

- name: Configure Docker for Artifact Registry
run: gcloud auth configure-docker us-docker.pkg.dev --quiet

- name: Refuse to overwrite an existing tag
env:
TAG: ${{ steps.meta.outputs.tag }}
run: |
set -euo pipefail
Comment thread
svalleru marked this conversation as resolved.
# `value(tag)` prints the whole resource path, so the short name is
# what gets compared. The list is drained into a variable and fed to
# grep as a here-string: piping into `grep -q` lets grep exit on the
# first hit, the producer takes SIGPIPE, and a pipefail pipeline then
# reports "no match" for a tag that exists.
if ! listing="$(gcloud artifacts docker tags list "${IMAGE}" --format='value(tag.basename())' --quiet 2>&1)"; then
# Nothing has been pushed under this name yet, which is where the
# first release starts, so no tag is taken. Every other failure
# means the list could not be read, and an unreadable list must
# never pass as "free": that would publish over a tag this step
# exists to protect.
if grep -q 'NOT_FOUND' <<<"${listing}"; then
echo "no image under ${IMAGE} yet, so ${TAG} is free"
exit 0
fi
echo "${listing}" >&2
echo "FIX: the publish identity cannot list ${IMAGE}; check the GCP_WORKLOAD_IDENTITY_PROVIDER and GCP_SERVICE_ACCOUNT repository variables and the registry IAM" >&2
exit 1
fi
if grep -Fxq -- "${TAG}" <<<"${listing}"; then
echo "FIX: ${IMAGE}:${TAG} already exists and tags here are immutable; release a new version" >&2
exit 1
fi

- name: Set up Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0

- name: Build and push
env:
TAG: ${{ steps.meta.outputs.tag }}
SHA: ${{ steps.meta.outputs.sha }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
set -euo pipefail
output=--push
if [ "${DRY_RUN}" = "true" ]; then
output=--load
fi
# Artifact Registry rejects buildx's default provenance/SBOM
# attestation manifest lists with HTTP 400.
docker buildx build \
--platform linux/amd64 \
--provenance=false \
--sbom=false \
--build-arg "BUILD=${TAG}" \
--label "org.opencontainers.image.revision=${SHA}" \
--label "org.opencontainers.image.version=${TAG}" \
--label "org.opencontainers.image.source=https://github.com/${GITHUB_REPOSITORY}" \
--tag "${IMAGE}:${TAG}" \
"${output}" .

- name: Record what was published
if: ${{ inputs.dry_run != true }}
env:
TAG: ${{ steps.meta.outputs.tag }}
SHA: ${{ steps.meta.outputs.sha }}
run: |
set -euo pipefail
digest="$(docker buildx imagetools inspect "${IMAGE}:${TAG}" --format '{{json .Manifest.Digest}}' | tr -d '"')"
{
echo "## ${IMAGE}:${TAG}"
echo
echo "Built from commit \`${SHA}\`."
echo
echo '| Image | Digest |'
echo '|---|---|'
echo "| \`${IMAGE}:${TAG}\` | \`${digest}\` |"
} >> "$GITHUB_STEP_SUMMARY"
48 changes: 48 additions & 0 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Keeps a release PR open with the pending changelog and, when it merges, tags
# the release. The tag is what triggers Publish.
name: Release Please

on:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: write
pull-requests: write

# Two pushes to main in quick succession would otherwise race on the release
# branch. Queue them instead of cancelling: a cancelled run leaves the release
# PR describing an older set of commits.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false

jobs:
release-please:
name: Create / update the release PR
runs-on: ubuntu-latest

steps:
# A tag pushed with the default GITHUB_TOKEN does not trigger `on: push`
# workflows, and a PR opened with it does not trigger `pull_request`
# checks, so a release made that way would be neither reviewed nor
# published. The App installation token used by the spec sync opens the
# release PR and creates the tag instead. This step is deliberately
# ungated: a missing or misconfigured App has to fail here rather than
# fall back to a token that produces a silently unpublishable release.
# If a release ever does get tagged without it, dispatch Publish by hand
# for that tag.
- name: Generate GitHub App installation token
id: app-token
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2
with:
app-id: ${{ vars.AUTOFIXER_APP_ID }}
private-key: ${{ secrets.AUTOFIXER_APP_SECRET }}

- name: Run release-please
uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1
with:
token: ${{ steps.app-token.outputs.token }}
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
3 changes: 3 additions & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
".": "0.1.0"
}
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,27 @@ bun run start
| `bun run generate:infra` | Regenerate infra-api contract types from `spec/` |
| `bun run generate:dashboard-api` | Regenerate dashboard-api contract types from `spec/` |

## Releases

Versions are managed by [release-please](https://github.com/googleapis/release-please):
merging the open release PR updates `CHANGELOG.md` and `package.json` and tags
the release as `vX.Y.Z`.

That tag publishes a container image to
`us-docker.pkg.dev/e2b-artifacts/dashboard/dashboard:vX.Y.Z` (`linux/amd64`,
anonymous pulls):

```bash
docker run --rm -p 3001:3001 us-docker.pkg.dev/e2b-artifacts/dashboard/dashboard:vX.Y.Z
```

A published image has no deployment baked in; it reaches an E2B deployment only
once you give it the runtime URL variables.

Image tags are immutable — a publish never moves an existing tag. The Publish
workflow can also be run by hand (`ref`, `tag`, and a `dry_run` that builds
without pushing).

## License

Apache 2.0 — see [LICENSE](LICENSE).
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@e2b/dashboard",
"version": "1.0.0",
"version": "0.1.0",
"private": true,
"scripts": {
"<<<<<<< Next.js": "",
Expand Down
14 changes: 14 additions & 0 deletions release-please-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"bootstrap-sha": "72551f41b5178239f0eac6a92800557dd76e73d5",
"bump-minor-pre-major": true,
"packages": {
".": {
"release-type": "node",
"package-name": "@e2b/dashboard",
"include-v-in-tag": true,
"include-component-in-tag": false,
"changelog-path": "CHANGELOG.md"
}
}
}
89 changes: 89 additions & 0 deletions tests/unit/publish-workflow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'

/**
* The publish path cannot be exercised outside a release, so these assertions
* guard the flags it cannot go without: the registry it pushes to, the single
* architecture, and the attestation switches Artifact Registry rejects.
*/
const read = (relativePath: string) =>
readFileSync(join(process.cwd(), relativePath), 'utf8')

const workflow = read('.github/workflows/publish.yml')
const releasePlease = read('.github/workflows/release-please.yml')

describe('publish workflow', () => {
it('runs on version tags and by hand', () => {
expect(workflow).toMatch(/tags:\s*\n\s*- 'v\*'/)
expect(workflow).toContain('workflow_dispatch:')
expect(workflow).toContain('dry_run:')
// The one string that couples release-please's tag shape to what this
// workflow agrees to publish.
expect(workflow).toContain("'^v[0-9]+\\.[0-9]+\\.[0-9]+$'")
})

it('is unreachable from a pull request, which would hand a fork the publisher identity', () => {
expect(workflow).not.toContain('pull_request_target')
expect(workflow).not.toContain('pull_request:')
expect(workflow).not.toContain('workflow_run')
})

it('pushes the published image coordinates', () => {
expect(workflow).toContain(
'us-docker.pkg.dev/e2b-artifacts/dashboard/dashboard'
)
expect(workflow).toContain('--platform linux/amd64')
})

it('disables the attestations Artifact Registry rejects', () => {
expect(workflow).toContain('--provenance=false')
expect(workflow).toContain('--sbom=false')
})

it('authenticates with workload identity from repository variables', () => {
expect(workflow).toContain('vars.GCP_WORKLOAD_IDENTITY_PROVIDER')
expect(workflow).toContain('vars.GCP_SERVICE_ACCOUNT')
expect(workflow).not.toMatch(/credentials_json|service_account_key/)
})

it('fails closed when the registry cannot be read', () => {
expect(workflow).toContain("grep -q 'NOT_FOUND'")
expect(workflow).toContain('FIX: the publish identity cannot list')
})

it('checks a dispatched tag names the commit being built', () => {
expect(workflow).toContain('git fetch --no-tags --depth=1 origin')
expect(workflow).toContain('FETCH_HEAD^{commit}')
// A dry run publishes nothing and may rehearse with any tag. Every run
// that does publish has to pass the check, so the skip stops there.
expect(workflow).toContain(
"if: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run != true }}"
)
})
})

/**
* These two hold credentials the rest of CI does not — the publish identity
* and an App token — so a moved tag on a third-party action would run
* someone else's code against them.
*/
describe('release automation workflows', () => {
const files = {
'publish.yml': workflow,
'release-please.yml': releasePlease,
}

for (const [name, text] of Object.entries(files)) {
it(`pins every action in ${name} to a commit sha`, () => {
const uses = Array.from(
text.matchAll(/^\s*(?:- )?uses:\s*(\S+.*)$/gm)
).map((match) => match[1].trim())

expect(uses.length).toBeGreaterThan(0)
for (const line of uses) {
expect(line).toMatch(/^[\w.-]+\/[\w.-]+@[0-9a-f]{40} # v\d/)
}
})
}
})
Loading
Loading