From 6d37968276c2e08220ee39b741afb2026ecec2b2 Mon Sep 17 00:00:00 2001 From: Shan Valleru Date: Fri, 11 Sep 2026 16:08:03 -0700 Subject: [PATCH 1/3] ci(release): manage versions with release-please --- .github/workflows/release-please.yml | 48 ++++++++++++++++++++++++++++ .release-please-manifest.json | 3 ++ package.json | 2 +- release-please-config.json | 14 ++++++++ tests/unit/release-config.test.ts | 31 ++++++++++++++++++ 5 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release-please.yml create mode 100644 .release-please-manifest.json create mode 100644 release-please-config.json create mode 100644 tests/unit/release-config.test.ts diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 000000000..d8b51fdb4 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -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@v2 + with: + app-id: ${{ vars.AUTOFIXER_APP_ID }} + private-key: ${{ secrets.AUTOFIXER_APP_SECRET }} + + - name: Run release-please + uses: googleapis/release-please-action@v4 + with: + token: ${{ steps.app-token.outputs.token }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 000000000..466df71c8 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.1.0" +} diff --git a/package.json b/package.json index 9788e6888..b9bb71554 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@e2b/dashboard", - "version": "1.0.0", + "version": "0.1.0", "private": true, "scripts": { "<<<<<<< Next.js": "", diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 000000000..234b3ed9b --- /dev/null +++ b/release-please-config.json @@ -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" + } + } +} diff --git a/tests/unit/release-config.test.ts b/tests/unit/release-config.test.ts new file mode 100644 index 000000000..3bf7000a1 --- /dev/null +++ b/tests/unit/release-config.test.ts @@ -0,0 +1,31 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * release-please is only exercised on main, where a mistake means a release + * that never tags or a tag the publish workflow ignores. These assertions keep + * the three files that have to agree from drifting apart. + */ +const read = (relativePath: string) => + JSON.parse(readFileSync(join(process.cwd(), relativePath), 'utf8')) + +describe('release-please configuration', () => { + it('releases the repository root as a node package, tags are v with no component prefix', () => { + const config = read('release-please-config.json') + const root = config.packages['.'] + + expect(root['release-type']).toBe('node') + expect(root['include-v-in-tag']).toBe(true) + expect(root['include-component-in-tag']).toBe(false) + expect(config['bump-minor-pre-major']).toBe(true) + }) + + it('starts from 0.1.0 and agrees with package.json', () => { + const manifest = read('.release-please-manifest.json') + const pkg = read('package.json') + + expect(manifest['.']).toBe('0.1.0') + expect(pkg.version).toBe(manifest['.']) + }) +}) From 52faf51c8e347d41ca2e9609782c1c6b2e029996 Mon Sep 17 00:00:00 2001 From: Shan Valleru Date: Fri, 11 Sep 2026 16:36:25 -0700 Subject: [PATCH 2/3] ci(release): publish the container image on a version tag --- .github/workflows/publish.yml | 170 ++++++++++++++++++++++++++++ README.md | 21 ++++ tests/unit/publish-workflow.test.ts | 48 ++++++++ 3 files changed, 239 insertions(+) create mode 100644 .github/workflows/publish.yml create mode 100644 tests/unit/publish-workflow.test.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 000000000..e79a8f7ff --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,170 @@ +# 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@v4 + 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" + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@v3 + 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@v3 + + - 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 + # `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. + # + # Before the first publish the repository holds no image and the list + # fails with NOT_FOUND, so a failed list is treated as "free" rather + # than blocking the first release. Tags in this repository are + # immutable, so the registry, not this step, is what ultimately + # refuses to move one; this is the early, readable error. + if ! existing="$(gcloud artifacts docker tags list "${IMAGE}" --format='value(tag.basename())' --quiet 2>&1)"; then + echo "could not list the tags of ${IMAGE}, treating ${TAG} as free: ${existing}" + existing="" + fi + if grep -Fxq -- "${TAG}" <<<"${existing}"; 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@v3 + + - 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" diff --git a/README.md b/README.md index b855a51e7..a344e1bee 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/tests/unit/publish-workflow.test.ts b/tests/unit/publish-workflow.test.ts new file mode 100644 index 000000000..f58569fc0 --- /dev/null +++ b/tests/unit/publish-workflow.test.ts @@ -0,0 +1,48 @@ +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 workflow = readFileSync( + join(process.cwd(), '.github/workflows/publish.yml'), + 'utf8' +) + +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/) + }) +}) From 09288ae3a2be86644489184df61aa53ee2b37cf1 Mon Sep 17 00:00:00 2001 From: Shan Valleru Date: Mon, 14 Sep 2026 09:35:16 -0700 Subject: [PATCH 3/3] ci(release): fail closed in the publish workflow An unreadable tag listing was treated as "the tag is free", so a registry the publish identity cannot read would have published straight over an existing tag. NOT_FOUND still means free, since that is where the first release starts, but every other gcloud failure now fails the step with the error it returned and a line saying what to check. A dispatched run takes the ref and the tag as separate inputs, so it could publish an image as v1.2.3 built from a commit that v1.2.3 does not name. It now fetches the tag and refuses unless the tag names the commit that was checked out. A push run cannot disagree with itself, because the ref it builds is the tag, so it skips the check, and so does a dry run, which publishes nothing and may rehearse with any tag. Third-party actions in these two workflows are pinned to commit SHAs. They differ from the rest of CI because they are the two that hold credentials: this one holds id-token: write for the workload identity that writes to the registry, and release-please.yml holds an App installation token that pushes tags and opens PRs. A moved major tag on an action in either would run someone else's code against those. The other workflows keep their major tags. Pinning trades a moved-tag risk for a staleness one, so this repository should enable Dependabot for the github-actions ecosystem to keep the pins bumped. --- .github/workflows/publish.yml | 55 +++++++++++++++++++++------- .github/workflows/release-please.yml | 4 +- tests/unit/publish-workflow.test.ts | 49 +++++++++++++++++++++++-- 3 files changed, 88 insertions(+), 20 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e79a8f7ff..08031db93 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -61,7 +61,7 @@ jobs: fi - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: ref: ${{ inputs.ref || github.ref }} @@ -87,14 +87,37 @@ jobs: 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@v3 + 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@v3 + 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 @@ -109,23 +132,27 @@ jobs: # 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. - # - # Before the first publish the repository holds no image and the list - # fails with NOT_FOUND, so a failed list is treated as "free" rather - # than blocking the first release. Tags in this repository are - # immutable, so the registry, not this step, is what ultimately - # refuses to move one; this is the early, readable error. - if ! existing="$(gcloud artifacts docker tags list "${IMAGE}" --format='value(tag.basename())' --quiet 2>&1)"; then - echo "could not list the tags of ${IMAGE}, treating ${TAG} as free: ${existing}" - existing="" + 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}" <<<"${existing}"; then + 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@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Build and push env: diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index d8b51fdb4..c80350f8b 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -35,13 +35,13 @@ jobs: # for that tag. - name: Generate GitHub App installation token id: app-token - uses: actions/create-github-app-token@v2 + 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@v4 + uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1 with: token: ${{ steps.app-token.outputs.token }} config-file: release-please-config.json diff --git a/tests/unit/publish-workflow.test.ts b/tests/unit/publish-workflow.test.ts index f58569fc0..55439425c 100644 --- a/tests/unit/publish-workflow.test.ts +++ b/tests/unit/publish-workflow.test.ts @@ -7,10 +7,11 @@ import { describe, expect, it } from 'vitest' * guard the flags it cannot go without: the registry it pushes to, the single * architecture, and the attestation switches Artifact Registry rejects. */ -const workflow = readFileSync( - join(process.cwd(), '.github/workflows/publish.yml'), - 'utf8' -) +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', () => { @@ -45,4 +46,44 @@ describe('publish workflow', () => { 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/) + } + }) + } })