From 9f2128d441fc70918700ad542d91f75d129f7019 Mon Sep 17 00:00:00 2001 From: Gage Krumbach Date: Fri, 6 Nov 2026 12:51:11 -0600 Subject: [PATCH] ci: publish packages through environments, PR images to console-dev, pin actions The organization's infra team (Simon) is setting up two environments, so that no job writes a package with the workflow's own token: - `release` (main and release/** only) holds GHCR_TOKEN, which can write to ghcr.io/ai-openshell/console and its chart. ci.yml's build (on a push), push-manifest, promote-latest and publish-dev-chart, and publish.yml's tag-image and publish-chart, log in with it. No job asks for packages: write any more. - `pr-images` (approved by a maintainer) holds a GHCR_TOKEN that can write to ghcr.io/ai-openshell/console-dev only. A new pr-image job builds console-dev:pr- and :sha-<7> (linux/amd64) for a pull request from a branch of this repository, after approval. It runs on pull_request, never pull_request_target, and only reads the build cache. - A pull request no longer pushes anything to console, and writes no build cache: cache-to is for pushes only. - Every action in ci.yml is pinned by commit SHA, as publish.yml and follow-upstream.yml are. Tests hold all of it in place. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Gage Krumbach --- .github/workflows/ci.yml | 186 ++++++++++++++------- .github/workflows/publish.yml | 16 +- deploy/ci/upstream/tests/test_workflows.py | 76 ++++++++- docs/releasing.md | 19 ++- scripts/release/release.test.mjs | 7 +- 5 files changed, 220 insertions(+), 84 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 608319da..d5dab751 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,11 +23,11 @@ jobs: helm: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: azure/setup-helm@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1 with: version: v3.17.3 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version: "1.25.1" - name: Render and lint all chart overlays @@ -38,7 +38,7 @@ jobs: run: | helm plugin install https://github.com/helm-unittest/helm-unittest --version v1.0.3 make helm-test - - uses: jdx/mise-action@v4 + - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - name: Generated chart README is current run: | make helm-docs @@ -50,15 +50,15 @@ jobs: check-frontend: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dorny/paths-filter@v3 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: dorny/paths-filter@0e4a8c6effa4802afeda77dc8d303f8176d7dfad # v3.0.4 id: filter with: filters: | frontend: - 'frontend/**' - 'package*.json' - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 if: steps.filter.outputs.frontend == 'true' || github.event_name == 'push' with: node-version: "22" @@ -78,20 +78,20 @@ jobs: check-backend: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dorny/paths-filter@v3 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: dorny/paths-filter@0e4a8c6effa4802afeda77dc8d303f8176d7dfad # v3.0.4 id: filter with: filters: | backend: - 'backend/**' - - uses: actions/setup-go@v5 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 if: steps.filter.outputs.backend == 'true' || github.event_name == 'push' with: go-version-file: backend/go.mod cache-dependency-path: backend/go.sum - if: steps.filter.outputs.backend == 'true' || github.event_name == 'push' - uses: golangci/golangci-lint-action@v9 + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: working-directory: backend version: v2.12.2 @@ -101,8 +101,8 @@ jobs: e2e: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "22" cache: "npm" @@ -124,14 +124,14 @@ jobs: echo "The dev server did not answer on :3000 within 60 s." cat "$RUNNER_TEMP/vite.log" exit 1 - - uses: cypress-io/github-action@v6 + - uses: cypress-io/github-action@f790eee7a50d9505912f50c2095510be7de06aa7 # v6.10.9 with: working-directory: frontend install: false browser: chrome - if: failure() run: cat "$RUNNER_TEMP/vite.log" || true - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: failure() with: name: cypress-screenshots @@ -169,7 +169,7 @@ jobs: config_schema: ${{ steps.read.outputs.config_schema }} sandbox_image: ${{ steps.read.outputs.sandbox_image }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 # The validator below and the Follow upstream workflow share this code. # A few seconds of unit tests here keep both honest on every PR. - name: Unit-test the pins and upstream logic @@ -199,7 +199,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: The pins name a stable OpenShell release, not a pre-release run: python3 deploy/ci/upstream/follow.py require-stable deploy/ci/gateway-pins.json @@ -259,7 +259,7 @@ jobs: # changed. COMPAT_EXPECT_COMPATIBILITY: supported steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Pull images run: | @@ -282,7 +282,7 @@ jobs: export OPENSHELL_GRPC_ENDPOINT="http://${BRIDGE}:8080" deploy/ci/e2e-stack.sh up - - uses: actions/setup-go@v5 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version-file: backend/go.mod cache-dependency-path: backend/go.sum @@ -316,13 +316,20 @@ jobs: if: always() run: deploy/ci/e2e-stack.sh down + # Builds the image on every run. Only a push (to main or a release branch) + # publishes it, and only through the `release` environment, whose token is + # the one thing that can write to ghcr.io/ai-openshell/console. The + # environment admits main and release/** only, so a pull request, which + # runs with no environment here, cannot reach the token even if it edits + # this file. Pull request images are the `pr-image` job's, and go to + # console-dev. build: runs-on: ${{ matrix.runner }} + environment: ${{ github.event_name == 'push' && 'release' || '' }} permissions: contents: read - packages: write env: - PUBLISH_IMAGE: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }} + PUBLISH_IMAGE: ${{ github.event_name == 'push' }} strategy: matrix: include: @@ -331,8 +338,8 @@ jobs: - platform: linux/arm64 runner: ubuntu-24.04-arm steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "22" # The image states which gateway release line it is for, and the SDK it @@ -348,18 +355,18 @@ jobs: # pipefail, or a failing derivation would hide behind tee's exit 0. set -euo pipefail node scripts/gateway-range.mjs --format github | tee -a "$GITHUB_OUTPUT" - - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 if: env.PUBLISH_IMAGE == 'true' with: registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/metadata-action@v5 + username: ${{ vars.GHCR_USERNAME || github.actor }} + password: ${{ secrets.GHCR_TOKEN }} + - uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 id: meta with: images: ghcr.io/ai-openshell/console - - uses: docker/build-push-action@v6 + - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 id: build with: context: . @@ -375,7 +382,9 @@ jobs: OPENSHELL_SDK_VERSION=${{ steps.gateway.outputs.sdk }} DASHBOARD_COMMIT=${{ github.sha }} cache-from: type=gha,scope=${{ matrix.platform }} - cache-to: type=gha,scope=${{ matrix.platform }},mode=max + # Only a push writes the cache that a release is built from. A pull + # request reads it and writes nothing back. + cache-to: ${{ github.event_name == 'push' && format('type=gha,scope={0},mode=max', matrix.platform) || '' }} outputs: ${{ env.PUBLISH_IMAGE == 'true' && 'type=image,name=ghcr.io/ai-openshell/console,push-by-digest=true,name-canonical=true,push=true' || '' }} - name: Export digest if: env.PUBLISH_IMAGE == 'true' @@ -383,7 +392,7 @@ jobs: mkdir -p /tmp/digests digest="${{ steps.build.outputs.digest }}" touch "/tmp/digests/${digest#sha256:}" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: env.PUBLISH_IMAGE == 'true' with: name: digest-${{ matrix.runner }} @@ -392,35 +401,33 @@ jobs: retention-days: 1 # Joins the two per-platform images into one multi-arch image and tags it with - # the commit (and the PR number, on a pull request). It needs only `build`, - # deliberately: these tags say "this is what that commit built", which is true - # whether or not the commit is any good, and they are how a pull request gets - # an image to try before its checks finish. + # the commit. It needs only `build`, deliberately: the tag says "this is what + # that commit built", which is true whether or not the commit is any good. # # It does NOT tag `latest`. That tag is a recommendation. Moved here, on # every push to main with only `build` to wait for, it could point at a # commit whose compat job had failed. `promote-latest` below moves it, once # everything has passed. push-manifest: - if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + if: github.event_name == 'push' runs-on: ubuntu-latest + environment: release permissions: contents: read - packages: write needs: [build] steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: path: /tmp/digests pattern: digest-* merge-multiple: true - - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/metadata-action@v5 + username: ${{ vars.GHCR_USERNAME || github.actor }} + password: ${{ secrets.GHCR_TOKEN }} + - uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 id: meta with: images: ghcr.io/ai-openshell/console @@ -429,7 +436,6 @@ jobs: # adds when a release is cut. Keep the format in step with both. tags: | type=sha,prefix=sha-,format=short - type=ref,event=pr,prefix=pr- - name: Create and push multi-arch manifest working-directory: /tmp/digests run: | @@ -447,21 +453,21 @@ jobs: # is published with empty labels. This inspects the image config; it does not # pull or run the image. image-range: - if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + if: github.event_name == 'push' runs-on: ubuntu-latest needs: [push-manifest] permissions: contents: read packages: read steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "22" - - uses: docker/setup-buildx-action@v3 - # A new GHCR package is private until an org admin makes it public, and - # reading a private image needs a login. - - uses: docker/login-action@v3 + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + # Reading only. The workflow's own token keeps read access to the + # package; a private package needs it. + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -495,9 +501,9 @@ jobs: promote-latest: if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest + environment: release permissions: contents: read - packages: write needs: [ push-manifest, @@ -511,21 +517,21 @@ jobs: release-tooling, ] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Is this commit still the tip of main? id: tip run: | # pipefail, or a failing check would hide behind tee's exit 0. set -euo pipefail scripts/branch-tip.sh origin main "$GITHUB_SHA" | tee -a "$GITHUB_OUTPUT" - - uses: docker/setup-buildx-action@v3 + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 if: steps.tip.outputs.is_tip == 'true' - - uses: docker/login-action@v3 + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 if: steps.tip.outputs.is_tip == 'true' with: registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + username: ${{ vars.GHCR_USERNAME || github.actor }} + password: ${{ secrets.GHCR_TOKEN }} - name: Point latest at this commit's image if: steps.tip.outputs.is_tip == 'true' run: scripts/retag-image.sh ghcr.io/ai-openshell/console "sha-${GITHUB_SHA::7}" latest @@ -536,12 +542,12 @@ jobs: if: github.event_name == 'push' && github.ref == 'refs/heads/main' needs: [promote-latest] runs-on: ubuntu-latest + environment: release permissions: contents: read - packages: write steps: - - uses: actions/checkout@v4 - - uses: azure/setup-helm@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1 with: version: v3.17.3 - name: Is this commit still the tip of main? @@ -552,15 +558,67 @@ jobs: - name: Publish floating development chart if: steps.tip.outputs.is_tip == 'true' env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }} + GHCR_USERNAME: ${{ vars.GHCR_USERNAME || github.actor }} run: | set -euo pipefail - helm registry login ghcr.io -u "$GITHUB_ACTOR" --password-stdin <<< "$GH_TOKEN" + helm registry login ghcr.io -u "$GHCR_USERNAME" --password-stdin <<< "$GHCR_TOKEN" helm package deploy/helm/openshell-dashboard --version 0.0.0-dev \ --app-version "sha-${GITHUB_SHA::7}" --destination "$RUNNER_TEMP" helm push "$RUNNER_TEMP/openshell-dashboard-0.0.0-dev.tgz" \ "oci://ghcr.io/${GITHUB_REPOSITORY,,}/helm-chart" + # An image of a pull request, to try on a cluster: ghcr.io/ai-openshell/ + # console-dev:pr- (and :sha-<7>), linux/amd64. It waits until a + # maintainer approves the run in the `pr-images` environment, whose token can + # write to console-dev and nothing else; the public image is out of its + # reach. A plain pull_request run on purpose, never pull_request_target: that + # would run with main's cache scope, so a pull request could plant layers + # that main's build, and so a release, reuses. Here the cache is only read. + # A fork's pull request gets no image: GitHub hands it no environment secret. + pr-image: + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04 + environment: pr-images + permissions: + contents: read + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "22" + - name: Derive the gateway release line + id: gateway + run: | + set -euo pipefail + node scripts/gateway-range.mjs --format github | tee -a "$GITHUB_OUTPUT" + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + registry: ghcr.io + username: ${{ vars.GHCR_USERNAME || github.actor }} + password: ${{ secrets.GHCR_TOKEN }} + - uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 + id: meta + with: + images: ghcr.io/ai-openshell/console-dev + tags: | + type=ref,event=pr,prefix=pr- + type=sha,prefix=sha-,format=short + - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + with: + context: . + file: deploy/Dockerfile + platforms: linux/amd64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + GATEWAY_RELEASE_LINE=${{ steps.gateway.outputs.line }} + OPENSHELL_SDK_VERSION=${{ steps.gateway.outputs.sdk }} + DASHBOARD_COMMIT=${{ github.sha }} + cache-from: type=gha,scope=linux/amd64 + # The release pipeline (publish.yml, scripts/release/) runs for real only on # main and on release branches, after a merge. This job is where a mistake in # it is caught beforehand, and it is also where the supported gateway release @@ -580,8 +638,8 @@ jobs: release-tooling: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "22" - name: The built-in gateway release line and the SDK pin match the pins diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5e6ee8d2..a64ecc55 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -165,13 +165,16 @@ jobs: # A separate job so that the package permission is never in the job that # holds the repository permissions, and so that a registry hiccup can # be retried on its own ("Re-run failed jobs") without touching the release. + # Both jobs below write packages, so both run in the `release` environment, + # which admits main and release/** only and holds the one token that can + # write to the console image and its chart. tag-image: needs: publish if: needs.publish.outputs.version != '' runs-on: ubuntu-latest + environment: release permissions: contents: read - packages: write steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: @@ -181,8 +184,8 @@ jobs: - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + username: ${{ vars.GHCR_USERNAME || github.actor }} + password: ${{ secrets.GHCR_TOKEN }} - name: Tag the image built for this commit with the release version env: SOURCE_TAG: ${{ needs.publish.outputs.source_tag }} @@ -198,9 +201,9 @@ jobs: needs: [publish, tag-image] if: needs.publish.outputs.version != '' runs-on: ubuntu-latest + environment: release permissions: contents: read - packages: write steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: @@ -211,11 +214,12 @@ jobs: version: v3.17.3 - name: Publish immutable versioned chart env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }} + GHCR_USERNAME: ${{ vars.GHCR_USERNAME || github.actor }} VERSION: ${{ needs.publish.outputs.version }} run: | set -euo pipefail - echo "$GH_TOKEN" | helm registry login ghcr.io -u "$GITHUB_ACTOR" --password-stdin + echo "$GHCR_TOKEN" | helm registry login ghcr.io -u "$GHCR_USERNAME" --password-stdin registry="oci://ghcr.io/${GITHUB_REPOSITORY,,}/helm-chart" ref="$registry/openshell-dashboard" if helm show chart "$ref" --version "$VERSION" > "$RUNNER_TEMP/chart-lookup.log" 2>&1; then diff --git a/deploy/ci/upstream/tests/test_workflows.py b/deploy/ci/upstream/tests/test_workflows.py index b196eceb..683c5cb3 100644 --- a/deploy/ci/upstream/tests/test_workflows.py +++ b/deploy/ci/upstream/tests/test_workflows.py @@ -639,14 +639,11 @@ def test_latest_is_promoted_only_from_main_and_only_past_both_checks(self): for job in ("stable-release", "compat", "release-tooling", "check-backend"): self.assertIn(" %s," % job, needs) - def test_a_pull_request_from_this_repository_gets_its_images(self): - # The build and its manifest are not limited by base branch; only by - # the head being in this repository, which is what has the registry login. - for name in ("build", "push-manifest"): - text = "\n".join(CI_ALL_JOBS[name]) - self.assertIn("github.event.pull_request.head.repo.full_name == github.repository", text) - self.assertNotIn("base_ref", text) - self.assertIn("type=ref,event=pr,prefix=pr-", "\n".join(CI_ALL_JOBS["push-manifest"])) + def test_nothing_publishes_from_a_pull_request_but_the_pr_image_job(self): + self.assertIn(" PUBLISH_IMAGE: ${{ github.event_name == 'push' }}", CI_ALL_JOBS["build"]) + for name in ("push-manifest", "image-range"): + self.assertIn(" if: github.event_name == 'push'", CI_ALL_JOBS[name], name) + self.assertNotIn("event=pr", "\n".join(CI_ALL_JOBS["push-manifest"])) class TheSweepIsRetired(unittest.TestCase): @@ -704,5 +701,68 @@ def test_with_nothing_set_it_runs_the_pinned_gateway(self): self.assertLess(asked, STACK.index('default_gateway="$(pinned gateway_image)"')) + +def job_environment(lines): + for line in lines: + m = re.match(r"^ environment:\s*(.+)$", line) + if m: + return m.group(1).strip() + return None + + +class Publishing(unittest.TestCase): + """Packages are written only with an environment's token, never with the workflow's own.""" + + ALL = [("ci.yml", name, lines) for name, lines in CI_ALL_JOBS.items()] + [ + ("publish.yml", name, lines) for name, lines in jobs(PUBLISH).items() + ] + + def test_no_job_lets_the_workflow_token_write_packages(self): + for workflow, name, lines in self.ALL: + self.assertNotEqual((permissions(lines) or {}).get("packages"), "write", "%s %s" % (workflow, name)) + + def test_every_job_that_holds_the_registry_token_is_in_an_environment(self): + holders = {} + for workflow, name, lines in self.ALL: + if "secrets.GHCR_TOKEN" in "\n".join(lines): + holders["%s %s" % (workflow, name)] = job_environment(lines) + self.assertEqual( + holders, + { + "ci.yml build": "${{ github.event_name == 'push' && 'release' || '' }}", + "ci.yml push-manifest": "release", + "ci.yml promote-latest": "release", + "ci.yml publish-dev-chart": "release", + "ci.yml pr-image": "pr-images", + "publish.yml tag-image": "release", + "publish.yml publish-chart": "release", + }, + ) + + def test_a_pull_request_writes_no_cache_a_release_reads(self): + build = "\n".join(CI_ALL_JOBS["build"]) + self.assertIn("cache-to: ${{ github.event_name == 'push' && format(", build) + self.assertNotIn("cache-to", "\n".join(CI_ALL_JOBS["pr-image"])) + + def test_pr_images_go_to_console_dev_after_approval_and_never_from_a_fork(self): + job = CI_ALL_JOBS["pr-image"] + text = "\n".join(job) + self.assertIn( + " if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository", + job, + ) + self.assertIn(" environment: pr-images", job) + self.assertIn("images: ghcr.io/ai-openshell/console-dev", text) + self.assertNotIn("ghcr.io/ai-openshell/console\n", text + "\n") + code = "\n".join(line for line in CI.splitlines() if not line.lstrip().startswith("#")) + self.assertNotIn("pull_request_target", code) + + def test_every_action_in_ci_and_publish_is_pinned_by_sha(self): + for workflow, name, lines in self.ALL: + for step in steps(lines): + if step["uses"]: + self.assertRegex(step["uses"], PINNED, "%s %s" % (workflow, name)) + + if __name__ == "__main__": unittest.main() diff --git a/docs/releasing.md b/docs/releasing.md index 27052711..477777c0 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -12,7 +12,22 @@ A release `vX.Y.Z` is three things, all made by `publish.yml`: | Container image tags `X.Y.Z` and `X.Y` | `ghcr.io/ai-openshell/console` | retagging the image CI already built | | Helm chart `openshell-dashboard`, version `X.Y.Z` | `oci://ghcr.io/ai-openshell/console/helm-chart` | packaging the chart at the released commit | -The chart's registry path follows the repository's name (`publish.yml` pushes to `oci://ghcr.io///helm-chart`), so a package created under a new name starts out private: make it public in the organization's package settings before announcing the release. Releases up to `0.1.2` are at `oci://ghcr.io/gkrumbach07/openshell-dashboard/helm-chart`, the path before the repository moved to `ai-openshell/console`; nothing is published there any more. The image moved the same way: releases up to `0.1.3` are at `quay.io/gkrumbach07/openshell-dashboard`, and CI pushes every newer image to `ghcr.io/ai-openshell/console` with the workflow's own token, so no registry secret is needed. That package also starts out private. +The chart's registry path follows the repository's name (`publish.yml` pushes to `oci://ghcr.io///helm-chart`), so a package created under a new name starts out private: make it public in the organization's package settings before announcing the release. Releases up to `0.1.2` are at `oci://ghcr.io/gkrumbach07/openshell-dashboard/helm-chart`, the path before the repository moved to `ai-openshell/console`; nothing is published there any more. The image moved the same way: releases up to `0.1.3` are at `quay.io/gkrumbach07/openshell-dashboard`, and CI pushes every newer image to `ghcr.io/ai-openshell/console`. That package also starts out private. + +## Who can write the packages + +The workflow's own token cannot write any package: no job asks for `packages: write`. Two environments, kept by the organization's infra team, each hold a token named `GHCR_TOKEN` that can write to one thing: + +| Environment | Who may use it | Its token writes to | Used by | +|---|---|---|---| +| `release` | `main` and `release/**` only | `ghcr.io/ai-openshell/console` and its Helm chart | `ci.yml` on a push (`build`, `push-manifest`, `promote-latest`, `publish-dev-chart`) and `publish.yml` (`tag-image`, `publish-chart`) | +| `pr-images` | a run a maintainer approved | `ghcr.io/ai-openshell/console-dev` only | `ci.yml`'s `pr-image` job | + +A pull request never publishes to `console`, even one that edits a workflow: it runs with no environment. It does not write the build cache that `main`'s build, and so a release, reads. + +**An image of a pull request.** The `pr-image` job waits on every pull request from a branch of this repository. When a maintainer approves it (*Review deployments* on the pull request's checks), it builds `ghcr.io/ai-openshell/console-dev:pr-` and `:sha-<7>` for linux/amd64. Each new push waits for approval again. A fork's pull request gets no image. The job runs on `pull_request`, never `pull_request_target`, so it cannot share `main`'s cache. + +Every action in `ci.yml`, `publish.yml` and `follow-upstream.yml` is pinned by commit SHA, because those jobs hold these tokens. The BFF and the UI ship together and share the version. @@ -112,7 +127,7 @@ publish.yml started by a person, on main or release/X.Y ``` - **The image is built once.** `scripts/retag-image.sh` adds `latest`, `X.Y.Z` and `X.Y` later, by pointing the tag at the digest of `sha-<7>`, so a released image is byte for byte the one CI built for the commit that passed. -- **`latest` waits for everything.** `sha-<7>` and `pr-` are pushed when the build finishes. `latest` moves after every job in `ci.yml` has passed on `main`. +- **`latest` waits for everything.** `sha-<7>` is pushed when the build of a push finishes. `latest` moves after every job in `ci.yml` has passed on `main`. - **`latest` only moves forward, and no release moves it.** A run can be re-run for thirty days and keeps its commit, so `promote-latest` first checks that the commit is still the tip of `main` (`scripts/branch-tip.sh`), and otherwise succeeds without touching the registry. - **A version is written once.** `retag-image.sh` refuses to point an existing `X.Y.Z` at another digest, and `publish-chart` leaves an existing chart version untouched. `X.Y`, `latest` and the development chart `0.0.0-dev` are meant to move. diff --git a/scripts/release/release.test.mjs b/scripts/release/release.test.mjs index f374a9bf..7397a23d 100644 --- a/scripts/release/release.test.mjs +++ b/scripts/release/release.test.mjs @@ -497,10 +497,9 @@ test('promote-latest touches the registry only after its commit is confirmed as assert.ok(steps[tip].includes('set -euo pipefail')); // Everything before the check is the checkout that provides the script. - assert.deepEqual( - steps.slice(0, tip).map((step) => step.split('\n')[0]), - ['uses: actions/checkout@v4'], - ); + const before = steps.slice(0, tip).map((step) => step.split('\n')[0]); + assert.equal(before.length, 1); + assert.match(before[0], /^uses: actions\/checkout@[0-9a-f]{40} # v\d/); // Everything after it stands down with it: no login, no retag. const after = steps.slice(tip + 1); assert.ok(after.some((step) => step.includes('docker/login-action')));